diff --git a/README.md b/README.md index 3efac8d3..40d548ff 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode / Kimi / ## Features - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. +- **Shared Codex Sessions** - Terminal and Web/phone use the same Codex engine simultaneously; normal terminal/Runner lifecycle, with resume after exit. Requires Codex 0.154.0+. [Lifecycle and limits](docs/guide/codex-shared-sessions.md). - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. - **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, Kimi, Copilot, Antigravity, Pi, DeepSeek Harness—different agents, one unified workflow. diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt index 25ef1108..e3a908c8 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -886,16 +886,33 @@ class ChatViewModel( if (text.isEmpty() && attachmentMetadata == null) return composerText.value = "" draftJob?.cancel() + sendInFlight.value = true scope.launch { - drafts?.let { runCatching { it.clear(sessionId) } } - performSend( - text = text, - localId = localIdGenerator(), - createdAt = now(), - deliveryMode = if (steer) "steer" else "queue", - attachments = attachmentMetadata, - isRetry = false, - ) + try { + drafts?.let { runCatching { it.clear(sessionId) } } + if (attachmentMetadata == null && (text == "/clear" || text == "/new") && + sessionStore.sessionDetail(sessionId).first()?.metadata?.capabilities?.concurrentClients == true) { + sendInFlight.value = true + try { + val result = api.clearConversation(sessionId) + _events.tryEmit(ChatEvent.SessionSuperseded(result.sessionId)) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + composerText.value = text + _events.tryEmit(ChatEvent.Notice(ChatNotice.ReopenFailed(error.message))) + } finally { sendInFlight.value = false } + return@launch + } + performSend( + text = text, + localId = localIdGenerator(), + createdAt = now(), + deliveryMode = if (steer) "steer" else "queue", + attachments = attachmentMetadata, + isRetry = false, + ) + } finally { sendInFlight.value = false } } } @@ -1524,9 +1541,11 @@ class ChatViewModel( return SessionConfigUi( flavor = flavor, active = detail?.active ?: summary?.active ?: false, - controlledByUser = detail?.agentState?.controlledByUser == true, + controlledByUser = detail?.agentState?.controlledByUser == true && detail?.metadata?.capabilities?.concurrentClients != true, permissionMode = detail?.permissionMode, - permissionModes = PermissionModes.forFlavor(flavor), + permissionModes = PermissionModes.forFlavor(flavor).filter { + detail?.metadata?.capabilities?.concurrentClients != true || it != PermissionMode.SafeYolo + }, model = model, modelOptions = modelOptions, modelOptionsLoading = modelOptionsLoading, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt index 30cbc877..bf88b293 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt @@ -253,6 +253,7 @@ private fun PermissionStateRow(permission: ToolPermission) { stringResource(R.string.chat_tool_denied) + (permission.reason?.let { " · $it" } ?: ""), error = true, ) + "resolved" -> PermissionLine(stringResource(R.string.chat_tool_resolved)) "canceled" -> PermissionLine(stringResource(R.string.chat_tool_canceled)) } } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt index b09a3ead..cec31227 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt @@ -278,7 +278,7 @@ object NewSessionLogic { sessionType = draft.sessionType, worktreeName = draft.worktreeName, ) - val allowedModes = PermissionModes.forFlavor(base.agent).map { it.wireId } + val allowedModes = PermissionModes.forLaunch(base.agent).map { it.wireId } val sessionType = if (base.sessionType == SESSION_TYPE_WORKTREE) SESSION_TYPE_WORKTREE else SESSION_TYPE_SIMPLE return base.copy( permissionMode = if (base.permissionMode in allowedModes) base.permissionMode else "default", diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt index b9c2131c..8697defa 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt @@ -736,7 +736,6 @@ private fun NewSessionPreviewCodexWorktree() { listOf( OptionItem("default", "Default"), OptionItem("read-only", "Read Only"), - OptionItem("safe-yolo", "Safe Yolo"), OptionItem("yolo", "Yolo"), ), ), diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt index a3fb70e8..f7fa13ef 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt @@ -805,7 +805,7 @@ class NewSessionViewModel( val permission: PermissionUi = when { agent == "pi" || agent == "dsh" -> PermissionUi.Managed usesNativePermissionSelect(agent) -> PermissionUi.NativeSelect( - PermissionModes.forFlavor(agent).map { OptionItem(it.wireId, it.label) }, + PermissionModes.forLaunch(agent).map { OptionItem(it.wireId, it.label) }, ) else -> PermissionUi.YoloToggle(hapiYoloNativeMode(agent)?.label) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index eecbf580..03778240 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -318,6 +318,7 @@ ⏳ Awaiting approval ✓ Approved ✕ Denied + Resolved in Codex — Canceled pending error diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelInteractionTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelInteractionTest.kt index 9a4a98de..6cc997fc 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelInteractionTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelInteractionTest.kt @@ -218,6 +218,8 @@ private class RecordingChatApi : ChatSessionApi { configCalls.value = configCalls.value + "abort:$sessionId" } + override suspend fun clearConversation(sessionId: String): ResumeSessionResponse = error("Unexpected clear") + override suspend fun resumeSession(sessionId: String, permissionMode: String?): ResumeSessionResponse { resumeCalls.value = resumeCalls.value + (sessionId to permissionMode) return resumeResult ?: throw RuntimeException("resume not scripted") diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt index 256a021e..00271dd3 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt @@ -180,6 +180,8 @@ private open class FakeMessagesApi : ChatSessionApi { override suspend fun steerMessage(sessionId: String, messageId: String): SteerQueuedMessageResponse = SteerQueuedMessageResponse(status = "steered", localId = messageId) override suspend fun abortSession(sessionId: String) {} + override suspend fun clearConversation(sessionId: String): ResumeSessionResponse = error("Unexpected clear") + override suspend fun resumeSession(sessionId: String, permissionMode: String?): ResumeSessionResponse = ResumeSessionResponse(sessionId = sessionId) override suspend fun approvePermission(sessionId: String, requestId: String, options: ApprovePermissionRequest) {} diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt index 6762aa4d..30206f03 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt @@ -206,7 +206,7 @@ class SpawnBodyTest { agent = "codex", model = "gpt-5.2-codex", modelReasoningEffort = "high", - permissionMode = "safe-yolo", + permissionMode = "read-only", yolo = true, // must NOT leak into the body for codex-family sessionType = SESSION_TYPE_WORKTREE, worktreeName = " feature-x ", @@ -226,7 +226,7 @@ class SpawnBodyTest { assertEquals("codex", body["agent"]!!.jsonPrimitive.content) assertEquals("gpt-5.2-codex", body["model"]!!.jsonPrimitive.content) assertEquals("high", body["modelReasoningEffort"]!!.jsonPrimitive.content) - assertEquals("safe-yolo", body["permissionMode"]!!.jsonPrimitive.content) + assertEquals("read-only", body["permissionMode"]!!.jsonPrimitive.content) assertEquals("worktree", body["sessionType"]!!.jsonPrimitive.content) assertEquals("feature-x", body["worktreeName"]!!.jsonPrimitive.content) assertEquals("fast", body["serviceTier"]!!.jsonPrimitive.content) @@ -401,10 +401,12 @@ class NewSessionLogicTest { ) assertEquals("default", badMode.permissionMode) - val goodMode = NewSessionLogic.sanitizeDraft( + val staleMode = NewSessionLogic.sanitizeDraft( NewSessionForm(agent = "codex", permissionMode = "safe-yolo"), ) - assertEquals("safe-yolo", goodMode.permissionMode) + assertEquals("default", staleMode.permissionMode) + assertEquals(listOf("default", "read-only", "yolo"), app.hapi.protocol.catalog.PermissionModes.forLaunch("codex").map { it.wireId }) + assertEquals("safe-yolo", NewSessionLogic.sanitizeDraft(NewSessionForm(agent = "kimi", permissionMode = "safe-yolo")).permissionMode) } } diff --git a/android/core/data/src/main/kotlin/app/hapi/data/api/ChatSessionApi.kt b/android/core/data/src/main/kotlin/app/hapi/data/api/ChatSessionApi.kt index 9abd47b9..01784ec9 100644 --- a/android/core/data/src/main/kotlin/app/hapi/data/api/ChatSessionApi.kt +++ b/android/core/data/src/main/kotlin/app/hapi/data/api/ChatSessionApi.kt @@ -42,6 +42,9 @@ interface AttachmentUploadApi { * and [AttachmentUploadApi] for the composer attachment flow (B-M3f). */ interface ChatSessionApi : MessagesApi, AttachmentUploadApi { + /** Shared root creation; only the initiating client navigates. */ + suspend fun clearConversation(sessionId: String): ResumeSessionResponse + /** `POST /api/sessions/:id/messages` — `{ok:true}`; the row arrives via SSE. */ suspend fun sendMessage(sessionId: String, message: SendMessageRequest) diff --git a/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt b/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt index cf004714..67111f01 100644 --- a/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt +++ b/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt @@ -280,6 +280,10 @@ class HapiApi internal constructor( QueuedStateRequest(localIds).toJsonBody(), ) + /** `POST /api/sessions/:id/clear` — shared sessions; returns an independent root. */ + override suspend fun clearConversation(sessionId: String): ResumeSessionResponse = + request("POST", url("api", "sessions", sessionId, "clear").build(), EMPTY_JSON) + /** `POST /api/sessions/:id/abort` — active sessions only. */ override suspend fun abortSession(sessionId: String) { request("POST", url("api", "sessions", sessionId, "abort").build(), EMPTY_JSON) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt index c51a2619..f2a7770b 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt @@ -97,6 +97,10 @@ object PermissionModes { /** `isPermissionModeAllowedForFlavor`. */ fun isAllowedForFlavor(mode: PermissionMode, flavor: String?): Boolean = mode in forFlavor(flavor) + + /** New Codex sessions use the shared runtime, without HAPI Safe Yolo. */ + fun forLaunch(flavor: String?): List = + forFlavor(flavor).filter { flavor != "codex" || it != PermissionMode.SafeYolo } } /** `CODEX_COLLABORATION_MODES` + labels. */ diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt index 3aa787c9..8e44338f 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionMetadata.kt @@ -67,6 +67,7 @@ data class WorktreeMetadata( /** `SessionCapabilitiesSchema`. */ @Serializable data class SessionCapabilities( + val concurrentClients: Boolean? = null, val terminal: Boolean? = null, val conversationHistory: ConversationHistoryCapabilities? = null, ) diff --git a/bun.lock b/bun.lock index cbf6e156..e02f108b 100644 --- a/bun.lock +++ b/bun.lock @@ -35,11 +35,13 @@ "socket.io-client": "^4.8.3", "tar": "^7.5.2", "vscode-jsonrpc": "8.2.0", + "ws": "8.18.3", "yaml": "^2.8.2", "zod": "^4.2.1", }, "devDependencies": { "@types/node": "26.3.0", + "@types/ws": "8.18.1", "bun-types": "1.4.0", "dotenv": "^17.2.3", "typescript": "^5", @@ -1273,6 +1275,8 @@ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -3181,7 +3185,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -3763,10 +3767,6 @@ "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "ink/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "jsdom/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "leva/zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="], "light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -3879,6 +3879,8 @@ "rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], "redent/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], diff --git a/cli/README.md b/cli/README.md index 2c2e2eb8..c7bc05ef 100644 --- a/cli/README.md +++ b/cli/README.md @@ -49,6 +49,15 @@ hapi resume `hapi resume` lists resumable sessions for the current machine. `hapi resume ` hands off an active remote session and opens the same HAPI session in the local terminal. +**Codex exception:** Codex 0.154.0+ uses shared sessions, not handoff. `hapi +resume ` attaches another official TUI to its live execution; Web and other +terminals remain usable. The original terminal owns a terminal-created execution: +its exit stops that execution, leaving resumable history. Web-created/resumed +executions use the existing Runner; additional terminals only detach on exit. +**End session** archives the selected root. Native profile-v2 launch selection and in-place rewind are currently +unavailable. See [Codex shared sessions](../docs/guide/codex-shared-sessions.md) +for queue semantics, environment isolation, recovery, supported flags and tests. + ### Answer local Claude prompts from HAPI In a local Claude session started by `hapi`, main-session `AskUserQuestion` diff --git a/cli/package.json b/cli/package.json index ee7069a1..11117476 100644 --- a/cli/package.json +++ b/cli/package.json @@ -54,7 +54,6 @@ }, "dependencies": { "@hapi/protocol": "workspace:*", - "hapi-hub": "workspace:*", "@modelcontextprotocol/sdk": "^1.25.1", "@types/cross-spawn": "^6.0.6", "@types/ps-list": "^6.2.1", @@ -64,17 +63,20 @@ "cross-spawn": "^7.0.6", "fastify": "^5.6.2", "fastify-type-provider-zod": "6.1.0", + "hapi-hub": "workspace:*", "ink": "^6.6.0", "ps-list": "^9.0.0", "react": "^19.2.3", "socket.io-client": "^4.8.3", "tar": "^7.5.2", "vscode-jsonrpc": "8.2.0", + "ws": "8.18.3", "yaml": "^2.8.2", "zod": "^4.2.1" }, "devDependencies": { "@types/node": "26.3.0", + "@types/ws": "8.18.1", "bun-types": "1.4.0", "dotenv": "^17.2.3", "typescript": "^5", diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index e42e767a..79a47ba1 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -21,6 +21,9 @@ export { HAPI_SESSION_ID_ENV, exportHapiSessionEnv, exportHapiHubAuthEnv } from export type SessionStartedBy = 'runner' | 'terminal' export type SessionBootstrapOptions = { + reportStarted?: boolean + /** Multi-session workers inject session identity into each child, never process.env. */ + exportSessionEnv?: boolean flavor: string startedBy?: SessionStartedBy workingDirectory?: string @@ -229,9 +232,9 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis const session = api.sessionSyncClient(sessionInfo) - exportHapiSessionEnv(sessionInfo.id) + if (options.exportSessionEnv !== false) exportHapiSessionEnv(sessionInfo.id) - await reportSessionStarted(sessionInfo.id, metadata) + if (options.reportStarted !== false) await reportSessionStarted(sessionInfo.id, metadata) return { api, @@ -314,7 +317,7 @@ export async function bootstrapLazySession(options: SessionBootstrapOptions): Pr // Export only after the hub row exists. Exporting the provisional id at // bootstrap lets agents inherit HAPI_SESSION_ID before GET /api/sessions/:id // can resolve (and before hapiMcpUrl is persisted) — #1119 / PR #1121 Major. - exportHapiSessionEnv(materialized.id) + if (options.exportSessionEnv !== false) exportHapiSessionEnv(materialized.id) void reportSessionStarted(materialized.id, snapshot.metadata ?? metadata) } }) @@ -331,6 +334,8 @@ export async function bootstrapLazySession(options: SessionBootstrapOptions): Pr } export async function bootstrapExistingSession(options: { + reportStarted?: boolean + exportSessionEnv?: boolean sessionId: string flavor: string startedBy?: SessionStartedBy @@ -371,9 +376,9 @@ export async function bootstrapExistingSession(options: { const session = api.sessionSyncClient(sessionInfo) session.updateMetadata(buildUpdatedMetadata) - exportHapiSessionEnv(sessionInfo.id) + if (options.exportSessionEnv !== false) exportHapiSessionEnv(sessionInfo.id) - await reportSessionStarted(sessionInfo.id, metadata) + if (options.reportStarted !== false) await reportSessionStarted(sessionInfo.id, metadata) return { api, diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 4a510ffd..3792ce80 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -447,6 +447,26 @@ describe('ApiSessionClient agy transcript messages', () => { }) describe('ApiSessionClient incoming user messages', () => { + it.each([true, false])('replays explicitly marked native queue input only for shared sessions (%s)', shared => { + socketHarness.sockets.length = 0 + const client = new ApiSessionClient('token', createSession({ + metadata: { path: '/tmp', host: 'test', capabilities: { concurrentClients: shared } } + })) + const socket = socketHarness.sockets[0] + const received = vi.fn() + client.onUserMessage(received) + const message = { id: 'native-queued', seq: 10, localId: 'native-client-id', content: { + role: 'user', content: { type: 'text', text: 'queued before exit' }, + meta: { sentFrom: 'cli', isNativeQueuedMessage: true } + } } + socket.trigger('update', { body: { t: 'new-message', message } }) + socket.trigger('update', { body: { t: 'new-message', message } }) + expect(received).toHaveBeenCalledTimes(shared ? 1 : 0) + if (shared) expect(received).toHaveBeenCalledWith(expect.objectContaining({ + content: { type: 'text', text: 'queued before exit' } + }), 'native-client-id') + client.close() + }) it('ignores CLI-originated transcript messages while advancing the incoming cursor', () => { socketHarness.sockets.length = 0 const client = new ApiSessionClient('token', createSession({ namespace: 'default' })) diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 64766f17..aed3ba04 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -1,3 +1,4 @@ +type QueueCancelResult = boolean | 'in-flight' | 'indeterminate' | 'consumed' import { EventEmitter } from 'node:events' import { randomUUID } from 'node:crypto' import { io, type Socket } from 'socket.io-client' @@ -36,7 +37,7 @@ import type { import { AgentStateSchema, CliMessagesResponseSchema, MetadataSchema, UserMessageSchema } from './types' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' -import { cleanupUploadDir } from '../modules/common/handlers/uploads' +import { cleanupUploadDir, preserveUploadDirOnExit } from '../modules/common/handlers/uploads' import { TerminalManager } from '@/terminal/TerminalManager' import { applyVersionedAck } from './versionedUpdate' import { buildHubRequestHeaders, buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' @@ -231,6 +232,8 @@ function hasSameJsonValue(left: unknown, right: unknown): boolean { } export class ApiSessionClient extends EventEmitter { + private reconnectHandler: (() => void) | null = null + onReconnect(handler: (() => void) | null): void { this.reconnectHandler = handler } private readonly token: string readonly sessionId: string private metadata: Metadata | null @@ -241,8 +244,8 @@ export class ApiSessionClient extends EventEmitter { private pendingMessages: { message: UserMessage; localId?: string }[] = [] private pendingHubPromptEchoes: { text: string; localIds: string[] }[] = [] private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null - private cancelQueuedMessageCallback: ((localId: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed') | null = null - private retryQueuedMessageCallback: ((localId: string) => boolean) | null = null + private cancelQueuedMessageCallback: ((localId: string) => QueueCancelResult | Promise) | null = null + private retryQueuedMessageCallback: ((localId: string) => boolean | Promise) | null = null private readonly incomingFilter = new IncomingMessageFilter() private backfillInFlight: Promise | null = null private needsBackfill = false @@ -334,6 +337,7 @@ export class ApiSessionClient extends EventEmitter { } void this.backfillIfNeeded() this.hasConnectedOnce = true + this.reconnectHandler?.() this.socket.emit('session-alive', { sid: this.sessionId, time: Date.now(), @@ -422,7 +426,7 @@ export class ApiSessionClient extends EventEmitter { this.agentTerminalActive = false })) - this.socket.on('update', (data: Update, ack?: (response: { removed: boolean; inFlight?: boolean; indeterminate?: boolean; accepted?: boolean; consumed?: boolean }) => void) => { + this.socket.on('update', async (data: Update, ack?: (response: { removed: boolean; inFlight?: boolean; indeterminate?: boolean; accepted?: boolean; consumed?: boolean }) => void) => { try { if (!data.body) return @@ -436,10 +440,11 @@ export class ApiSessionClient extends EventEmitter { // reservation and bypass the normal message-id dedup. let accepted = true if (data.body.localId && this.cancelQueuedMessageCallback) { - const cancelled = this.cancelQueuedMessageCallback(data.body.localId) + const cancellation = this.cancelQueuedMessageCallback(data.body.localId) + const cancelled = cancellation instanceof Promise ? await cancellation : cancellation accepted = cancelled !== 'in-flight' && cancelled !== 'consumed' if (cancelled === 'indeterminate') { - accepted = this.retryQueuedMessageCallback?.(data.body.localId) === true + accepted = await this.retryQueuedMessageCallback?.(data.body.localId) === true } if (cancelled === 'consumed') { ack?.({ removed: false, accepted: false, consumed: true }) @@ -454,9 +459,10 @@ export class ApiSessionClient extends EventEmitter { } if (data.body.t === 'cancel-queued-message') { - const result = (data.body.localId && this.cancelQueuedMessageCallback) + const cancellation = (data.body.localId && this.cancelQueuedMessageCallback) ? this.cancelQueuedMessageCallback(data.body.localId) : false + const result = cancellation instanceof Promise ? await cancellation : cancellation // 'in-flight' = the row is inside an async steer: not // removed, but also NOT consumed — the hub must neither // delete it nor stamp invoked_at. @@ -499,6 +505,11 @@ export class ApiSessionClient extends EventEmitter { this.emit('message', data.body) } catch (error) { logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error }) + // A failed asynchronous native cancellation is not permission + // for the hub's best-effort path to delete a possibly live row. + if (data.body?.t === 'cancel-queued-message' || data.body?.t === 'retry-queued-message') { + ack?.({ removed: false, accepted: false, indeterminate: true }) + } } }) @@ -696,11 +707,11 @@ export class ApiSessionClient extends EventEmitter { } } - onCancelQueuedMessage(callback: (localId: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed'): void { + onCancelQueuedMessage(callback: (localId: string) => QueueCancelResult | Promise): void { this.cancelQueuedMessageCallback = callback } - onRetryQueuedMessage(callback: (localId: string) => boolean): void { + onRetryQueuedMessage(callback: (localId: string) => boolean | Promise): void { this.retryQueuedMessageCallback = callback } @@ -782,7 +793,10 @@ export class ApiSessionClient extends EventEmitter { // User messages mirrored from a local agent transcript are history, // not new remote input. Keep them in the incoming filter above so // reconnect backfill still advances and deduplicates correctly. - if (userResult.data.meta?.sentFrom === 'cli') { + if (userResult.data.meta?.sentFrom === 'cli' + && !(userResult.data.meta.isNativeQueuedMessage === true + && this.metadata?.capabilities?.concurrentClients === true + && message.localId)) { return } this.enqueueUserMessage(userResult.data, message.localId ?? undefined) @@ -1013,7 +1027,7 @@ export class ApiSessionClient extends EventEmitter { }) } - sendUserMessage(text: string, meta?: MessageMeta): void { + sendUserMessage(text: string, meta?: MessageMeta, localId?: string): void { if (!text) { return } @@ -1033,17 +1047,23 @@ export class ApiSessionClient extends EventEmitter { this.emitOrQueue(() => { this.socket.emit('message', { sid: this.sessionId, - message: content + message: content, + localId }) + if (localId) this.socket.emit('messages-consumed', { sid: this.sessionId, localIds: [localId] }) }) this.notifyUserActivity() } + syncNativeQueuedMessage(localId: string, text: string | null): void { + this.emitOrQueue(() => this.socket.emit('native-queue-message', { sid: this.sessionId, localId, text })) + } + notifyUserActivity(): void { void this.materialize() } - sendAgentMessage(body: unknown): void { + sendAgentMessage(body: unknown, localId?: string): void { const content = { role: 'agent', content: { @@ -1057,8 +1077,10 @@ export class ApiSessionClient extends EventEmitter { this.emitOrQueue(() => { this.socket.emit('message', { sid: this.sessionId, - message: content + message: content, + localId }) + if (localId) this.socket.emit('messages-consumed', { sid: this.sessionId, localIds: [localId] }) }) } @@ -1180,7 +1202,7 @@ export class ApiSessionClient extends EventEmitter { keepAlive( thinking: boolean, - mode: 'local' | 'remote', + mode: 'local' | 'remote' | undefined, runtime?: { permissionMode?: SessionPermissionMode model?: SessionModel @@ -1264,9 +1286,10 @@ export class ApiSessionClient extends EventEmitter { })) } - sendSessionDeath(reason?: SessionEndReason): void { + sendSessionDeath(reason?: SessionEndReason, options?: { preserveUploads?: boolean }): void { if (this.state === 'active') { - void cleanupUploadDir(this.sessionId) + if (options?.preserveUploads) preserveUploadDirOnExit(this.sessionId) + else void cleanupUploadDir(this.sessionId) } this.emitOrQueue(() => { this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason }) diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index ae06f860..2eaf4048 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -71,6 +71,8 @@ export type { export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), + // Shared Codex queue mirrors are replayable input, not transcript echoes. + isNativeQueuedMessage: z.boolean().optional(), // Claude jsonl echoes the remote (web/telegram) prompt as a second user row. // Hub notify ingest skips these so they do not consume a work_ad cause slot. isTranscriptEcho: z.boolean().optional(), diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index 6cad414c..32a72d9c 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -1,5 +1,6 @@ import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { existsSync } from 'node:fs'; +import WebSocket from 'ws'; import { logger } from '@/ui/logger'; import { JsonLineParser } from '@/utils/jsonLineParser'; import { killProcessByChildProcess } from '@/utils/process'; @@ -82,6 +83,9 @@ export function isIndeterminateError(error: unknown): boolean { type CodexAppServerClientOptions = { cwd?: string; + /** An independently owned shared server. Disconnect only detaches this client. */ + endpoint?: string; + token?: string; }; function asRecord(value: unknown): Record | null { @@ -133,7 +137,7 @@ function compareVersion(a: number[] | null, b: number[] | null): number { return 0; } -function resolveCodexAppServerCommand(): string { +export function resolveCodexAppServerCommand(): string { if (process.env.HAPI_CODEX_APP_SERVER_BIN) { return process.env.HAPI_CODEX_APP_SERVER_BIN; } @@ -176,6 +180,9 @@ function resolveCodexAppServerCommand(): string { } export class CodexAppServerClient extends JsonLineParser { + private connecting: Promise | null = null; + private socket: WebSocket | null = null; + private serverRequestHandler: ((request: { id: string | number; method: string; params: unknown }) => void) | null = null; private process: ChildProcessWithoutNullStreams | null = null; private connected = false; private initialized = false; @@ -208,10 +215,46 @@ export class CodexAppServerClient extends JsonLineParser { } async connect(): Promise { + if (this.connecting) return this.connecting; + this.connecting = this.connectTransport(); + try { await this.connecting; } finally { this.connecting = null; } + } + + private async connectTransport(): Promise { if (this.connected) { return; } + if (this.options.endpoint) { + const endpoint = this.options.endpoint; + const url = endpoint.startsWith('unix://') ? `ws+unix://${endpoint.slice(7)}:/` : endpoint; + const socket = new WebSocket(url, { + headers: { Host: 'localhost', ...(this.options.token ? { Authorization: `Bearer ${this.options.token}` } : {}) }, + maxPayload: 64 * 1024 * 1024, + perMessageDeflate: false, + handshakeTimeout: 10_000 + }); + this.socket = socket; + socket.on('message', data => { + if (this.socket === socket) this.handleLine(data.toString()); + }); + socket.on('error', error => logger.debug('[CodexAppServer] WebSocket error', error)); + socket.on('close', () => { + if (this.socket !== socket) return; + this.socket = null; + this.connected = this.initialized = false; + this.rejectAllPending(new Error('Shared Codex connection closed')); + this.resetParserState(); + this.transportAbandonedHandler?.(); + }); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + }); + this.connected = true; + return; + } + const codexCommand = resolveCodexAppServerCommand(); logger.debug(`[CodexAppServer] Starting ${codexCommand} app-server`); const child = spawn(codexCommand, ['app-server'], { @@ -289,6 +332,18 @@ export class CodexAppServerClient extends JsonLineParser { this.requestHandlers.set(method, handler); } + setServerRequestHandler(handler: typeof this.serverRequestHandler): void { + this.serverRequestHandler = handler; + } + + respond(id: string | number, result: unknown): void { + this.writePayload({ id, result }); + } + + async request(method: string, params?: unknown): Promise { + return await this.sendRequest(method, params, { timeoutMs: 20_000 }) as T; + } + async initialize(params: InitializeParams): Promise { const response = await this.sendRequest('initialize', params, { timeoutMs: 30_000 }); this.sendNotification('initialized'); @@ -363,11 +418,12 @@ export class CodexAppServerClient extends JsonLineParser { return response as ThreadForkResponse; } - async supportsMethod(method: 'thread/fork' | 'thread/rollback' | 'thread/settings/update'): Promise { + async supportsMethod(method: string): Promise { try { await this.sendRequest(method, { threadId: '__hapi_capability_probe__' }, { timeoutMs: 30_000 }); return true; } catch (error) { + if (this.options.endpoint && isIndeterminateError(error)) throw error; return !/method not found|unknown method|unsupported/i.test( error instanceof Error ? error.message : String(error) ); @@ -472,6 +528,15 @@ export class CodexAppServerClient extends JsonLineParser { } async disconnect(): Promise { + if (this.socket) { + const socket = this.socket; + this.socket = null; + this.connected = this.initialized = false; + socket.close(); + this.rejectAllPending(new Error('Shared Codex client detached')); + this.resetParserState(); + return; + } if (!this.connected) { return; } @@ -520,8 +585,14 @@ export class CodexAppServerClient extends JsonLineParser { throw createAbortError(); } if (!this.connected) { + if (this.options.endpoint && method !== 'initialize') { + throw new Error('Shared Codex client disconnected; reconnect and initialize before sending'); + } await this.connect(); } + if (this.options.endpoint && !this.initialized && method !== 'initialize') { + throw new Error('Shared Codex client is not initialized'); + } if (options?.signal?.aborted) { throw createAbortError(); } @@ -561,6 +632,8 @@ export class CodexAppServerClient extends JsonLineParser { }; const abandonUnconfirmedDispatch = (error: Error) => { + this.socket?.terminate(); + this.socket = null; const child = this.process; this.process = null; this.connected = false; @@ -646,7 +719,7 @@ export class CodexAppServerClient extends JsonLineParser { try { const serialized = JSON.stringify(payload); - this.process?.stdin.write(`${serialized}\n`, (error) => { + const onWrite = (error?: Error | null) => { if (error) { const writeError = error instanceof Error ? error : new Error(String(error)); failRequest(this.markIndeterminate(writeError), !dispatchSettled); @@ -656,7 +729,10 @@ export class CodexAppServerClient extends JsonLineParser { dispatchSettled = true; resolveDispatched(); } - }); + }; + if (this.socket) this.socket.send(serialized, onWrite); + else if (this.process) this.process.stdin.write(`${serialized}\n`, onWrite); + else failRequest(this.markIndeterminate(new Error('Codex transport is unavailable'))); } catch (error) { const writeError = error instanceof Error ? error : new Error(String(error)); failRequest(writeError); @@ -689,6 +765,7 @@ export class CodexAppServerClient extends JsonLineParser { logger.debug('[CodexAppServer] Failed to parse JSON line', { line, error }); this.rejectAllPending(protocolError); this.process?.stdin.end(); + this.socket?.close(1007, 'Invalid JSON-RPC'); return; } @@ -698,6 +775,14 @@ export class CodexAppServerClient extends JsonLineParser { if ('id' in message && message.id !== undefined) { const requestId = message.id; + if (this.options.endpoint) { + // A side client must not consume requests it does not own, even + // with a JSON-RPC error: the first response wins upstream. + if (typeof requestId === 'string' || typeof requestId === 'number') { + this.serverRequestHandler?.({ id: requestId, method, params }); + } + return; + } const sourceProcess = this.process; void this.handleIncomingRequest({ id: requestId, @@ -795,6 +880,10 @@ export class CodexAppServerClient extends JsonLineParser { payload: JsonRpcLiteRequest | JsonRpcLiteNotification | JsonRpcLiteResponse, targetProcess: ChildProcessWithoutNullStreams | null = this.process ): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(payload)); + return; + } if (!targetProcess || targetProcess !== this.process) { return; } diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 3f3d0c3d..ad3dbf8f 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -1,11 +1,4 @@ -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { logger } from '@/ui/logger'; -import { runLocalRemoteSession } from '@/agent/loopBase'; -import { CodexSession } from './session'; -import { codexLocalLauncher } from './codexLocalLauncher'; -import { codexRemoteLauncher } from './codexRemoteLauncher'; -import { ApiClient, ApiSessionClient } from '@/lib'; -import type { CodexCliOverrides } from './utils/codexCliOverrides'; +// Mode data retained for protocol/config helpers. Codex execution is shared/runtime.ts. import type { ReasoningEffort } from './appServerTypes'; import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types'; @@ -28,58 +21,3 @@ export interface EnhancedMode { /** When set, forwarded to app-server thread/turn params. */ personality?: CodexPersonality; } - -interface LoopOptions { - path: string; - startingMode?: 'local' | 'remote'; - startedBy?: 'runner' | 'terminal'; - onModeChange: (mode: 'local' | 'remote') => void; - messageQueue: MessageQueue2; - session: ApiSessionClient; - api: ApiClient; - codexArgs?: string[]; - codexCliOverrides?: CodexCliOverrides; - permissionMode?: PermissionMode; - model?: string; - modelReasoningEffort?: ReasoningEffort; - collaborationMode?: CodexCollaborationMode; - resumeSessionId?: string; - sourceSessionId?: string; - replayTranscriptHistoryOnStart?: boolean; - onSessionReady?: (session: CodexSession) => void; -} - -export async function loop(opts: LoopOptions): Promise { - const logPath = logger.getLogPath(); - const startedBy = opts.startedBy ?? 'terminal'; - const startingMode = opts.startingMode ?? 'local'; - const session = new CodexSession({ - api: opts.api, - client: opts.session, - path: opts.path, - sessionId: opts.resumeSessionId ?? null, - logPath, - messageQueue: opts.messageQueue, - onModeChange: opts.onModeChange, - mode: startingMode, - startedBy, - startingMode, - codexArgs: opts.codexArgs, - codexCliOverrides: opts.codexCliOverrides, - permissionMode: opts.permissionMode ?? 'default', - model: opts.model, - modelReasoningEffort: opts.modelReasoningEffort, - collaborationMode: opts.collaborationMode ?? 'default', - sourceSessionId: opts.sourceSessionId, - replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false - }); - - await runLocalRemoteSession({ - session, - startingMode: opts.startingMode, - logTag: 'codex-loop', - runLocal: codexLocalLauncher, - runRemote: codexRemoteLauncher, - onSessionReady: opts.onSessionReady - }); -} diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 75b0bb5d..2b054d74 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -1,310 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { runCodex } from './runCodex' +import { describe, expect, it, vi } from 'vitest'; +const run = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); +vi.mock('./shared/frontend', () => ({ runSharedCodex: run })); +import { runCodex } from './runCodex'; -const mockCodexSession = vi.hoisted(() => ({ - getPermissionMode: vi.fn<() => string | undefined>(), - getModel: vi.fn<() => string | null | undefined>(), - getModelReasoningEffort: vi.fn<() => string | null | undefined>(), - getServiceTier: vi.fn<() => string | null | undefined>(), - getCollaborationMode: vi.fn<() => string | undefined>(), - setPermissionMode: vi.fn(), - setModel: vi.fn(), - setModelReasoningEffort: vi.fn(), - setServiceTier: vi.fn(), - setCollaborationMode: vi.fn(), - stopKeepAlive: vi.fn() -})) - -const harness = vi.hoisted(() => ({ - bootstrapArgs: [] as Array>, - loopArgs: [] as Array>, - sessionInfo: { serviceTier: null as string | null } as Record, - session: { - onUserMessage: vi.fn(), - onCancelQueuedMessage: vi.fn(), - onRetryQueuedMessage: vi.fn(), - rpcHandlerManager: { - registerHandler: vi.fn() - } - } -})) - -vi.mock('@/agent/sessionFactory', () => ({ - bootstrapSession: vi.fn(async (options: Record) => { - harness.bootstrapArgs.push(options) - return { - api: {}, - session: harness.session, - sessionInfo: harness.sessionInfo - } - }), - bootstrapLazySession: vi.fn(async (options: Record) => { - harness.bootstrapArgs.push({ ...options, lazy: true }) - return { - api: {}, - session: harness.session, - sessionInfo: harness.sessionInfo - } - }), - bootstrapExistingSession: vi.fn(async (options: Record) => { - harness.bootstrapArgs.push(options) - return { - api: {}, - session: harness.session, - sessionInfo: harness.sessionInfo - } - }) -})) - -vi.mock('./loop', () => ({ - loop: vi.fn(async (options: Record) => { - harness.loopArgs.push(options) - const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined - onSessionReady?.(mockCodexSession) - }) -})) - -vi.mock('@/claude/registerKillSessionHandler', () => ({ - registerKillSessionHandler: vi.fn() -})) - -const lifecycleMock = vi.hoisted(() => ({ - registerProcessHandlers: vi.fn(), - cleanupAndExit: vi.fn(async () => {}), - markCrash: vi.fn(), - setExitCode: vi.fn(), - setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn(), - hasExplicitSessionEndReason: vi.fn(() => false) -})) - -vi.mock('@/agent/runnerLifecycle', () => ({ - createModeChangeHandler: vi.fn(() => vi.fn()), - createRunnerLifecycle: vi.fn(() => lifecycleMock), - setControlledByUser: vi.fn() -})) - -vi.mock('@/agent/localHandoff', () => ({ - registerLocalHandoffHandler: vi.fn() -})) - -vi.mock('@/ui/logger', () => ({ - logger: { - debug: vi.fn() - } -})) - -vi.mock('@/utils/attachmentFormatter', () => ({ - formatMessageWithAttachments: vi.fn((text: string) => text) -})) - -vi.mock('@/modules/common/slashCommands', () => ({ - listSlashCommands: vi.fn(async () => []) -})) - -vi.mock('./utils/slashCommands', () => ({ - resolveCodexSlashCommand: vi.fn(() => ({ - kind: 'passthrough' - })) -})) - -vi.mock('./codexSpecialCommands', () => ({ - parseCodexSpecialCommand: vi.fn(() => ({})) -})) - -vi.mock('./utils/codexCliOverrides', () => ({ - parseCodexCliOverrides: vi.fn(() => ({})) -})) - -import { runCodex as runCodexImpl } from './runCodex' -import { RPC_METHODS } from '@hapi/protocol/rpcMethods' - -describe('runCodex', () => { - beforeEach(() => { - harness.bootstrapArgs.length = 0 - harness.loopArgs.length = 0 - harness.sessionInfo = { serviceTier: null } - harness.session.onUserMessage.mockReset() - harness.session.onCancelQueuedMessage.mockReset() - harness.session.rpcHandlerManager.registerHandler.mockReset() - mockCodexSession.getPermissionMode.mockReset() - mockCodexSession.getModel.mockReset() - mockCodexSession.getModelReasoningEffort.mockReset() - mockCodexSession.getServiceTier.mockReset() - mockCodexSession.getCollaborationMode.mockReset() - mockCodexSession.setPermissionMode.mockReset() - mockCodexSession.setModel.mockReset() - mockCodexSession.setModelReasoningEffort.mockReset() - mockCodexSession.setServiceTier.mockReset() - mockCodexSession.setCollaborationMode.mockReset() - lifecycleMock.registerProcessHandlers.mockClear() - lifecycleMock.cleanupAndExit.mockClear() - lifecycleMock.markCrash.mockClear() - lifecycleMock.setExitCode.mockClear() - lifecycleMock.setArchiveReason.mockClear() - lifecycleMock.setSessionEndReason.mockClear() - }) - - it('uses the requested collaboration mode when resuming locally', async () => { - const options = { - existingSessionId: 'hapi-session-1', - workingDirectory: '/tmp/project', - resumeSessionId: 'codex-thread-1', - collaborationMode: 'plan' - } as Parameters[0] & { collaborationMode: 'plan' } - - await runCodexImpl(options) - - expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({ - sessionId: 'hapi-session-1', - workingDirectory: '/tmp/project' - })) - expect(harness.loopArgs[0]).toEqual(expect.objectContaining({ - resumeSessionId: 'codex-thread-1', - collaborationMode: 'plan', - replayTranscriptHistoryOnStart: false - })) - expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan') - }) - - it('preserves a persisted Fast service tier on startup', async () => { - harness.sessionInfo = { serviceTier: 'fast' } - - await runCodexImpl({ - existingSessionId: 'hapi-session-1', - workingDirectory: '/tmp/project', - resumeSessionId: 'codex-thread-1' - } as Parameters[0]) - - // The first keepalive sync must re-assert Fast, not collapse it. - expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast') - expect(mockCodexSession.setServiceTier).not.toHaveBeenCalledWith(null) - }) - - it('keeps an explicit Standard service tier sticky on startup', async () => { - harness.sessionInfo = { serviceTier: 'standard' } - - await runCodexImpl({ - existingSessionId: 'hapi-session-1', - workingDirectory: '/tmp/project', - resumeSessionId: 'codex-thread-1' - } as Parameters[0]) - - // Explicit Standard must survive resume (not be dropped to untouched), - // so later turns keep sending app-server serviceTier: null. - expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('standard') - }) - - it('prefers the spawn-time service tier override when resuming (hub passes Fast)', async () => { - // On resume the hub spawns a fresh session (serviceTier null in the new - // row) and passes the old tier via opts; the override must win so the - // resumed thread immediately runs Fast. - harness.sessionInfo = { serviceTier: null } - - await runCodexImpl({ - workingDirectory: '/tmp/project', - resumeSessionId: 'codex-thread-1', - serviceTier: 'fast' - } as Parameters[0]) - - expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast') - }) - - it('does not collapse an untouched service tier into explicit Standard on startup', async () => { - harness.sessionInfo = { serviceTier: null } - - await runCodexImpl({ - workingDirectory: '/tmp/project' - } as Parameters[0]) - - // Untouched (account-default) sessions must omit the tier entirely so - // the keepalive never persists serviceTier: null over the default. - expect(mockCodexSession.setServiceTier).not.toHaveBeenCalled() - }) - - it('does not collapse inherited Codex reasoning effort into explicit default on startup', async () => { - await runCodexImpl({ workingDirectory: '/tmp/project' }) - - expect(mockCodexSession.setModelReasoningEffort).not.toHaveBeenCalled() - expect(harness.loopArgs[0]?.modelReasoningEffort).toBeUndefined() - }) - - it('uses lazy bootstrap for a fresh terminal launch', async () => { - await runCodexImpl({ workingDirectory: '/tmp/project' }) - - expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({ - workingDirectory: '/tmp/project', - lazy: true - })) - expect(harness.loopArgs[0]).toEqual(expect.objectContaining({ - replayTranscriptHistoryOnStart: true - })) - }) - - it('keeps eager bootstrap for runner launches', async () => { - await runCodexImpl({ - startedBy: 'runner', - workingDirectory: '/tmp/project' - }) - - expect(harness.bootstrapArgs[0]).not.toHaveProperty('lazy') - }) - - it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => { - await runCodexImpl({ - workingDirectory: '/tmp/project', - resumeSessionId: 'codex-thread-2' - }) - - expect(harness.loopArgs[0]).toEqual(expect.objectContaining({ - resumeSessionId: 'codex-thread-2', - replayTranscriptHistoryOnStart: true - })) - }) - - it('accepts and normalizes model-reported reasoning efforts from session config', async () => { - await runCodexImpl({ workingDirectory: '/tmp/project' }) - - const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( - ([method]) => method === RPC_METHODS.SetSessionConfig - ) - const handler = registration?.[1] as ((payload: unknown) => Promise) | undefined - expect(handler).toBeTypeOf('function') - - await handler?.({ modelReasoningEffort: 'max' }) - await handler?.({ modelReasoningEffort: ' EXTREME ' }) - - expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(1, 'max') - expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'extreme') - }) - - it('preserves native fallback settings when changing another session setting', async () => { - await runCodexImpl({ workingDirectory: '/tmp/project', model: 'gpt-5.6', modelReasoningEffort: 'high' }) - mockCodexSession.getModel.mockReturnValue('gpt-5.6-luna') - mockCodexSession.getModelReasoningEffort.mockReturnValue('medium') - mockCodexSession.getServiceTier.mockReturnValue('standard') - const handler = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( - ([method]) => method === RPC_METHODS.SetSessionConfig - )?.[1] as (payload: unknown) => Promise - expect(await handler({ permissionMode: 'yolo' })).toEqual({ - applied: expect.objectContaining({ modelReasoningEffort: 'medium', serviceTier: 'standard' }) - }) - expect(mockCodexSession.setModelReasoningEffort).toHaveBeenLastCalledWith('medium') - }) - - it('still persists an explicit reasoning effort reset as null', async () => { - await runCodexImpl({ workingDirectory: '/tmp/project' }) - - const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( - ([method]) => method === RPC_METHODS.SetSessionConfig - ) - const handler = registration?.[1] as ((payload: unknown) => Promise) | undefined - const result = await handler?.({ modelReasoningEffort: null }) - - expect(mockCodexSession.setModelReasoningEffort).toHaveBeenCalledTimes(1) - expect(mockCodexSession.setModelReasoningEffort).toHaveBeenCalledWith(null) - expect(result).toEqual({ - applied: expect.objectContaining({ modelReasoningEffort: null }) - }) - }) -}) +describe('Codex entrypoint', () => { + it('uses the shared runtime for terminal, runner and resume (no exclusive-mode branch)', async () => { + const launches = [{ startedBy: 'terminal' as const }, { startedBy: 'runner' as const }, + { existingSessionId: 'hapi', resumeSessionId: 'native', collaborationMode: 'plan' as const }]; + for (const options of launches) await runCodex(options); + expect(run.mock.calls).toEqual(launches.map(options => [options])); + }); +}); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index dc454aec..27da81b6 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -1,461 +1,4 @@ -import { logger } from '@/ui/logger'; -import { randomUUID } from 'node:crypto'; -import { loop, type CodexPersonality, type EnhancedMode, type PermissionMode } from './loop'; -import { MessageQueue2 } from '@/utils/MessageQueue2'; -import { hashObject } from '@/utils/deterministicJson'; -import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import type { AgentState } from '@/api/types'; -import type { CodexSession } from './session'; -import { parseCodexCliOverrides } from './utils/codexCliOverrides'; -import { bootstrapExistingSession, bootstrapLazySession, bootstrapSession } from '@/agent/sessionFactory'; -import { registerLocalHandoffHandler } from '@/agent/localHandoff'; -import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; -import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; -import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; -import { getInvokedCwd } from '@/utils/invokedCwd'; -import type { ReasoningEffort } from './appServerTypes'; -import { parseCodexSpecialCommand } from './codexSpecialCommands'; -import { listSlashCommands } from '@/modules/common/slashCommands'; -import { resolveCodexSlashCommand } from './utils/slashCommands'; -import { parseReasoningEffortValue } from './utils/reasoningEffort'; - +// Codex has no local/remote handoff. Both surfaces share one isolated app-server; +// the owning terminal stops it on exit, while additional terminals only detach. +export { runSharedCodex as runCodex } from './shared/frontend'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; - -export async function runCodex(opts: { - startedBy?: 'runner' | 'terminal'; - codexArgs?: string[]; - permissionMode?: PermissionMode; - resumeSessionId?: string; - model?: string; - modelReasoningEffort?: ReasoningEffort; - serviceTier?: string; - collaborationMode?: EnhancedMode['collaborationMode']; - existingSessionId?: string; - workingDirectory?: string; -}): Promise { - const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); - const startedBy = opts.startedBy ?? 'terminal'; - - logger.debug(`[codex] Starting with options: startedBy=${startedBy}`); - - let state: AgentState = { - controlledByUser: false - }; - const useLazyBootstrap = !opts.existingSessionId && startedBy === 'terminal'; - const bootstrap = opts.existingSessionId - ? await bootstrapExistingSession({ - sessionId: opts.existingSessionId, - flavor: 'codex', - startedBy, - workingDirectory - }) - : await (useLazyBootstrap ? bootstrapLazySession : bootstrapSession)({ - flavor: 'codex', - startedBy, - workingDirectory, - agentState: state, - model: opts.model, - modelReasoningEffort: opts.modelReasoningEffort - }); - const { api, session, sessionInfo } = bootstrap; - const codexSourceSessionId = typeof sessionInfo.metadata?.codexSourceSessionId === 'string' - ? sessionInfo.metadata.codexSourceSessionId - : undefined; - - const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; - - setControlledByUser(session, startingMode); - - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model, - modelReasoningEffort: mode.modelReasoningEffort, - collaborationMode: mode.collaborationMode, - proactiveMultiAgent: mode.proactiveMultiAgent, - serviceTier: mode.serviceTier, - personality: mode.personality - })); - - const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); - const sessionWrapperRef: { current: CodexSession | null } = { current: null }; - // 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时, - // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。 - const replayTranscriptHistoryOnStart = useLazyBootstrap || Boolean(opts.resumeSessionId && !opts.existingSessionId); - - const persistedPermissionMode = sessionInfo.permissionMode ?? sessionInfo.metadata?.preferredPermissionMode; - let currentPermissionMode: PermissionMode = opts.permissionMode - ?? (persistedPermissionMode && isPermissionModeAllowedForFlavor(persistedPermissionMode, 'codex') ? persistedPermissionMode as PermissionMode : undefined) - ?? 'default'; - let currentModel = opts.model; - // Three states matter here: `undefined` inherits Codex configuration, - // `null` explicitly clears a HAPI override, and a string explicitly sets it. - let currentModelReasoningEffort: ReasoningEffort | null | undefined = opts.modelReasoningEffort; - let currentCollaborationMode: EnhancedMode['collaborationMode'] = opts.collaborationMode ?? 'default'; - let currentProactiveMultiAgent: boolean | undefined; - // Service tier (Fast mode), stored representation: `'fast'` and - // `'standard'` are explicit user choices, `undefined`/`null` mean untouched - // (use the account default). Prefer the spawn-time override (set by the hub - // when resuming a session, mirroring model/effort) so a resumed Fast/Standard - // thread immediately runs with the right tier; otherwise seed from the - // persisted session. A persisted/absent `null` stays untouched (omitted). - let currentServiceTier: string | null | undefined = opts.serviceTier ?? sessionInfo.serviceTier ?? undefined; - /** In-session override only. Undefined = omit app-server field (inherit Codex config/thread). */ - let currentPersonality: CodexPersonality | undefined; - - const lifecycle = createRunnerLifecycle({ - session, - logTag: 'codex', - stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive() - }); - - lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle); - registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); - - const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { - const sessionInstance = sessionWrapperRef.current; - if (!sessionInstance) { - return; - } - sessionInstance.setPermissionMode(currentPermissionMode); - if (options?.syncModel !== false) { - sessionInstance.setModel(currentModel ?? null); - } - // Do not collapse inherited Codex config into an explicit default. - // Explicit clears remain `null` and must still be synchronized. - if (currentModelReasoningEffort !== undefined) { - sessionInstance.setModelReasoningEffort(currentModelReasoningEffort); - } - // Preserve the third state: only sync when the user/persisted session - // has an explicit tier. `undefined` means "omit" so the keepalive does - // not overwrite the account-default or persisted Fast tier with null. - if (currentServiceTier !== undefined) { - sessionInstance.setServiceTier(currentServiceTier); - } - sessionInstance.setCollaborationMode(currentCollaborationMode); - logger.debug( - `[Codex] Synced session config for keepalive: ` + - `permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, ` + - `modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, collaborationMode=${currentCollaborationMode}` - ); - }; - - const applySlashUpdates = (updates: { - permissionMode?: PermissionMode; - model?: string | null; - modelReasoningEffort?: ReasoningEffort | null; - collaborationMode?: EnhancedMode['collaborationMode']; - serviceTier?: string | null; - proactiveMultiAgent?: boolean; - personality?: CodexPersonality; - } | undefined): void => { - if (!updates) return; - if (updates.permissionMode !== undefined) { - currentPermissionMode = updates.permissionMode; - } - if (updates.model !== undefined) { - currentModel = updates.model ?? undefined; - } - if (updates.modelReasoningEffort !== undefined) { - currentModelReasoningEffort = updates.modelReasoningEffort; - } - if (updates.collaborationMode !== undefined) { - currentCollaborationMode = updates.collaborationMode; - } - if (updates.serviceTier !== undefined) { - currentServiceTier = updates.serviceTier; - } - if (updates.proactiveMultiAgent !== undefined) { - currentProactiveMultiAgent = updates.proactiveMultiAgent; - } - if (updates.personality !== undefined) { - currentPersonality = updates.personality; - } - applyCurrentConfigToSession(); - }; - - const syncCurrentConfigFromSession = (): void => { - const sessionPermissionMode = sessionWrapperRef.current?.getPermissionMode(); - if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'codex')) { - currentPermissionMode = sessionPermissionMode as PermissionMode; - } - const sessionModel = sessionWrapperRef.current?.getModel(); - if (sessionModel !== undefined) { - currentModel = sessionModel ?? undefined; - } - const sessionModelReasoningEffort = sessionWrapperRef.current?.getModelReasoningEffort(); - if (sessionModelReasoningEffort !== undefined) { - currentModelReasoningEffort = sessionModelReasoningEffort as ReasoningEffort | null; - } - const sessionCollaborationMode = sessionWrapperRef.current?.getCollaborationMode(); - if (sessionCollaborationMode) { - currentCollaborationMode = sessionCollaborationMode; - } - const sessionServiceTier = sessionWrapperRef.current?.getServiceTier(); - if (sessionServiceTier !== undefined) { - currentServiceTier = sessionServiceTier; - } - }; - - let userMessageChain: Promise = Promise.resolve(); - session.onUserMessage((message, localId) => { - userMessageChain = userMessageChain.then(async () => { - try { - syncCurrentConfigFromSession(); - let text = message.content.text; - let isolatedCommandText: string | null = null; - const commands = await listSlashCommands('codex', workingDirectory).catch(() => []); - const slash = resolveCodexSlashCommand(text, { - commands, - permissionMode: currentPermissionMode, - collaborationMode: currentCollaborationMode, - model: currentModel, - modelReasoningEffort: currentModelReasoningEffort ?? undefined, - serviceTier: currentServiceTier, - proactiveMultiAgent: currentProactiveMultiAgent, - personality: currentPersonality - }); - if (slash.kind === 'goal') { - if (slash.message) { - session.sendAgentMessage({ - type: 'message', - message: slash.message, - id: randomUUID() - }); - } - const goalCommand = slash.action === 'set' - ? `/goal ${slash.objective ?? ''}` - : slash.action === 'show' - ? '/goal' - : `/goal ${slash.action}`; - messageQueue.pushIsolateAndClear(goalCommand, { - permissionMode: currentPermissionMode ?? 'default', - model: currentModel, - modelReasoningEffort: currentModelReasoningEffort ?? undefined, - collaborationMode: currentCollaborationMode, - serviceTier: currentServiceTier, - personality: currentPersonality - }, localId); - return; - } - if (slash.kind !== 'passthrough') { - applySlashUpdates(slash.updates); - if (slash.message) { - session.sendAgentMessage({ - type: 'message', - message: slash.message, - id: randomUUID() - }); - } - if (slash.kind === 'handled') { - if (localId) session.emitMessagesConsumed([localId]); - return; - } - text = slash.text; - } else { - const specialCommand = parseCodexSpecialCommand(message.content.text); - if (specialCommand.type) { - logger.debug(`[Codex] Detected special command: ${specialCommand.type}`); - isolatedCommandText = message.content.text.trim(); - } - } - text = formatMessageWithAttachments(text, message.content.attachments); - - const messagePermissionMode = currentPermissionMode; - logger.debug( - `[Codex] User message received with permission mode: ${currentPermissionMode}, ` + - `model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` + - `collaborationMode: ${currentCollaborationMode}` - ); - - const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode ?? 'default', - model: currentModel, - modelReasoningEffort: currentModelReasoningEffort ?? undefined, - collaborationMode: currentCollaborationMode, - proactiveMultiAgent: currentProactiveMultiAgent, - serviceTier: currentServiceTier, - personality: currentPersonality - }; - if (isolatedCommandText) { - messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId); - return; - } - messageQueue.push(text, enhancedMode, localId); - } catch (error) { - logger.debug('[Codex] Failed to handle user message', error); - const enhancedMode: EnhancedMode = { - permissionMode: currentPermissionMode ?? 'default', - model: currentModel, - modelReasoningEffort: currentModelReasoningEffort ?? undefined, - collaborationMode: currentCollaborationMode, - proactiveMultiAgent: currentProactiveMultiAgent, - serviceTier: currentServiceTier, - personality: currentPersonality - }; - messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId); - } - }).catch((error) => { - logger.debug('[Codex] User message handler chain failed', error); - }); - }); - - session.onCancelQueuedMessage((localId) => { - const removed = messageQueue.cancelByLocalId(localId); - logger.debug(`[codex] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); - return removed; - }); - session.onRetryQueuedMessage((localId) => messageQueue.releaseIndeterminateReservation(localId)); - - const formatFailureReason = (message: string): string => { - const maxLength = 200; - if (message.length <= maxLength) { - return message; - } - return `${message.slice(0, maxLength)}...`; - }; - - const resolvePermissionMode = (value: unknown): PermissionMode => { - const parsed = PermissionModeSchema.safeParse(value); - if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'codex')) { - throw new Error('Invalid permission mode'); - } - return parsed.data as PermissionMode; - }; - - const resolveCollaborationMode = (value: unknown): EnhancedMode['collaborationMode'] => { - if (value === null) { - return 'default'; - } - const parsed = CodexCollaborationModeSchema.safeParse(value); - if (!parsed.success) { - throw new Error('Invalid collaboration mode'); - } - return parsed.data; - }; - - const resolveModel = (value: unknown): string => { - if (typeof value !== 'string') { - throw new Error('Invalid model'); - } - const trimmedValue = value.trim(); - if (!trimmedValue) { - throw new Error('Invalid model'); - } - return trimmedValue; - }; - - // Stored representation: `'fast'` and `'standard'` are explicit user - // choices; `null` means untouched (use the account default). The - // `'standard'` sentinel is only translated to the Codex app-server's - // `serviceTier: null` when building thread/turn params — see - // appServerConfig — so an explicit Fast-off stays sticky across resume. - const resolveServiceTier = (value: unknown): string | null => { - if (value === null) { - return null; - } - if (typeof value !== 'string') { - throw new Error('Invalid service tier'); - } - const trimmedValue = value.trim().toLowerCase(); - if (trimmedValue === 'fast' || trimmedValue === 'standard') { - return trimmedValue; - } - if (!trimmedValue || trimmedValue === 'default' || trimmedValue === 'auto') { - return null; - } - throw new Error('Invalid service tier'); - }; - - session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { - if (!payload || typeof payload !== 'object') { - throw new Error('Invalid session config payload'); - } - syncCurrentConfigFromSession(); - const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; collaborationMode?: unknown; serviceTier?: unknown }; - - if (config.permissionMode !== undefined) { - currentPermissionMode = resolvePermissionMode(config.permissionMode); - } - - const shouldSyncModel = config.model !== undefined; - if (shouldSyncModel) { - currentModel = resolveModel(config.model); - } - - if (config.modelReasoningEffort !== undefined) { - currentModelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort) ?? null; - } - - if (config.collaborationMode !== undefined) { - currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); - } - - if (config.serviceTier !== undefined) { - currentServiceTier = resolveServiceTier(config.serviceTier); - } - - applyCurrentConfigToSession({ syncModel: shouldSyncModel }); - const applied: { - permissionMode: PermissionMode; - model?: string | null; - modelReasoningEffort: ReasoningEffort | null; - collaborationMode: EnhancedMode['collaborationMode']; - serviceTier: string | null; - } = { - permissionMode: currentPermissionMode, - modelReasoningEffort: currentModelReasoningEffort ?? null, - collaborationMode: currentCollaborationMode, - serviceTier: currentServiceTier ?? null - }; - if (shouldSyncModel) { - applied.model = currentModel ?? null; - } - return { - applied - }; - }); - - let crashed = false; - - try { - await loop({ - path: workingDirectory, - startingMode, - messageQueue, - api, - session, - codexArgs: opts.codexArgs, - codexCliOverrides, - startedBy, - permissionMode: currentPermissionMode, - model: currentModel, - modelReasoningEffort: currentModelReasoningEffort ?? undefined, - collaborationMode: currentCollaborationMode, - resumeSessionId: opts.resumeSessionId, - sourceSessionId: codexSourceSessionId, - replayTranscriptHistoryOnStart, - onModeChange: createModeChangeHandler(session), - onSessionReady: (instance) => { - sessionWrapperRef.current = instance; - applyCurrentConfigToSession(); - } - }); - } catch (error) { - crashed = true; - lifecycle.markCrash(error); - logger.debug('[codex] Loop error:', error); - } finally { - const localFailure = sessionWrapperRef.current?.localLaunchFailure; - if (localFailure?.exitReason === 'exit') { - lifecycle.setExitCode(1); - lifecycle.setArchiveReason(`Local launch failed: ${formatFailureReason(localFailure.message)}`); - lifecycle.setSessionEndReason('error'); - } else if (!crashed) { - lifecycle.setSessionEndReason('completed'); - } - await lifecycle.cleanupAndExit(); - } -} diff --git a/cli/src/codex/shared/frontend.integration.test.ts b/cli/src/codex/shared/frontend.integration.test.ts new file mode 100644 index 00000000..4f161553 --- /dev/null +++ b/cli/src/codex/shared/frontend.integration.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { record } from './gateway'; +import { CodexAppServerClient } from '../codexAppServerClient'; +import { initializeSharedClient } from './launch'; +import { isProcessAlive, killProcessTreeByPid } from '@/utils/process'; + +// Test-only PTY driver, not part of the runtime. Python stdlib only; no model +// credentials or terminal scraping in the implementation. +const terminalDriver = String.raw` +import os,sys,pty,select,fcntl,termios,struct +pid,fd=pty.fork() +if pid==0: os.execvpe(sys.argv[1],sys.argv[1:],os.environ) +fcntl.ioctl(fd,termios.TIOCSWINSZ,struct.pack('HHHH',42,140,0,0)) +try: + while True: + ready,_,_=select.select([fd,0],[],[],1) + if fd in ready: + try: data=os.read(fd,65536) + except OSError: break + if not data: break + os.write(1,data) + for query,answer in [(b'\x1b[6n',b'\x1b[1;1R'),(b'\x1b[c',b'\x1b[?1;2c'),(b'\x1b]10;?',b'\x1b]10;rgb:ffff/ffff/ffff\x1b\\'),(b'\x1b]11;?',b'\x1b]11;rgb:0000/0000/0000\x1b\\')]: + if query in data: os.write(fd,answer) + if 0 in ready: + data=os.read(0,65536) + if not data: break + os.write(fd,data) +finally: + os.close(fd) + _,status=os.waitpid(pid,0) + sys.exit(os.waitstatus_to_exitcode(status)) +`; +async function eventually(read: () => Promise, accept: (value: T) => boolean, description: string): Promise { + const deadline = Date.now() + 25_000; + while (Date.now() < deadline) { + const value = await read(); if (accept(value)) return value; + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out: ${description}`); +} + +function hasReply(value: Record, prompt: string): boolean { + return (Array.isArray(value.messages) ? value.messages : []).some(message => { + const text = JSON.stringify(message); return text.includes('MOCK_DONE') && text.includes(prompt); + }); +} +// Actual CLI/Runner -> app-server + MCP + hub sockets -> actual native TUI. +// Separate from the protocol integration whose HAPI transport is mocked. +describe.skipIf(process.env.HAPI_RUN_SHARED_CODEX_TESTS !== '1' || process.platform === 'win32')('shared Codex full stack', () => { + it('shares interaction, stops the primary execution, resumes through Runner and only detaches secondary terminals', async () => { + const home = await mkdtemp('/tmp/hapi-shared-e2e-'); + const cwd = join(home, 'work'); const ch = join(home, 'codex'); const hh = join(home, 'hapi'); + await Promise.all([cwd, ch, hh].map(path => mkdir(path))); + const requests: Record[] = []; + const model = createServer((request, response) => { + const chunks: Buffer[] = []; request.on('data', data => chunks.push(Buffer.from(data))); + request.on('end', () => { + if (!request.url?.endsWith('/responses')) { response.writeHead(404).end(); return; } + const body = record(JSON.parse(Buffer.concat(chunks).toString())); requests.push(body); + const items = Array.isArray(body.input) ? body.input.map(record) : []; + let lastUser = -1; items.forEach((item, index) => { if (item.role === 'user') lastUser = index; }); + const prompt = JSON.stringify(items[lastUser]); + const outputs = items.slice(lastUser + 1).filter(item => item.type === 'function_call_output'); + const id = randomUUID(); + const question = prompt.includes('ASK_SHARED') && !outputs.length; + const environment = prompt.includes('CHECK_ROOT_ENV') && !outputs.length; + const item = question ? { type: 'function_call', call_id: `ask_${id}`, name: 'request_user_input', arguments: JSON.stringify({ + questions: [{ id: 'choice', header: 'Choice', question: 'Choose a shared client', options: [ + { label: 'Terminal', description: 'Native answer' }, { label: 'Web', description: 'Web answer' } + ] }] + }) } : environment ? { type: 'function_call', call_id: `env_${id}`, name: 'exec_command', arguments: JSON.stringify({ cmd: 'printf "ROOT=%s\\n" "$HAPI_SESSION_ID"' }) } + : { type: 'message', role: 'assistant', id: `msg_${id}`, content: [{ type: 'output_text', text: `MOCK_DONE ${prompt} ${JSON.stringify(outputs)}` }] }; + const events = [{ type: 'response.created', response: { id } }, { type: 'response.output_item.done', item }, + { type: 'response.completed', response: { id, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } }]; + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')); + }); + }); + await new Promise(resolve => model.listen(0, '127.0.0.1', resolve)); + const port = (model.address() as { port: number }).port; + await writeFile(join(ch, 'config.toml'), `model = "mock-model"\nmodel_provider = "mock_provider"\napproval_policy = "never"\nsandbox_mode = "read-only"\ncheck_for_update_on_startup = false\n[model_providers.mock_provider]\nname = "Isolated mock"\nbase_url = "http://127.0.0.1:${port}/v1"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n[analytics]\nenabled = false\n[feedback]\nenabled = false\n[projects."${cwd}"]\ntrust_level = "trusted"\n`); + const py = join(home, 'terminal.py'); await writeFile(py, terminalDriver); + const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, CODEX_HOME: ch, HAPI_HOME: hh, + HAPI_API_URL: process.env.HAPI_API_URL, CLI_API_TOKEN: process.env.CLI_API_TOKEN, + HAPI_INVOKED_CWD: cwd, HAPI_TEST_MARKER: process.env.HAPI_HOME, TERM: 'xterm-256color', LANG: 'en_US.UTF-8', DEBUG: '1' }; + const terminals: ChildProcessWithoutNullStreams[] = []; + const runtimePids = new Set(); + const uploadDirectories = new Set(); + let runner: ChildProcessWithoutNullStreams | undefined; + let runnerOutput = ''; + let output = ''; + let client: CodexAppServerClient | undefined; + const eventsAbort = new AbortController(); + let eventStream: Promise | undefined; + let webEvents = ''; + const openTerminal = (args: string[]) => { + const child = spawn('python3', [py, process.env.HAPI_BUN_EXEC!, '--cwd', resolve('.'), resolve('src/index.ts'), ...args], { cwd, env }); + child.stdout.on('data', data => { output += data.toString(); }); child.stderr.on('data', data => { output += data.toString(); }); + terminals.push(child); return child; + }; + const base = process.env.HAPI_API_URL!; + const auth = await fetch(`${base}/api/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessToken: env.CLI_API_TOKEN }) }); + const jwt = String(record(await auth.json()).token); + const api = async (path: string, body?: unknown) => { + const response = await fetch(`${base}/api${path}`, { method: body === undefined ? 'GET' : 'POST', + headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + const value = record(await response.json()); if (!response.ok) throw new Error(`${path}: ${JSON.stringify(value)}`); return value; + }; + try { + runner = spawn(process.env.HAPI_BUN_EXEC!, ['--cwd', resolve('.'), resolve('src/index.ts'), 'runner', 'start-sync'], { cwd, env }); + runner.stdout.on('data', data => { runnerOutput += data.toString(); }); + runner.stderr.on('data', data => { runnerOutput += data.toString(); }); + await eventually(async () => record(JSON.parse(await readFile(join(hh, 'runner.state.json'), 'utf8').catch(() => '{}'))), value => Boolean(value.httpPort), 'ordinary Runner startup'); + const first = openTerminal(['codex', '--no-alt-screen', '--collaboration-mode', 'plan', '--permission-mode', 'read-only', '--yolo']); + const runtime = await eventually(async () => { + for (const name of await readdir(join(hh, 'codex-runtimes')).catch(() => [] as string[])) { + if (!name.endsWith('.json')) continue; + const value = record(JSON.parse(await readFile(join(hh, 'codex-runtimes', name), 'utf8'))); + if (typeof value.pid === 'number') runtimePids.add(value.pid); + if (Object.keys(record(value.sessions)).length && value.endpoint) return value; + } + return {}; + }, value => Boolean(value.endpoint), 'execution readiness'); + const sessionId = Object.keys(record(runtime.sessions))[0]; + client = new CodexAppServerClient({ endpoint: String(runtime.endpoint) }); client.setServerRequestHandler(() => {}); await initializeSharedClient(client); + await eventually(async () => output, text => text.includes('Ask Codex') || text.includes('mock-model'), 'native TUI ready'); + const detail = () => api(`/sessions/${sessionId}`); + await eventually(detail, value => record(value.session).active === true, 'hub active'); + const settings = record(await client.request('thread/resume', { threadId: record(record(runtime.sessions)[sessionId]).threadId })); + expect(settings.sandbox).toMatchObject({ type: 'readOnly' }); + const send = async (text: string) => api(`/sessions/${sessionId}/messages`, { text, localId: randomUUID() }); + // Reproduce the actual surface order: first a native turn, then + // opening Web, then Web input. Check replies, not just prompt echoes. + first.stdin.write('HELLO_NATIVE'); await new Promise(resolve => setTimeout(resolve, 150)); first.stdin.write('\r'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => JSON.stringify(value).includes('HELLO_NATIVE'), 'native message in hub'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => JSON.stringify(value).includes('MOCK_DONE'), 'native answer in Web history'); + const eventsResponse = await fetch(`${base}/api/events?sessionId=${sessionId}`, { headers: { Authorization: `Bearer ${jwt}` }, signal: eventsAbort.signal }); + expect(eventsResponse.ok).toBe(true); + eventStream = (async () => { + const reader = eventsResponse.body!.getReader(); const decoder = new TextDecoder(); + for (;;) { const chunk = await reader.read(); if (chunk.done) return; webEvents += decoder.decode(chunk.value, { stream: true }); } + })().catch(error => { if (!eventsAbort.signal.aborted) throw error; }); + await send('HELLO_SHARED'); + await eventually(async () => output, text => text.includes('HELLO_SHARED'), 'Web input in native TUI'); + const hasSharedReply = (value: string) => value.split('\n').some(line => line.includes('MOCK_DONE') && line.includes('HELLO_SHARED')); + await eventually(async () => webEvents, hasSharedReply, 'Web answer over live SSE'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => hasReply(value, 'HELLO_SHARED'), 'Web answer persisted'); + await eventually(detail, value => !record(value.session).thinking, 'native turn idle'); + await send('ASK_SHARED'); + const pending = await eventually(detail, value => Object.keys(record(record(record(value.session).agentState).requests)).length > 0, 'Web question'); + const requestId = Object.keys(record(record(record(pending.session).agentState).requests))[0]; + expect(record(record(pending.session).agentState).steeringActive).toBe(true); + const questionCallId = record(record(record(record(pending.session).agentState).requests)[requestId]).toolCallId; + await api(`/sessions/${sessionId}/permissions/${encodeURIComponent(requestId)}/approve`, { answers: { choice: ['Web'] } }); + await eventually(async () => requests, value => JSON.stringify(value).includes('\\"answers\\":[\\"Web\\"]'), 'native question answer'); + await eventually(detail, value => record(record(record(record(value.session).agentState).completedRequests)[requestId]).status === 'resolved', 'neutral resolved status'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => { + const messages = (value.messages as unknown[]).map(message => record(record(record(record(message).content).content).data)); + return messages.some(message => message.type === 'tool-call' && message.callId === questionCallId && message.name === 'request_user_input') + && messages.some(message => message.type === 'tool-call-result' && message.callId === questionCallId + && record(message.output).status === 'resolved' && !('answers' in record(message.output))); + }, 'request-only question and neutral resolution persisted for reload'); + await eventually(detail, value => record(record(value.session).agentState).steeringActive === false, 'Steer clears at turn completion'); + const terminalQuestionOutput = output.length; + await send('ASK_SHARED_NATIVE'); + const nativePending = await eventually(detail, value => Object.keys(record(record(record(value.session).agentState).requests)).length > 0, 'native-answer question'); + const nativeRequestId = Object.keys(record(record(record(nativePending.session).agentState).requests))[0]; + await eventually(async () => output.slice(terminalQuestionOutput), text => text.includes('Choose a shared client'), 'native question visible'); + await new Promise(resolve => setTimeout(resolve, 200)); first.stdin.write('\r'); + await eventually(async () => requests, value => JSON.stringify(value).includes('\\"answers\\":[\\"Terminal\\"]'), 'real terminal keyboard answer'); + await eventually(detail, value => record(record(record(record(value.session).agentState).completedRequests)[nativeRequestId]).status === 'resolved', 'terminal answer closes Web controls'); + await send('CHECK_ROOT_ENV'); + await eventually(async () => requests, value => JSON.stringify(value).includes(`ROOT=${sessionId}`), 'per-root shell identity'); + await eventually(async () => webEvents, value => value.includes('CodexBash') && value.includes('tool-call-result') && value.includes(`ROOT=${sessionId}`), 'tool call and result over Web SSE'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => JSON.stringify(value).includes('CodexBash'), 'tool call persisted for Web reload'); + const secondOutput = output.length; + const second = openTerminal(['resume', sessionId]); + await eventually(async () => output.slice(secondOutput), text => text.includes('HELLO_SHARED'), 'secondary attach to terminal-owned engine'); + second.stdin.write('/exit'); await new Promise(resolve => setTimeout(resolve, 150)); second.stdin.write('\r'); + await eventually(async () => second.exitCode, value => value !== null, 'secondary detach'); + expect(record((await detail()).session).active).toBe(true); expect(isProcessAlive(Number(runtime.serverPid))).toBe(true); + const machineId = String(record(record((await detail()).session).metadata).machineId); + const webCreated = await api(`/machines/${machineId}/spawn`, { directory: cwd, agent: 'codex' }); + expect(webCreated.type).toBe('success'); const webId = String(webCreated.sessionId); + const webDetail = () => api(`/sessions/${webId}`); + const independent = await eventually(webDetail, value => record(value.session).active === true, 'Web-created execution'); + runtimePids.add(Number(record(record(independent.session).metadata).hostPid)); + await api(`/sessions/${webId}/messages`, { text: 'HELLO_WEB_CREATED', localId: randomUUID() }); + await eventually(() => api(`/sessions/${webId}/messages`), value => hasReply(value, 'HELLO_WEB_CREATED'), 'new Web execution reply'); + const newAttachOutput = output.length; + const newAttached = openTerminal(['resume', webId]); + await eventually(async () => output.slice(newAttachOutput), text => text.includes('HELLO_WEB_CREATED'), 'attach to Web-created session'); + newAttached.stdin.write('/exit'); await new Promise(resolve => setTimeout(resolve, 150)); newAttached.stdin.write('\r'); + await eventually(async () => newAttached.exitCode, value => value !== null, 'detach from Web-created session'); + expect(record((await webDetail()).session).active).toBe(true); + const suspendedSibling = await api(`/sessions/${sessionId}/clear`, {}); + first.stdin.write('/exit'); await new Promise(resolve => setTimeout(resolve, 150)); first.stdin.write('\r'); + await eventually(async () => first.exitCode, value => value !== null, 'primary terminal exit'); expect(first.exitCode).toBe(0); + const inactive = await eventually(detail, value => record(value.session).active === false, 'primary exit becomes inactive'); + expect(record(record(inactive.session).metadata).lifecycleState).not.toBe('archived'); + const siblingInactive = await eventually(() => api(`/sessions/${suspendedSibling.sessionId}`), value => record(value.session).active === false, 'same-execution siblings suspend'); + expect(record(record(siblingInactive.session).metadata).lifecycleState).not.toBe('archived'); + expect(isProcessAlive(Number(runtime.serverPid))).toBe(false); + expect(record((await webDetail()).session).active).toBe(true); + await client.disconnect(); + // /clear and /new use this endpoint on all clients. Inactive clear + // must resume through the ordinary Runner, then create a new root. + const coldClear = await api(`/sessions/${sessionId}/clear`, {}); + expect(coldClear.sessionId).not.toBe(sessionId); + const webResumed = await eventually(detail, value => record(value.session).active === true, 'Web resumed through Runner'); + const resumedPid = Number(record(record(webResumed.session).metadata).hostPid); runtimePids.add(resumedPid); + expect(resumedPid).not.toBe(runtime.pid); + expect(record(record(webResumed.session).metadata).codexSessionId).toBe(record(record(runtime.sessions)[sessionId]).threadId); + const readRuntime = async (pid: number) => { + for (const name of await readdir(join(hh, 'codex-runtimes'))) { + if (!name.endsWith('.json')) continue; + const value = record(JSON.parse(await readFile(join(hh, 'codex-runtimes', name), 'utf8'))); + if (value.pid === pid) return value; + } + return {}; + }; + const runnerRuntime = await readRuntime(resumedPid); + client = new CodexAppServerClient({ endpoint: String(runnerRuntime.endpoint) }); client.setServerRequestHandler(() => {}); await initializeSharedClient(client); + await client.request('hapi/stopSession', { sessionId: coldClear.sessionId }); + await send('AFTER_WEB_RESUME'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => hasReply(value, 'AFTER_WEB_RESUME'), 'Web reply after resume'); + const attachOutput = output.length; + const attached = openTerminal(['resume', sessionId]); + await eventually(async () => output.slice(attachOutput), text => text.includes('AFTER_WEB_RESUME'), 'attach to Runner-created execution'); + attached.stdin.write('/exit'); await new Promise(resolve => setTimeout(resolve, 150)); attached.stdin.write('\r'); + await eventually(async () => attached.exitCode, value => value !== null, 'Runner execution terminal detach'); + expect(isProcessAlive(Number(runnerRuntime.serverPid))).toBe(true); expect(record((await detail()).session).active).toBe(true); + await send('AFTER_ATTACH_EXIT'); + await eventually(async () => webEvents, value => value.split('\n').some(line => line.includes('MOCK_DONE') && line.includes('AFTER_ATTACH_EXIT')), 'Runner Web reply without attached terminals'); + const next = await api(`/sessions/${sessionId}/clear`, {}); expect(next.sessionId).not.toBe(sessionId); + expect(record((await detail()).session).metadata).toMatchObject({ codexSessionId: record(record(runtime.sessions)[sessionId]).threadId }); + const nextId = String(next.sessionId); + const nextDetail = () => api(`/sessions/${nextId}`); + await api(`/sessions/${nextId}/messages`, { text: 'ASK_SHARED_SIBLING', localId: randomUUID() }); + const siblingPending = await eventually(nextDetail, value => Object.keys(record(record(record(value.session).agentState).requests)).length > 0, 'sibling question'); + expect(Object.keys(record(record(record((await detail()).session).agentState).requests))).toHaveLength(0); + const nextThread = String(record(record(siblingPending.session).metadata).codexSessionId); + const queueId = randomUUID(); + await api(`/sessions/${nextId}/messages`, { text: 'NATIVE_QUEUE_EDIT', localId: queueId }); + const queued = await eventually(async () => record(await client!.request('thread/queue/list', { threadId: nextThread })), value => + Array.isArray(value.data) && value.data.some(item => record(item).clientUserMessageId === queueId), 'native busy queue'); + const submission = (queued.data as unknown[]).map(record).find(item => item.clientUserMessageId === queueId)!; + await client.request('thread/queue/update', { threadId: nextThread, queuedSubmissionId: submission.id, + input: [{ type: 'text', text: 'NATIVE_QUEUE_EDITED', text_elements: [] }] }); + await eventually(() => api(`/sessions/${nextId}/messages`), value => JSON.stringify(value).includes('NATIVE_QUEUE_EDITED'), 'native queue edit in Web'); + await client.request('thread/queue/delete', { threadId: nextThread, queuedSubmissionId: submission.id }); + await eventually(() => api(`/sessions/${nextId}/messages`), value => !JSON.stringify(value).includes(queueId), 'confirmed queue deletion in Web'); + await eventually(detail, value => !record(value.session).thinking, 'root idle before fork'); + const fork = await api(`/sessions/${sessionId}/fork`, {}); + const forkSession = record((await api(`/sessions/${fork.sessionId}`)).session); + expect(record(forkSession.metadata)).toMatchObject({ forkedFrom: sessionId, hostPid: resumedPid }); + await client.request('hapi/stopSession', { sessionId: fork.sessionId }); + expect(Object.keys(record(record(record((await nextDetail()).session).agentState).requests))).toHaveLength(1); + await client.request('hapi/stopSession', { sessionId: next.sessionId }); + expect(record((await detail()).session).active).toBe(true); + // A cold restart must reuse the HAPI/native binding, not create a + // duplicate root or replay the previously accepted submissions. + const interruptedInput = randomUUID(); + await api(`/sessions/${sessionId}/messages`, { text: 'ASK_SHARED_SUSPEND', localId: interruptedInput }); + await eventually(detail, value => Object.keys(record(record(record(value.session).agentState).requests)).length > 0, 'question before execution suspension'); + const suspendedInput = randomUUID(); + const upload = await api(`/sessions/${sessionId}/upload`, { filename: 'pending.txt', mimeType: 'text/plain', content: Buffer.from('pending attachment').toString('base64') }); + expect(upload.success).toBe(true); const uploadPath = String(upload.path); uploadDirectories.add(dirname(uploadPath)); + await api(`/sessions/${sessionId}/messages`, { text: 'PRESERVE_PENDING_ON_EXIT', localId: suspendedInput, + attachments: [{ id: randomUUID(), filename: 'pending.txt', mimeType: 'text/plain', size: 18, path: uploadPath }] }); + const nativeThread = String(record(record(runtime.sessions)[sessionId]).threadId); + await eventually(async () => record(await client!.request('thread/queue/list', { threadId: nativeThread })), value => JSON.stringify(value).includes(suspendedInput), 'pending native input before suspension'); + const nativeQueuedInput = randomUUID(); + await client.request('thread/queue/add', { threadId: nativeThread, clientUserMessageId: nativeQueuedInput, + input: [{ type: 'text', text: 'PRESERVE_NATIVE_QUEUE_ON_EXIT', text_elements: [] }] }); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => (value.messages as unknown[]).map(record).some(message => + message.localId === nativeQueuedInput && message.invokedAt === null + && record(record(message.content).meta).isNativeQueuedMessage === true), 'terminal-origin queued prompt mirrored as replayable'); + process.kill(resumedPid, 'SIGTERM'); + const stopped = await eventually(detail, value => record(value.session).active === false, 'ordinary execution shutdown'); + expect(record(record(stopped.session).agentState).steeringActive).toBe(false); + await eventually(async () => isProcessAlive(resumedPid), alive => !alive, 'wrapper has exited, including upload cleanup hooks'); + expect(await readFile(uploadPath, 'utf8')).toBe('pending attachment'); + await client.disconnect(); + const pendingHistory = await api(`/sessions/${sessionId}/messages`); + expect((pendingHistory.messages as unknown[]).map(record).find(message => message.localId === suspendedInput)?.invokedAt).toBeNull(); + output = ''; openTerminal(['resume', sessionId]); + const recovered = await eventually(async () => { + for (const name of await readdir(join(hh, 'codex-runtimes'))) { + if (!name.endsWith('.json')) continue; + const value = record(JSON.parse(await readFile(join(hh, 'codex-runtimes', name), 'utf8'))); + if (typeof value.pid === 'number') runtimePids.add(value.pid); + if (record(record(value.sessions)[sessionId]).active === true && value.endpoint) return value; + } + return {}; + }, value => Boolean(value.endpoint), 'cold recovery binding'); + expect(record(record(recovered.sessions)[sessionId]).threadId).toBe(record(record(runtime.sessions)[sessionId]).threadId); + await eventually(async () => output, text => text.includes('AFTER_ATTACH_EXIT'), 'cold native history'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => (value.messages as unknown[]).map(record).some(message => message.localId === suspendedInput && typeof message.invokedAt === 'number'), 'confirmed-unexecuted pending input delivered after resume'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => hasReply(value, 'PRESERVE_PENDING_ON_EXIT'), 'pending input completes exactly once'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => hasReply(value, 'PRESERVE_NATIVE_QUEUE_ON_EXIT'), 'terminal-origin queued input resumes'); + client = new CodexAppServerClient({ endpoint: String(recovered.endpoint) }); client.setServerRequestHandler(() => {}); await initializeSharedClient(client); + // Count authoritative accepted input, not model HTTP calls (Codex + // also calls the model for background title generation). + const history = record(await client.request('thread/turns/list', { threadId: nativeThread, itemsView: 'full', sortDirection: 'asc', limit: 100 })); + const userItems = (history.data as unknown[]).map(record).flatMap(turn => (turn.items as unknown[]).map(record)).filter(item => item.type === 'userMessage'); + for (const id of [suspendedInput, interruptedInput, nativeQueuedInput]) expect(userItems.filter(item => item.clientId === id)).toHaveLength(1); + expect(JSON.stringify(userItems.find(item => item.clientId === suspendedInput))).toContain(uploadPath); + const recoveryDetail = record((await detail()).session); + expect(Object.keys(record(record(recoveryDetail.agentState).requests))).toHaveLength(0); + await send('AFTER_COLD_RESUME'); + await eventually(() => api(`/sessions/${sessionId}/messages`), value => hasReply(value, 'AFTER_COLD_RESUME'), 'Web after cold resume'); + await client.request('hapi/stopSession', { sessionId }); + await api(`/sessions/${webId}/archive`, {}); + await eventually(webDetail, value => record(value.session).active === false, 'end independent Web session'); + } catch (error) { + await writeFile('/tmp/hapi-shared-e2e-terminal.log', output + '\nRUNNER:\n' + runnerOutput); + throw error; + } finally { + eventsAbort.abort(); await eventStream; + await client?.disconnect(); + for (const child of terminals) if (child.pid && child.exitCode === null) await killProcessTreeByPid(child.pid); + for (const pid of runtimePids) await killProcessTreeByPid(pid); + if (runner?.pid && runner.exitCode === null) await killProcessTreeByPid(runner.pid); + await Promise.all([...uploadDirectories].map(path => rm(path, { recursive: true, force: true }))); + await new Promise(resolve => model.close(() => resolve())); + await rm(home, { recursive: true, force: true, maxRetries: 3 }); + } + }, 180_000); +}); diff --git a/cli/src/codex/shared/frontend.test.ts b/cli/src/codex/shared/frontend.test.ts new file mode 100644 index 00000000..46291193 --- /dev/null +++ b/cli/src/codex/shared/frontend.test.ts @@ -0,0 +1,80 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RuntimeReady } from './runtime'; +import type { SharedLaunchOptions } from './launch'; + +const state = vi.hoisted(() => ({ existing: false, run: vi.fn(), spawn: vi.fn(), kill: vi.fn() })); +const runtime: RuntimeReady['runtime'] = { id: 'runtime', pid: 1, marker: 'start', endpoint: 'unix://private', command: '/resolved/codex', + args: [], codexHome: '/isolated/codex', hub: 'hub', authHash: 'auth', sessions: { sid: { threadId: 'thread', namespace: 'ns', active: true } } }; +vi.mock('node:child_process', () => ({ spawn: state.spawn, execFileSync: vi.fn() })); +vi.mock('@/api/api', () => ({ ApiClient: { create: async () => ({ getSession: async () => ({ id: 'sid', namespace: 'ns', active: state.existing, metadata: { path: '/work' } }) }) } })); +vi.mock('@/persistence', () => ({ readRunnerState: async () => null })); +vi.mock('@/utils/process', () => ({ isProcessAlive: () => true, killProcessByChildProcess: state.kill })); +vi.mock('./registry', () => ({ findRuntime: async () => state.existing ? runtime : undefined, runtimeAlive: () => true })); +vi.mock('./runtime', () => ({ runSharedRuntime: state.run })); +vi.mock('../codexAppServerClient', () => ({ CodexAppServerClient: class { + setServerRequestHandler() {} async connect() {} async initialize() {} async disconnect() {} + async request() { return { threadId: 'thread' }; } +} })); +import { runSharedCodex } from './frontend'; + +const tty = [Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'), Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')]; +beforeEach(() => { + vi.clearAllMocks(); state.existing = false; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + state.spawn.mockImplementation(() => new EventEmitter()); + state.kill.mockImplementation(async (child: EventEmitter) => { child.emit('exit', 0); return true; }); + state.run.mockImplementation(async (_options: SharedLaunchOptions, ready?: (ready: RuntimeReady) => void, signal?: AbortSignal) => { + ready?.({ sessionId: 'sid', runtime }); + await new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true })); + }); +}); +afterEach(() => { + for (const [index, stream] of [process.stdin, process.stdout].entries()) { + if (tty[index]) Object.defineProperty(stream, 'isTTY', tty[index]!); + else Reflect.deleteProperty(stream, 'isTTY'); + } +}); +describe('shared frontend execution ownership', () => { + it.each([0, 1])('stops the owned execution when its primary TUI exits (%s)', async code => { + const running = runSharedCodex({ workingDirectory: '/work' }); + const result = code ? expect(running).rejects.toThrow('terminal exited') : expect(running).resolves.toBeUndefined(); + await vi.waitFor(() => expect(state.spawn).toHaveBeenCalledOnce()); + const child = state.spawn.mock.results[0].value as EventEmitter; child.emit('exit', code); + await result; + expect((state.run.mock.calls[0][2] as AbortSignal).aborted).toBe(true); + }); + it('detaches a secondary TUI without acquiring or ending execution ownership', async () => { + state.existing = true; + const running = runSharedCodex({ existingSessionId: 'sid', workingDirectory: '/work' }); + await vi.waitFor(() => expect(state.spawn).toHaveBeenCalledOnce()); + (state.spawn.mock.results[0].value as EventEmitter).emit('exit', 0); + await running; expect(state.run).not.toHaveBeenCalled(); expect(state.kill).not.toHaveBeenCalled(); + expect(state.spawn.mock.calls[0][0]).toBe('/resolved/codex'); + }); + it('uses the ordinary Runner-owned wrapper without spawning a TUI', async () => { + state.run.mockResolvedValueOnce(undefined); + await runSharedCodex({ startedBy: 'runner', workingDirectory: '/work' }); + expect(state.run).toHaveBeenCalledOnce(); expect(state.spawn).not.toHaveBeenCalled(); + }); + it('stops the engine on TUI spawn error and leaves no execution on startup failure', async () => { + state.run.mockRejectedValueOnce(new Error('startup failed')); + await expect(runSharedCodex({ workingDirectory: '/work' })).rejects.toThrow('startup failed'); + expect(state.spawn).not.toHaveBeenCalled(); + const running = runSharedCodex({ workingDirectory: '/work' }); + const result = expect(running).rejects.toThrow('TUI unavailable'); + await vi.waitFor(() => expect(state.spawn).toHaveBeenCalledOnce()); + (state.spawn.mock.results[0].value as EventEmitter).emit('error', new Error('TUI unavailable')); + await result; expect((state.run.mock.calls[1][2] as AbortSignal).aborted).toBe(true); + }); + it('closes the TUI if its engine ends first', async () => { + let finish!: () => void; + state.run.mockImplementationOnce(async (_options: SharedLaunchOptions, ready: (ready: RuntimeReady) => void) => { + ready({ sessionId: 'sid', runtime }); await new Promise(resolve => { finish = resolve; }); + }); + const running = runSharedCodex({ workingDirectory: '/work' }); + await vi.waitFor(() => expect(state.spawn).toHaveBeenCalledOnce()); finish(); await running; + expect(state.kill).toHaveBeenCalledOnce(); + }); +}); diff --git a/cli/src/codex/shared/frontend.ts b/cli/src/codex/shared/frontend.ts new file mode 100644 index 00000000..7c1b65d9 --- /dev/null +++ b/cli/src/codex/shared/frontend.ts @@ -0,0 +1,88 @@ +import { spawn } from 'node:child_process'; +import { z } from 'zod'; +import { ApiClient } from '@/api/api'; +import { readRunnerState } from '@/persistence'; +import { isProcessAlive, killProcessByChildProcess } from '@/utils/process'; +import { getInvokedCwd } from '@/utils/invokedCwd'; +import { CodexAppServerClient } from '../codexAppServerClient'; +import { findRuntime, runtimeAlive, type CodexRuntimeRecord } from './registry'; +import { initializeSharedClient, sharedLaunchConfig, SharedLaunchSchema, type SharedLaunchOptions } from './launch'; +import { runSharedRuntime, type RuntimeReady } from './runtime'; + +export async function runtimeControl(runtime: CodexRuntimeRecord, method: string, sessionId: string): Promise { + if (!runtimeAlive(runtime)) throw new Error('Shared runtime is not alive'); + const client = new CodexAppServerClient({ endpoint: runtime.endpoint, token: runtime.token }); + client.setServerRequestHandler(() => {}); + try { await initializeSharedClient(client); return await client.request(method, { sessionId }); } + finally { await client.disconnect(); } +} + +export async function attachSharedSession(runtime: CodexRuntimeRecord, sessionId: string, args: string[] = [], signal?: AbortSignal): Promise { + const session = await (await ApiClient.create()).getSession(sessionId); + if (runtime.sessions[sessionId]?.namespace !== session.namespace) throw new Error('Shared runtime namespace mismatch'); + const { threadId } = z.object({ threadId: z.string() }).parse(await runtimeControl(runtime, 'hapi/attach', sessionId)); + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(`Native Codex attachment needs a terminal: ${sessionId}`); + signal?.throwIfAborted(); + const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: runtime.codexHome }; + const authEnv = 'HAPI_CODEX_REMOTE_TOKEN'; + if (runtime.token) env[authEnv] = runtime.token; + // The same executable that owns this runtime; never resolve PATH again at attach. + const child = spawn(runtime.command, [...runtime.args, '--remote', runtime.endpoint, + ...(runtime.token ? ['--remote-auth-token-env', authEnv] : []), 'resume', threadId, ...args], + { stdio: 'inherit', env, cwd: session.metadata?.path ?? process.cwd(), windowsHide: false }); + let cleanup: Promise | undefined; + const stop = () => { cleanup ??= killProcessByChildProcess(child); }; + try { + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', code => code && code !== 130 && !signal?.aborted + ? reject(new Error(`Codex terminal exited (${code})`)) : resolve()); + signal?.addEventListener('abort', stop, { once: true }); + process.on('SIGTERM', stop); process.on('SIGINT', stop); process.on('SIGHUP', stop); + if (signal?.aborted) stop(); + }); + } finally { + signal?.removeEventListener('abort', stop); + process.off('SIGTERM', stop); process.off('SIGINT', stop); process.off('SIGHUP', stop); + await cleanup; + } +} + +export async function runSharedCodex(raw: SharedLaunchOptions): Promise { + const options = SharedLaunchSchema.parse({ ...raw, workingDirectory: raw.workingDirectory ?? getInvokedCwd() }); + if (options.existingSessionId) { + const api = await ApiClient.create(); + const session = await api.getSession(options.existingSessionId); + const runtime = await findRuntime(session.id); + if (runtime) { + if (options.startedBy === 'runner') throw new Error('Shared session is already running; attach instead of spawning another execution'); + return attachSharedSession(runtime, session.id); + } + if (session.active) throw new Error('Existing session is active in another or legacy runtime. Stop it explicitly before cold resume; no hot migration.'); + options.resumeSessionId ??= session.metadata?.codexSessionId; + if (!options.resumeSessionId) throw new Error('Existing HAPI session has no Codex thread binding'); + } + const runner = await readRunnerState(); + if (runner && isProcessAlive(runner.pid) && !runner.sharedCodexRuntime) { + throw new Error('Running HAPI runner predates shared Codex sessions. Upgrade/restart the runner first; existing sessions are not migrated or stopped automatically.'); + } + const launch = sharedLaunchConfig(options, options.workingDirectory!); + if (options.startedBy === 'runner') return runSharedRuntime(options); + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Native Codex launch needs a terminal'); + // Normal CLI ownership: no detached worker, inherited IPC or adoption loop. + // A terminal attaching above never enters this owner's lifecycle. + const stop = new AbortController(); + let ready!: (value: RuntimeReady) => void; + const readiness = new Promise(resolve => { ready = resolve; }); + const running = runSharedRuntime(options, ready, stop.signal); + let attachment: Promise | undefined; + try { + const runtime = await Promise.race([readiness, running.then(() => { throw new Error('Codex stopped before startup completed'); })]); + console.log(`HAPI shared session: ${runtime.sessionId}\nExiting this terminal stops this execution. Web can resume it through Runner.`); + attachment = attachSharedSession(runtime.runtime, runtime.sessionId, launch.tuiArgs, stop.signal); + await Promise.race([attachment, running]); + } finally { + stop.abort(); + await Promise.allSettled([running, ...(attachment ? [attachment] : [])]); + } +} diff --git a/cli/src/codex/shared/gateway.test.ts b/cli/src/codex/shared/gateway.test.ts new file mode 100644 index 00000000..fbbf77cc --- /dev/null +++ b/cli/src/codex/shared/gateway.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WebSocket, WebSocketServer } from 'ws'; +import { startCodexGateway, type Envelope } from './gateway'; + +async function fixture(after: (request: Envelope, response: Envelope, connection: string) => Promise) { + const engine = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + await new Promise(resolve => engine.once('listening', resolve)); + const connections = new Map(); + engine.on('connection', client => { + connections.set(client, []); + client.on('message', raw => { + const request = JSON.parse(raw.toString()) as Envelope; + connections.get(client)!.push(request); + client.send(JSON.stringify({ id: request.id, result: { thread: { id: String(request.params) } } })); + }); + }); + const port = (engine.address() as { port: number }).port; + const before = vi.fn(async (value: Envelope) => value); + const gateway = await startCodexGateway({ upstream: `ws://127.0.0.1:${port}`, token: 'secret', hooks: { + before, after, control: async () => ({}) + } }); + const clients: WebSocket[] = []; + const connect = async () => { + const client = new WebSocket(gateway.endpoint, { headers: { Authorization: 'Bearer secret' } }); clients.push(client); + await new Promise((resolve, reject) => { client.once('open', resolve); client.once('error', reject); }); return client; + }; + return { gateway, connect, before, async close() { + for (const client of clients) client.terminate(); await gateway.close(); + for (const client of engine.clients) client.terminate(); + await new Promise(resolve => engine.close(() => resolve())); + } }; +} +describe('native gateway barriers', () => { + it('namespaces colliding native request IDs by connection and withholds replies until binding', async () => { + let release!: () => void; const barrier = new Promise(resolve => { release = resolve; }); + const after = vi.fn(async () => { await barrier; }); const f = await fixture(after); + try { + const first = await f.connect(); const second = await f.connect(); const replies: unknown[] = []; + first.on('message', value => replies.push(value)); second.on('message', value => replies.push(value)); + first.send(JSON.stringify({ id: 1, method: 'thread/start', params: 'a' })); + second.send(JSON.stringify({ id: 1, method: 'thread/start', params: 'b' })); + await vi.waitFor(() => expect(after).toHaveBeenCalledTimes(2)); expect(replies).toHaveLength(0); + const calls = after.mock.calls as unknown as Array<[Envelope, Envelope, string]>; + expect(calls[0][2]).not.toBe(calls[1][2]); release(); + await vi.waitFor(() => expect(replies).toHaveLength(2)); + } finally { release(); await f.close(); } + }); + it('finishes a dispatched lifecycle even when its terminal detaches before the response', async () => { + let release!: () => void; const barrier = new Promise(resolve => { release = resolve; }); + const bound = vi.fn(); const f = await fixture(async () => { await barrier; bound(); }); + try { + const client = await f.connect(); client.send(JSON.stringify({ id: 1, method: 'thread/start', params: 'a' })); + await vi.waitFor(() => expect(f.before).toHaveBeenCalled()); client.close(); release(); + await vi.waitFor(() => expect(bound).toHaveBeenCalledOnce()); + } finally { release(); await f.close(); } + }); + it('rejects unauthenticated loopback connections', async () => { + const f = await fixture(async () => {}); + try { + const client = new WebSocket(f.gateway.endpoint); + const response = await new Promise(resolve => { + client.on('unexpected-response', (_request, response) => { resolve(response.statusCode!); response.resume(); client.terminate(); }); + client.on('error', () => {}); + }); + expect(response).toBe(401); + } finally { await f.close(); } + }); +}); diff --git a/cli/src/codex/shared/gateway.ts b/cli/src/codex/shared/gateway.ts new file mode 100644 index 00000000..4ad40acc --- /dev/null +++ b/cli/src/codex/shared/gateway.ts @@ -0,0 +1,140 @@ +import { createServer } from 'node:http'; +import { chmod, unlink } from 'node:fs/promises'; +import { timingSafeEqual, randomUUID } from 'node:crypto'; +import WebSocket, { WebSocketServer } from 'ws'; +import { z } from 'zod'; + +export const EnvelopeSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), params: z.unknown().optional(), + result: z.unknown().optional(), error: z.unknown().optional() +}).passthrough(); +export type Envelope = z.infer; +export function record(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} +export function string(value: unknown): string | undefined { return typeof value === 'string' && value.length ? value : undefined; } +export function socketUrl(endpoint: string): string { + return endpoint.startsWith('unix://') ? `ws+unix://${endpoint.slice(7)}:/` : endpoint; +} + +export type GatewayHooks = { + before(request: Envelope, connectionId: string): Promise; + after(request: Envelope, response: Envelope, connectionId: string): Promise; + disconnected?(connectionId: string): void; + control(method: string, params: unknown): Promise; +}; + +/** Byte-preserving for normal traffic; only lifecycle requests have a binding barrier. */ +export async function startCodexGateway(options: { + upstream: string; upstreamToken?: string; path?: string; token: string; hooks: GatewayHooks; +}): Promise<{ endpoint: string; close(): Promise }> { + const server = createServer((_request, response) => { response.writeHead(404).end(); }); + const websocket = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 * 1024 }); + const connections = new Map(); + server.on('upgrade', (request, socket, head) => { + if (!options.path) { + const provided = Buffer.from(request.headers.authorization ?? ''); + const expected = Buffer.from(`Bearer ${options.token}`); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); + return; + } + } + websocket.handleUpgrade(request, socket, head, client => websocket.emit('connection', client)); + }); + websocket.on('connection', downstream => { + const connectionId = randomUUID(); + const upstream = new WebSocket(socketUrl(options.upstream), { + headers: { Host: 'localhost', ...(options.upstreamToken ? { Authorization: `Bearer ${options.upstreamToken}` } : {}) }, + handshakeTimeout: 10_000, maxPayload: 64 * 1024 * 1024, perMessageDeflate: false + }); + connections.set(downstream, upstream); + const pending = new Map(); + const ready = new Promise((resolve, reject) => { upstream.once('open', resolve); upstream.once('error', reject); }); + void ready.catch(() => downstream.close(1011, 'Codex unavailable')); + let detached = false; + let finished = false; + let processing = 0; + let drainTimer: ReturnType | undefined; + let incoming = Promise.resolve(); + let outgoing = Promise.resolve(); + const finish = () => { + if (finished) return; + finished = true; clearTimeout(drainTimer); + connections.delete(downstream); upstream.close(); + options.hooks.disconnected?.(connectionId); + }; + const drained = () => { if (detached && processing === 0 && pending.size === 0) finish(); }; + const send = (target: WebSocket, value: Envelope) => { + if (target.readyState === WebSocket.OPEN) target.send(JSON.stringify(value)); + }; + downstream.on('message', raw => { + let message: Envelope; + try { message = EnvelopeSchema.parse(JSON.parse(raw.toString())); } + catch { downstream.close(1007, 'Invalid JSON-RPC'); return; } + // Answers must bypass the lifecycle barrier (initialization itself may elicit input). + if (!message.method) { void ready.then(() => send(upstream, message)).catch(() => {}); return; } + processing++; + incoming = incoming.then(async () => { + await ready; + try { + if (message.method?.startsWith('hapi/')) { + send(downstream, { id: message.id, result: await options.hooks.control(message.method, message.params) }); + return; + } + const transformed = await options.hooks.before(message, connectionId); + if (transformed.id !== undefined && ['thread/start', 'thread/resume', 'thread/fork', 'thread/archive', 'thread/revert', 'thread/rollback', 'thread/queue/delete'].includes(transformed.method ?? '')) { + pending.set(transformed.id, transformed); + } + send(upstream, transformed); + } catch (error) { + send(downstream, { id: message.id, error: { code: -32600, message: error instanceof Error ? error.message : String(error) } }); + } + }).catch(() => downstream.close(1011, 'Codex connection failed')).finally(() => { processing--; drained(); }); + }); + upstream.on('message', raw => { + outgoing = outgoing.then(async () => { + const message = EnvelopeSchema.parse(JSON.parse(raw.toString())); + const request = message.method === undefined && message.id !== undefined ? pending.get(message.id) : undefined; + let replay: void | Envelope[] = undefined; + if (request) { + try { replay = await options.hooks.after(request, message, connectionId); } + catch (error) { + send(downstream, { id: message.id, error: { code: -32603, message: error instanceof Error ? error.message : String(error) } }); + return; + } finally { pending.delete(message.id!); drained(); } + } + send(downstream, message); + for (const notification of replay ?? []) send(downstream, notification); + }).catch(() => downstream.close(1011, 'Invalid Codex response')); + }); + upstream.on('close', () => { + downstream.close(1011, 'Codex disconnected'); + void incoming.then(() => outgoing).finally(finish); + }); + upstream.on('error', () => downstream.close(1011, 'Codex unavailable')); + downstream.on('error', () => downstream.close()); + downstream.on('close', () => { + detached = true; + // A terminal can exit after dispatching /new but before its reply. + // Keep the upstream alive until binding finishes; never lose the + // new root or treat transport loss as proof the mutation failed. + drainTimer = setTimeout(finish, 60_000); drainTimer.unref(); drained(); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + if (options.path) server.listen(options.path, resolve); + else server.listen(0, '127.0.0.1', resolve); + }); + if (options.path) await chmod(options.path, 0o600); + const address = server.address(); + const endpoint = options.path ? `unix://${options.path}` : `ws://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`; + return { endpoint, async close() { + for (const [client, upstream] of connections) { client.terminate(); upstream.terminate(); } + websocket.close(); + await new Promise(resolve => server.close(() => resolve())); + if (options.path) await unlink(options.path).catch(() => {}); + } }; +} diff --git a/cli/src/codex/shared/launch.test.ts b/cli/src/codex/shared/launch.test.ts new file mode 100644 index 00000000..b9d2e3ea --- /dev/null +++ b/cli/src/codex/shared/launch.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { sharedLaunchConfig } from './launch'; +import { parseCodexCliOverrides } from '../utils/codexCliOverrides'; +import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig'; + +describe('shared launch configuration', () => { + it.each(['default', 'read-only', 'yolo'] as const)('keeps explicit %s authoritative over native permission flags', permissionMode => { + for (const flags of [ + ['--yolo'], ['--dangerously-bypass-approvals-and-sandbox'], ['--full-auto'], + ['-sdanger-full-access', '-aon-request'], ['--sandbox=read-only', '--ask-for-approval=untrusted'], + ['--approve-for-me'], ['--not-so-yolo'], + ['-c', 'sandbox_mode="danger-full-access"', '-c', 'approval_policy="never"', '-c', 'approvals_reviewer="auto_review"'] + ]) { + const result = sharedLaunchConfig({ permissionMode, codexArgs: [...flags, '--no-alt-screen', '--', '--yolo'] }, '/tmp'); + const { approvalPolicy, sandbox } = resolveCodexPermissionModeConfig(permissionMode); + expect(result.threadParams).toMatchObject({ approvalPolicy, sandbox, approvalsReviewer: 'user' }); + expect(parseCodexCliOverrides(result.tuiArgs)).toEqual({}); + expect(result.tuiArgs).toEqual(['--no-alt-screen', '--', '--yolo']); + expect(result.tuiArgs.slice(-2)).toEqual(['--', '--yolo']); + expect(result.tuiArgs).toContain('--no-alt-screen'); + expect(result.tuiArgs.slice(0, -2)).not.toContain('--yolo'); + expect(result.serverArgs.slice(-6)).toEqual([ + '-c', `approval_policy=${JSON.stringify(approvalPolicy)}`, + '-c', `sandbox_mode=${JSON.stringify(sandbox)}`, '-c', 'approvals_reviewer="user"' + ]); + } + }); + it('keeps unspecified settings inherited and explicit Standard distinct', () => { + expect(sharedLaunchConfig({}, '/tmp').threadParams).not.toHaveProperty('serviceTier'); + expect(sharedLaunchConfig({}, '/tmp').threadParams.config).not.toHaveProperty('model_reasoning_effort'); + expect(sharedLaunchConfig({ serviceTier: 'standard' }, '/tmp').threadParams.serviceTier).toBeNull(); + expect(sharedLaunchConfig({ serviceTier: 'fast' }, '/tmp').threadParams.serviceTier).toBe('priority'); + }); + it('normalizes short flags, forwards configuration and resolves cwd exactly once', () => { + const result = sharedLaunchConfig({ codexArgs: ['-C', 'project', '-mcustom', '-a', 'never', '--search', '--add-dir', '../extra', '-c', 'model_reasoning_effort="high"'] }, '/tmp'); + expect(result.cwd).toBe('/tmp/project'); expect(result.tuiArgs).not.toContain('-C'); + expect(result.threadParams).toMatchObject({ model: 'custom', approvalPolicy: 'never', config: { web_search: 'live', 'sandbox_workspace_write.writable_roots': ['/tmp/extra'] } }); + expect(result.serverArgs).toContain('model_reasoning_effort="high"'); + }); + it('rejects unsupported flags before starting an execution rather than silently ignoring them', () => { + for (const args of [['-pwork'], ['--profile=work'], ['--remote=ws://other'], ['--worktree'], ['--oss'], ['-m']]) { + expect(() => sharedLaunchConfig({ codexArgs: args }, '/tmp')).toThrow(); + } + }); + it('does not interpret flags after -- as configuration', () => { + expect(sharedLaunchConfig({ codexArgs: ['--', '--profile=explain'] }, '/tmp').tuiArgs).toEqual(['--', '--profile=explain']); + expect(sharedLaunchConfig({ codexArgs: ['--', '-C', 'literal'] }, '/tmp').tuiArgs).toEqual(['--', '-C', 'literal']); + expect(() => sharedLaunchConfig({ permissionMode: 'safe-yolo' }, '/tmp')).toThrow('not a native shared'); + }); + it('preserves launch-time provider and automatic-review policy', () => { + const result = sharedLaunchConfig({ codexArgs: ['--oss', '--local-provider', 'ollama', '--approve-for-me'] }, '/tmp'); + expect(result.threadParams).toMatchObject({ modelProvider: 'ollama', approvalsReviewer: 'auto_review', sandbox: 'workspace-write' }); + }); +}); diff --git a/cli/src/codex/shared/launch.ts b/cli/src/codex/shared/launch.ts new file mode 100644 index 00000000..4488a211 --- /dev/null +++ b/cli/src/codex/shared/launch.ts @@ -0,0 +1,152 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { z } from 'zod'; +import { resolveCodexCommand, type CodexCommand } from '../utils/codexExecutable'; +import { parseCodexCliOverrides, stripCodexCliOverrides } from '../utils/codexCliOverrides'; +import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig'; +import { getCodexSystemPrompt } from '../utils/systemPrompt'; +import type { CodexAppServerClient } from '../codexAppServerClient'; + +export const SharedLaunchSchema = z.object({ + startedBy: z.enum(['runner', 'terminal']).optional(), + codexArgs: z.array(z.string()).optional(), + permissionMode: z.enum(['default', 'read-only', 'safe-yolo', 'yolo']).optional(), + resumeSessionId: z.string().optional(), resumeLast: z.boolean().optional(), resumeAll: z.boolean().optional(), existingSessionId: z.string().optional(), + model: z.string().optional(), modelReasoningEffort: z.string().optional(), + serviceTier: z.string().optional(), collaborationMode: z.enum(['default', 'plan']).optional(), + workingDirectory: z.string().optional() +}); +export type SharedLaunchOptions = z.infer; + +/** Resolve once: a desktop server and a different PATH TUI are never mixed. */ +export function resolveSharedCodex(): CodexCommand { + const command = resolveCodexCommand(); + const version = execFileSync(command.command, [...command.args, '--version'], { + encoding: 'utf8', timeout: 10_000, windowsHide: true + }).match(/codex-cli (\d+)\.(\d+)\.(\d+)/); + if (!version || Number(version[1]) === 0 && Number(version[2]) < 154) { + throw new Error('Shared sessions require Codex >= 0.154.0. Upgrade the Codex executable on PATH.'); + } + return command; +} + +export function sharedLaunchConfig(options: SharedLaunchOptions, cwd: string): { + cwd: string; serverArgs: string[]; tuiArgs: string[]; threadParams: Record; +} { + if (options.permissionMode === 'safe-yolo') throw new Error('safe-yolo is not a native shared permission mode. Choose default, read-only, yolo, or native --approve-for-me.'); + const args = options.codexArgs ?? []; + const serverArgs: string[] = []; + let tuiArgs: string[] = []; + const config: Record = {}; + const extraDirs: string[] = []; + let model = options.model; + let provider: string | undefined; + let oss = false; + let autoReview = false; + for (let i = 0; i < args.length; i++) { + const raw = args[i]; + if (raw === '--') { tuiArgs.push(...args.slice(i)); break; } + const equal = raw.indexOf('='); + const arg = equal > 0 ? raw.slice(0, equal) : /^-[cmiCasp].+/.test(raw) ? raw.slice(0, 2) : raw; + const inline = equal > 0 ? raw.slice(equal + 1) : arg !== raw ? raw.slice(2) : undefined; + const value = () => { + const result = inline ?? args[++i]; + if (!result || result === '--' || result.startsWith('-') && arg !== '-c' && arg !== '--config') throw new Error(`Missing ${arg} value`); + return result; + }; + // Standalone app-server has no profile-v2 loader override. Flattening + // it to -c would change precedence (including project/admin policy). + if (arg === '-p' || arg === '--profile' || arg === '--config-profile') { + throw new Error('Codex app-server 0.154 does not support profile selection. Use CODEX_HOME with the desired config.toml or explicit -c overrides.'); + } + if (arg.startsWith('--remote')) throw new Error('HAPI owns --remote and its authentication options'); + if (['-c', '--config', '--enable', '--disable'].includes(arg)) { + const v = value(); serverArgs.push(arg, v); + // Remote resume inherits permissions from the server; do not send + // competing client-side permission config (including native /new). + if (!['-c', '--config'].includes(arg) || !/^\s*["']?(approval_policy|sandbox_mode|approvals_reviewer)["']?\s*=/.test(v)) tuiArgs.push(arg, v); + } else if (arg === '-m' || arg === '--model') { + model = value(); tuiArgs.push('--model', model); + } else if (['-s', '--sandbox', '-a', '--ask-for-approval', '-C', '--cd'].includes(arg)) { + const v = value(); + if ((arg === '-s' || arg === '--sandbox') && !['read-only', 'workspace-write', 'danger-full-access'].includes(v)) throw new Error(`Invalid ${arg} value`); + if ((arg === '-a' || arg === '--ask-for-approval') && !['untrusted', 'on-failure', 'on-request', 'never'].includes(v)) throw new Error(`Invalid ${arg} value`); + tuiArgs.push(arg, v); + } else if (arg === '--search') { + config.web_search = 'live'; tuiArgs.push(arg); + } else if (arg === '--add-dir') { + extraDirs.push(value()); + } else if (arg === '--oss') { + oss = true; + } else if (arg === '--local-provider') { + provider = value(); + if (!['ollama', 'lmstudio'].includes(provider)) throw new Error('Unsupported --local-provider'); + } else if (arg === '--approve-for-me' || arg === '--not-so-yolo') { + autoReview = true; + } else if (arg === '--dangerously-bypass-hook-trust') { + config.bypass_hook_trust = true; + } else if (arg === '--strict-config') { + serverArgs.push(arg); + } else if (arg === '-i' || arg === '--image') { + tuiArgs.push('--image', value()); + } else if (['--no-alt-screen', '--yolo', '--dangerously-bypass-approvals-and-sandbox'].includes(arg)) { + tuiArgs.push(arg); + } else if (arg === '--full-auto') { + // Older HAPI accepted this alias; upstream 0.154 removed it. + tuiArgs.push('--ask-for-approval', 'on-request', '--sandbox', 'workspace-write'); + } else if (arg.startsWith('-')) { + throw new Error(`Unsupported shared Codex launch flag: ${arg}`); + } else { + tuiArgs.push(raw); + } + } + if (oss && !provider) throw new Error('--oss requires --local-provider (ollama or lmstudio) for a shared app-server'); + const overrides = parseCodexCliOverrides(tuiArgs); + const directory = resolve(cwd, overrides.cwd ?? '.'); + const delimiter = tuiArgs.indexOf('--'); + for (let i = (delimiter < 0 ? tuiArgs.length : delimiter) - 2; i >= 0; i--) { + if (tuiArgs[i] === '-C' || tuiArgs[i] === '--cd') tuiArgs.splice(i, 2); + } + if (extraDirs.length) config['sandbox_workspace_write.writable_roots'] = extraDirs.map(path => resolve(directory, path)); + if (provider) config.model_provider = provider; + const permission = options.permissionMode ? resolveCodexPermissionModeConfig(options.permissionMode) : undefined; + if (permission) { + // Explicit HAPI permission selection wins on every launch surface: + // initial thread, attached TUI, and later native /new threads. + config.approval_policy = permission.approvalPolicy; + config.sandbox_mode = permission.sandbox; + config.approvals_reviewer = 'user'; + } + // The attached TUI resumes a remote task, which rejects permission flags. + // The initial thread already has their resolved policy; inherit it there. + tuiArgs = stripCodexCliOverrides(tuiArgs); + // Launch-wide flags also apply to later native /new roots, not just the + // initial HAPI-created root. Explicit config layers keep their order. + for (const [key, value] of Object.entries(config)) serverArgs.push('-c', `${key}=${JSON.stringify(value)}`); + return { + cwd: directory, serverArgs, tuiArgs, + threadParams: { + ...(model ? { model } : {}), + ...(provider ? { modelProvider: provider } : {}), + ...(options.serviceTier ? { serviceTier: options.serviceTier === 'fast' ? 'priority' : null } : {}), + ...(overrides.approvalPolicy ? { approvalPolicy: overrides.approvalPolicy } : {}), + ...(overrides.sandbox ? { sandbox: overrides.sandbox } : {}), + ...(autoReview ? { approvalPolicy: 'on-request', approvalsReviewer: 'auto_review', sandbox: 'workspace-write' } : {}), + ...(permission ? { approvalPolicy: permission.approvalPolicy, approvalsReviewer: 'user', sandbox: permission.sandbox } : {}), + config: { ...config, ...(options.modelReasoningEffort ? { model_reasoning_effort: options.modelReasoningEffort } : {}) }, + developerInstructions: getCodexSystemPrompt() + } + }; +} + +export async function initializeSharedClient(client: CodexAppServerClient): Promise { + await client.connect(); + await client.initialize({ clientInfo: { name: 'hapi', title: 'HAPI', version: '1' }, capabilities: { experimentalApi: true } }); +} + +/** Invalid IDs only: no probes may create turns, mutate threads or call a model. */ +export async function checkSharedCapabilities(client: CodexAppServerClient): Promise { + for (const method of ['thread/queue/list', 'thread/queue/add', 'thread/queue/delete', 'thread/queue/start', 'turn/steer', 'thread/settings/update', 'thread/metadata/update', 'thread/fork', 'thread/turns/list']) { + if (!await client.supportsMethod(method)) throw new Error(`Codex lacks ${method}; upgrade to a compatible >= 0.154.0 build`); + } +} diff --git a/cli/src/codex/shared/permissions.test.ts b/cli/src/codex/shared/permissions.test.ts new file mode 100644 index 00000000..aba1d5a3 --- /dev/null +++ b/cli/src/codex/shared/permissions.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentState } from '@/api/types'; +import type { CodexAppServerClient } from '../codexAppServerClient'; +import { SharedCodexPermissions } from './permissions'; + +function fixture() { + let state: AgentState = {}; + const handlers = new Map Promise>(); + const send = vi.fn(); + const session = { getMetadata: () => ({ codexSessionId: 'thread' }), sendAgentMessage: send, + updateAgentState: (fn: (state: AgentState) => AgentState) => { state = fn(state); }, + rpcHandlerManager: { registerHandler: (key: string, fn: (raw: unknown) => Promise) => handlers.set(key, fn) } } as unknown as ApiSessionClient; + const respond = vi.fn(); + const permissions = new SharedCodexPermissions(session, { respond } as unknown as CodexAppServerClient, 'generation'); + const request = { id: 1, method: 'item/tool/requestUserInput', params: { threadId: 'thread', turnId: 'turn', itemId: 'call', + questions: [{ id: 'choice', header: 'Choose', question: 'Which?', options: [{ label: 'A', description: 'A' }, { label: 'B', description: 'B' }] }] } }; + return { permissions, request, respond, handlers, send, state: () => state }; +} +describe('shared request arbitration', () => { + it('withdrawal on disconnect is canceled, not a claim that a native answer won', () => { + const f = fixture(); f.permissions.receive(f.request); f.permissions.close(); + expect(Object.values(f.state().completedRequests ?? {})[0]?.status).toBe('canceled'); + expect(f.respond).not.toHaveBeenCalled(); + expect(f.send).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'tool-call-result', callId: 'call', output: { status: 'canceled' }, is_error: false + }), 'codex:thread:question:call:canceled'); + }); + it('native resolution in the same tick cannot resurrect a prompt or submit a late answer', async () => { + const f = fixture(); f.permissions.receive(f.request); f.permissions.resolved('thread', 1); + await Promise.resolve(); await Promise.resolve(); + expect(Object.keys(f.state().requests ?? {})).toHaveLength(0); + expect(Object.values(f.state().completedRequests ?? {})[0]?.status).toBe('resolved'); + expect(f.respond).not.toHaveBeenCalled(); + f.permissions.receive(f.request); expect(Object.keys(f.state().requests ?? {})).toHaveLength(0); + expect(f.send.mock.calls).toEqual([ + [{ type: 'tool-call', name: 'request_user_input', callId: 'call', input: f.request.params, + id: 'codex:thread:question:call:start' }, 'codex:thread:question:call:start'], + [{ type: 'tool-call-result', callId: 'call', output: { status: 'resolved' }, is_error: false, + id: 'codex:thread:question:call:resolved' }, 'codex:thread:question:call:resolved'] + ]); + }); + it('validates all answers before submitting; sending is not proof of winning', async () => { + const f = fixture(); f.permissions.receive(f.request); + const id = Object.keys(f.state().requests ?? {})[0]; const reply = f.handlers.get('permission')!; + await expect(reply({ id, approved: true, answers: {} })).rejects.toThrow('Missing answer'); + await reply({ id, approved: true, answers: { choice: ['B'] } }); + await vi.waitFor(() => expect(f.respond).toHaveBeenCalledWith(1, { answers: { choice: { answers: ['B'] } } })); + expect(f.state().completedRequests).toBeUndefined(); + expect(f.send).toHaveBeenCalledTimes(1); // Submission alone is not a result. + f.permissions.resolved('thread', 1); + expect(Object.values(f.state().completedRequests ?? {})[0]).toMatchObject({ status: 'resolved' }); + expect(Object.values(f.state().completedRequests ?? {})[0]).not.toHaveProperty('answers'); + }); + it('keeps child questions in their agent trace and replay IDs stable across reconnects', () => { + const f = fixture(); + const request = { ...f.request, params: { ...f.request.params, threadId: 'child' } }; + f.permissions.receive(request); f.permissions.resolved('child', request.id); + expect(f.send.mock.calls[0][0]).toMatchObject({ type: 'agent-run-trace', agentId: 'child', + message: { type: 'tool-call', name: 'request_user_input', callId: 'call' }, + scope: { role: 'child', parentThreadId: 'thread' } }); + const again = fixture(); again.permissions.receive(request); again.permissions.resolved('child', request.id); + expect(again.send.mock.calls).toEqual(f.send.mock.calls); + }); + it('ignores unsupported requests, including requests for an unknown MCP elicitation form', async () => { + const f = fixture(); + f.permissions.receive({ id: 2, method: 'future/request', params: { threadId: 'thread' } }); + await Promise.resolve(); expect(f.respond).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/codex/shared/permissions.ts b/cli/src/codex/shared/permissions.ts new file mode 100644 index 00000000..f8466f6c --- /dev/null +++ b/cli/src/codex/shared/permissions.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import type { CodexAppServerClient } from '../codexAppServerClient'; +import { registerAppServerPermissionHandlers, IGNORE_SHARED_REQUEST } from '../utils/appServerPermissionAdapter'; +import { record, string } from './gateway'; + +const ReplySchema = z.object({ + id: z.string(), approved: z.boolean(), + decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), + reason: z.string().optional(), + answers: z.union([z.record(z.string(), z.array(z.string())), z.record(z.string(), z.object({ answers: z.array(z.string()) }))]).optional() +}); +type Reply = z.infer; +type Pending = { nativeId: string | number; threadId: string; turnId?: string; toolCallId: string; userInput: boolean; input: unknown; submitted: boolean; answer(reply: Reply): void; cancel(error: Error): void }; + +/** No winner is inferred from submitting a Web candidate: only native resolution closes it. */ +export class SharedCodexPermissions { + private readonly pending = new Map(); + private readonly retired = new Set(); + private closed = false; + private questionEvent(request: Pending, status?: 'resolved' | 'canceled'): void { + const id = `codex:${request.threadId}:question:${request.toolCallId}:${status ?? 'start'}`; + const message = status + ? { type: 'tool-call-result', callId: request.toolCallId, output: { status }, is_error: false, id } + : { type: 'tool-call', name: 'request_user_input', callId: request.toolCallId, input: request.input, id }; + const rootThreadId = this.session.getMetadata()?.codexSessionId; + this.session.sendAgentMessage(rootThreadId && rootThreadId !== request.threadId ? { + type: 'agent-run-trace', agentId: request.threadId, cardId: `codex-agent:${request.threadId}`, message, id, + scope: { role: 'child', threadId: request.threadId, parentThreadId: rootThreadId }, scope_role: 'child' + } : message, id); + } + constructor(private readonly session: ApiSessionClient, private readonly client: CodexAppServerClient, private readonly generation: string) { + session.rpcHandlerManager.registerHandler(RPC_METHODS.Permission, async (raw: unknown) => { + const reply = ReplySchema.parse(raw); + const request = this.pending.get(reply.id); + if (!request || request.submitted) throw new Error('Request already resolved or submitted'); + const questions = record(request.input).questions; + if (Array.isArray(questions) && reply.approved) { + if (!reply.answers) throw new Error('Answers are required'); + for (const question of questions) { + const id = string(record(question).id); + if (id && !reply.answers[id]) throw new Error(`Missing answer: ${id}`); + } + } + request.submitted = true; + if (request.userInput && !reply.approved) { + if (!request.turnId) { request.submitted = false; throw new Error('Cannot cancel a question without its native turn ID'); } + try { await this.client.request('turn/interrupt', { threadId: request.threadId, turnId: request.turnId }); } + catch (error) { request.submitted = false; throw error; } + return; + } + request.answer(reply); + }); + } + + receive(request: { id: string | number; method: string; params: unknown }): void { + const threadId = string(record(request.params).threadId); + if (!threadId) return; + const key = `${this.generation}:${threadId}:${typeof request.id}:${request.id}`; + if (this.closed || this.pending.has(key) || this.retired.has(key)) return; + const handlers = new Map unknown>(); + const ask = (tool: string, input: unknown): Promise => new Promise((answer, cancel) => { + const pending: Pending = { nativeId: request.id, threadId, turnId: string(record(request.params).turnId), + toolCallId: string(record(request.params).itemId) ?? key, + userInput: request.method === 'item/tool/requestUserInput', input, answer, cancel, submitted: false }; + this.pending.set(key, pending); + // requestUserInput is a server request, not a native transcript item. + // Persist its lifecycle so resolution cannot erase the question card. + if (pending.userInput) this.questionEvent(pending); + this.session.updateAgentState(state => ({ ...state, requests: { ...state.requests, + [key]: { tool, toolCallId: pending.toolCallId, arguments: input, createdAt: Date.now() } + } })); + }); + registerAppServerPermissionHandlers({ + client: { registerRequestHandler: (method, handler) => { handlers.set(method, handler); } }, + shared: true, + permissionHandler: { handleToolCall: async (_id, name, input) => { + const reply = await ask(name, input); + return { decision: reply.approved ? (reply.decision === 'approved_for_session' ? 'approved_for_session' : 'approved') : reply.decision === 'denied' ? 'denied' : 'abort' }; + } }, + onUserInputRequest: async ({ input }) => { + const reply = await ask('request_user_input', input); + return reply.approved && reply.answers ? { decision: 'accept', answers: reply.answers } : { decision: 'cancel' }; + } + }); + const handler = handlers.get(request.method); + if (!handler) return; + let response: unknown; + try { response = handler(request.params); } catch { return; } + void Promise.resolve(response).then(result => { + if (result === IGNORE_SHARED_REQUEST) return; + if (this.closed || this.retired.has(key)) return; + // No pending entry means a native client already resolved the displayed request. + if (this.pending.has(key) || request.method === 'mcpServer/elicitation/request' && record(request.params).serverName === 'hapi') { + // The adapter's decision envelope is HAPI-internal, not the + // native request_user_input response schema. An invalid first + // response would consume Codex's callback even if it cannot decode. + if (request.method === 'item/tool/requestUserInput') { + const answers = Object.fromEntries(Object.entries(record(record(result).answers)).map(([id, value]) => + [id, { answers: Array.isArray(value) ? value : record(value).answers }])); + this.client.respond(request.id, { answers }); + } else this.client.respond(request.id, result); + } + }).catch(() => { /* Native resolution/disconnection withdraws, never responds with an error. */ }); + } + + resolved(threadId: string, nativeId: unknown, status: 'resolved' | 'canceled' = 'resolved'): void { + this.retired.add(`${this.generation}:${threadId}:${typeof nativeId}:${nativeId}`); + for (const [key, request] of this.pending) { + if (request.threadId !== threadId || request.nativeId !== nativeId) continue; + this.pending.delete(key); + request.cancel(new Error('Resolved by Codex')); + if (request.userInput) this.questionEvent(request, status); + this.session.updateAgentState(state => { + const requests = { ...state.requests }; + const prior = requests[key]; delete requests[key]; + return { ...state, requests, completedRequests: { ...state.completedRequests, + ...(prior ? { [key]: { ...prior, completedAt: Date.now(), status } } : {}) + } }; + }); + } + } + + close(): void { + this.closed = true; + for (const request of [...this.pending.values()]) this.resolved(request.threadId, request.nativeId, 'canceled'); + } +} diff --git a/cli/src/codex/shared/projection.test.ts b/cli/src/codex/shared/projection.test.ts new file mode 100644 index 00000000..319b4739 --- /dev/null +++ b/cli/src/codex/shared/projection.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { SharedCodexProjection, inputText } from './projection'; + +describe('shared history projection', () => { + it.each([undefined, 'root'])('emits canonical error flags for tools (parent: %s)', async parentThreadId => { + const send = vi.fn(); + const session = { getMetadata: () => ({}), sendAgentMessage: send } as unknown as ApiSessionClient; + const projection = new SharedCodexProjection(session, 'thread', async () => {}, parentThreadId); + for (const failed of [true, false]) { + for (const type of ['mcpToolCall', 'collabAgentToolCall']) { + const item = { id: `${type}-${failed}`, type, server: 'test', tool: 'spawnAgent', + status: failed ? 'failed' : 'completed', error: failed ? { message: 'failed' } : null, + result: { Ok: 'done' } }; + await projection.notification('item/completed', { threadId: 'thread', turnId: 'turn', item }); + const body = send.mock.lastCall?.[0]; + const result = parentThreadId ? body.message : body; + expect(result).toMatchObject({ type: 'tool-call-result', is_error: failed }); + expect(result).not.toHaveProperty('isError'); + } + } + }); + it('keeps each turn model through settings changes, reconnect replay and rerouting', async () => { + const send = vi.fn(); + const session = { getMetadata: () => ({}), sendAgentMessage: send } as unknown as ApiSessionClient; + const projection = new SharedCodexProjection(session, 'thread', async () => {}); + await projection.notification('turn/started', { threadId: 'thread', turn: { id: 'old' } }, 'model-a'); + await projection.notification('turn/started', { threadId: 'thread', turn: { id: 'new' } }, 'model-b'); + projection.reset(); + // A replayed start must not rewrite the executing model either. + await projection.notification('turn/started', { threadId: 'thread', turn: { id: 'old' } }, 'model-b'); + const usage = async (turnId: string) => projection.notification('thread/tokenUsage/updated', { + threadId: 'thread', turnId, tokenUsage: { last: { inputTokens: 12, outputTokens: 3 } } + }, 'model-b'); + await usage('old'); + await projection.notification('model/rerouted', { threadId: 'thread', turnId: 'new', fromModel: 'model-b', toModel: 'model-c', reason: 'test' }); + await usage('new'); + const events = send.mock.calls.map(([body]) => body).filter(body => body.type === 'token_count'); + expect(events.map(body => body.model)).toEqual(['model-a', 'model-c']); + }); + it('keeps image-only native inputs visible without embedding data URLs', () => { + expect(inputText([{ type: 'image', url: 'data:image/png;base64,large' }])).toBe('[Image]'); + expect(inputText([{ type: 'localImage', path: '/tmp/image.png' }])).toBe('[Image: /tmp/image.png]'); + }); + it('replays after hub reconnect using the same durable local id', async () => { + const send = vi.fn(); const session = { getMetadata: () => ({}), sendAgentMessage: send } as unknown as ApiSessionClient; + const projection = new SharedCodexProjection(session, 'thread', async () => {}); + const snapshot = { turns: [{ id: 'turn', status: 'completed', items: [{ id: 'item', type: 'agentMessage', text: 'complete' }] }] }; + await projection.history(snapshot); projection.reset(); await projection.history(snapshot); + expect(send).toHaveBeenCalledTimes(2); expect(send.mock.calls[0]).toEqual(send.mock.calls[1]); + }); + it('does not settle an active snapshot under the final message id', async () => { + const send = vi.fn(); + const session = { getMetadata: () => ({}), sendAgentMessage: send } as unknown as ApiSessionClient; + const projection = new SharedCodexProjection(session, 'thread', async () => {}); + await projection.history({ turns: [{ id: 'turn', status: 'inProgress', items: [{ id: 'item', type: 'agentMessage', text: 'partial' }] }] }); + expect(send).not.toHaveBeenCalled(); + await projection.notification('item/completed', { threadId: 'thread', turnId: 'turn', item: { id: 'item', type: 'agentMessage', text: 'complete' } }); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'message', message: 'complete' }), expect.any(String)); + }); + it('routes descendant answers into a scoped agent trace, not a root message', async () => { + const send = vi.fn(); const user = vi.fn(); const committed = vi.fn(async () => {}); + const session = { getMetadata: () => ({}), sendAgentMessage: send, sendUserMessage: user } as unknown as ApiSessionClient; + const projection = new SharedCodexProjection(session, 'child', committed, 'root'); + await projection.notification('item/completed', { threadId: 'child', turnId: 'turn', item: { id: 'item', type: 'agentMessage', text: 'child answer' } }); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'agent-run-trace', agentId: 'child', message: expect.objectContaining({ message: 'child answer' }) }), expect.any(String)); + await projection.notification('item/completed', { threadId: 'child', turnId: 'turn', item: { id: 'prompt', type: 'userMessage', content: [{ type: 'text', text: 'child prompt' }], clientId: 'cid' } }); + expect(user).not.toHaveBeenCalled(); expect(committed).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/codex/shared/projection.ts b/cli/src/codex/shared/projection.ts new file mode 100644 index 00000000..b290a0fd --- /dev/null +++ b/cli/src/codex/shared/projection.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages'; +import { AppServerEventConverter } from '../utils/appServerEventConverter'; +import { record, string } from './gateway'; + +export function inputText(input: unknown): string { + if (!Array.isArray(input)) return ''; + return input.map(part => { + const value = record(part); + return string(value.text) ?? (value.type === 'mention' ? `@"${value.path}"` : value.type === 'skill' ? `$${value.name}` + : value.type === 'localImage' ? `[Image: ${value.path}]` : value.type === 'image' ? '[Image]' : ''); + }).filter(Boolean).join('\n'); +} + +/** Canonical V2 stream only. Stable message IDs also deduplicate snapshot replay at the hub. */ +export class SharedCodexProjection { + private converter = new AppServerEventConverter(); + private readonly emitted = new Set(); + private readonly turns = new Map(); + private readonly turnModels = new Map(); + constructor(private readonly session: ApiSessionClient, readonly threadId: string, + private readonly committed: (id: string) => Promise, private readonly parentThreadId?: string) { + if (!parentThreadId) for (const [id, turn] of Object.entries(session.getMetadata()?.conversationHistoryTurns ?? {})) this.turns.set(id, turn); + } + + turnFor(id: string): string | undefined { return this.turns.get(id); } + reset(): void { this.converter = new AppServerEventConverter(); this.emitted.clear(); } + private send(body: Record, key: string): void { + if (this.emitted.has(key)) return; + this.emitted.add(key); + const id = `codex:${this.threadId}:${key}`; + this.session.sendAgentMessage(this.parentThreadId && !String(body.type).startsWith('agent-run-') ? { + type: 'agent-run-trace', agentId: this.threadId, cardId: `codex-agent:${this.threadId}`, message: { ...body, id }, id, + scope: { role: 'child', threadId: this.threadId, parentThreadId: this.parentThreadId }, scope_role: 'child' + } : { ...body, id }, id); + } + + async notification(method: string, params: unknown, modelAtReceipt?: string): Promise { + if (method.startsWith('codex/event/')) return; + const p = record(params); + const item = record(p.item); + const turnId = string(p.turnId) ?? string(record(p.turn).id); + // Settings may already describe the next turn by the time queued + // notifications are projected. Never attribute usage to that model. + if (turnId && method === 'turn/started' && modelAtReceipt && !this.turnModels.has(turnId)) { + this.turnModels.set(turnId, modelAtReceipt); + } + if (turnId && method === 'model/rerouted' && string(p.toModel)) { + this.turnModels.set(turnId, string(p.toModel)!); + } + const itemId = string(item.id) ?? string(p.itemId); + if (!this.parentThreadId && (method === 'item/started' || method === 'item/completed') && item.type === 'userMessage') { + const id = string(item.clientId ?? item.clientUserMessageId) ?? (itemId ? `codex:${this.threadId}:user:${itemId}` : undefined); + if (id) { + const firstInTurn = turnId ? [...this.turns].find(([, value]) => value === turnId)?.[0] : undefined; + if (turnId) this.turns.set(id, turnId); + await this.committed(id); + const text = inputText(item.content); + if (text) this.session.sendUserMessage(text, undefined, id); + this.session.updateMetadata(metadata => ({ ...metadata, conversationHistoryTurns: Object.fromEntries(this.turns), + ...(turnId && (!firstInTurn || firstInTurn === id) ? { conversationHistoryPoints: { ...metadata.conversationHistoryPoints, [id]: true } } : {}) + })); + } + } + if (this.parentThreadId && (method === 'turn/started' || method === 'turn/completed')) { + this.send({ type: 'agent-run-update', agentId: this.threadId, cardId: `codex-agent:${this.threadId}`, + status: method === 'turn/started' ? 'running' : record(p.turn).status === 'completed' ? 'completed' : 'failed' + }, `lifecycle:${turnId}:${method}`); + } + const events = this.converter.handleNotification(method, params); + for (const event of events) { + const callId = string(event.call_id); + const key = `${turnId ?? 'thread'}:${itemId ?? callId ?? createHash('sha256').update(JSON.stringify(event)).digest('hex')}:${event.type}`; + if (event.type === 'agent_message') this.send({ type: 'message', message: event.message }, key); + else if (event.type === 'agent_reasoning') this.send({ type: 'reasoning', message: event.text }, key); + else if (event.type === 'exec_command_begin' && callId) { + this.send({ type: 'tool-call', name: 'CodexBash', callId, input: event }, key); + } else if (event.type === 'exec_command_end' && callId) { + this.send({ type: 'tool-call-result', callId, output: { ...event, stdout: event.output } }, key); + } else if (event.type === 'patch_apply_begin' && callId) { + this.send({ type: 'tool-call', name: 'CodexPatch', callId, input: { changes: event.changes, auto_approved: event.auto_approved } }, key); + } else if (event.type === 'patch_apply_end' && callId) { + this.send({ type: 'tool-call-result', callId, output: { stdout: event.stdout, stderr: event.stderr, success: event.success } }, key); + } else if (event.type === 'mcp_tool_call_begin' && callId) { + const invocation = record(event.invocation); + this.send({ type: 'tool-call', name: `mcp__${invocation.server}__${invocation.tool}`, callId, input: invocation.arguments ?? {} }, key); + } else if (event.type === 'mcp_tool_call_end' && callId) { + const result = record(event.result); + this.send({ type: 'tool-call-result', callId, output: result.Ok ?? result.Err ?? event.result, is_error: 'Err' in result }, key); + } else if (event.type === 'codex_tool_call_begin' && callId) { + this.send({ type: 'tool-call', name: event.name, callId, input: event.input ?? event.arguments }, key); + } else if (event.type === 'codex_tool_call_end' && callId) { + this.send({ type: 'tool-call-result', callId, output: event.output, is_error: event.is_error }, key); + } else if (event.type === 'token_count' || event.type === 'context_compacted' || event.type.startsWith('thread_goal_')) { + const model = event.type === 'token_count' && turnId ? this.turnModels.get(turnId) : undefined; + this.send({ ...event, ...(model ? { model } : {}), flavor: 'codex', scope: { role: 'parent', threadId: this.threadId }, scope_role: 'parent', thread_id: this.threadId }, key); + } else if (event.type === 'plan_update') { + this.send({ type: 'tool-call', name: 'update_plan', callId: 'codex-plan-state', input: { plan: event.plan, source: 'codex' } }, key); + this.send({ type: 'tool-call-result', callId: 'codex-plan-state', output: { plan: event.plan, source: 'codex', status: 'updated' } }, `${key}:result`); + } else if (event.type === 'generated_image' && typeof event.saved_path === 'string') { + const image = await registerGeneratedImageFromPath({ path: event.saved_path, id: createHash('sha256').update(`${this.threadId}:${key}`).digest('hex'), fileName: string(event.file_name) }); + if (image) this.send({ type: 'generated-image', imageId: image.id, fileName: image.fileName, mimeType: image.mimeType }, key); + } else if (event.type === 'task_failed') { + this.send({ type: 'message', message: `Codex error: ${event.error ?? event.message ?? 'Turn failed'}` }, key); + } + } + if (item.type === 'collabAgentToolCall') { + const states = record(item.agentsStates); + for (const [agentId, state] of Object.entries(states)) { + const status = string(record(state).status) ?? 'running'; + this.send({ type: 'agent-run-update', agentId, cardId: `codex-agent:${agentId}`, + status: status === 'completed' ? 'completed' : status === 'errored' ? 'failed' : 'running', + summary: record(state).message, input: item, scope: { role: 'child', threadId: agentId, parentThreadId: this.threadId }, scope_role: 'child', thread_id: agentId + }, `agent:${agentId}:${itemId}:${method}:${JSON.stringify(state)}`); + } + } + } + + async history(thread: unknown): Promise { + const turns = record(thread).turns; + if (!Array.isArray(turns)) return; + for (const value of turns) { + const turn = record(value); + if (!Array.isArray(turn.items)) continue; + for (const item of turn.items) { + const params = { threadId: this.threadId, turnId: turn.id, item }; + await this.notification('item/started', params); + // Active snapshots can contain partial assistant text. Do not + // settle it under the final stable id and suppress completion. + if (turn.status !== 'inProgress' || record(item).status === 'completed' || record(item).type === 'userMessage') { + await this.notification('item/completed', params); + } + } + } + } +} diff --git a/cli/src/codex/shared/queue.test.ts b/cli/src/codex/shared/queue.test.ts new file mode 100644 index 00000000..d517b655 --- /dev/null +++ b/cli/src/codex/shared/queue.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SharedCodexQueue } from './queue'; + +const input = [{ type: 'text', text: 'hello' }]; +const directories: string[] = []; +afterEach(async () => { await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), 'hapi-queue-')); directories.push(dir); + const rpc = vi.fn<(method: string, params: unknown) => Promise>(); + const client = { request: async (method: string, params?: unknown) => await rpc(method, params) as T }; + const consumed = vi.fn(); const uncertain = vi.fn(); + const queue = new SharedCodexQueue(client, 'thread', join(dir, 'ledger.json'), consumed, uncertain); + await queue.load(); + return { queue, rpc, consumed, uncertain, client, dir }; +} +describe('shared native queue', () => { + it('does not restore potentially stale input when the shutdown snapshot is unavailable', async () => { + const { queue, rpc, uncertain } = await fixture(); + rpc.mockResolvedValueOnce({ queuedSubmission: { id: 'n', input, clientUserMessageId: 'local' } }); await queue.enqueue('local', input); + rpc.mockRejectedValueOnce(new Error('snapshot lost')); await queue.suspend(); + expect(queue.state('local')).toBe('unknown'); expect(uncertain).toHaveBeenCalledWith(['local']); + expect(rpc.mock.calls.some(([method]) => method === 'thread/queue/delete')).toBe(false); + }); + it('returns only confirmed-deleted input to ordinary cold-resume delivery, preserving native edits', async () => { + const { queue, rpc, client, dir, consumed, uncertain } = await fixture(); + rpc.mockResolvedValueOnce({ queuedSubmission: { id: 'n', input, clientUserMessageId: 'local' } }); + await queue.enqueue('local', input); + const edited = [{ type: 'text', text: 'edited in native queue' }]; + rpc.mockResolvedValueOnce({ data: [{ id: 'n', input: edited, clientUserMessageId: 'local' }] }) + .mockResolvedValueOnce({ deleted: true }); + await queue.suspend(); + expect(queue.state('local')).toBe('released'); expect(consumed).not.toHaveBeenCalled(); + const requeued = vi.fn(async () => true); + const restarted = new SharedCodexQueue(client, 'thread', join(dir, 'ledger.json'), consumed, uncertain, undefined, requeued); + await restarted.load(); rpc.mockResolvedValueOnce({ data: [] }); await restarted.reconcile(); + expect(requeued).toHaveBeenCalledWith(['local']); + rpc.mockResolvedValueOnce({ queuedSubmission: { id: 'new-n', input: edited, clientUserMessageId: 'local' } }); + await restarted.enqueue('local', input); + expect(rpc).toHaveBeenLastCalledWith('thread/queue/add', { threadId: 'thread', input: edited, clientUserMessageId: 'local' }); + await restarted.committed('local'); await restarted.enqueue('local', input); + expect(rpc.mock.calls.filter(([method]) => method === 'thread/queue/add')).toHaveLength(2); + }); + it.each(['consumed', 'missing', 'lost ACK'] as const)('does not replay a suspended submission whose delete outcome is %s', async outcome => { + const { queue, rpc, client, dir, consumed, uncertain } = await fixture(); + const queued = { id: 'n', input, clientUserMessageId: 'local' }; + rpc.mockResolvedValueOnce({ queuedSubmission: queued }); await queue.enqueue('local', input); + rpc.mockResolvedValueOnce({ data: [queued] }).mockImplementationOnce(async () => { + if (outcome === 'lost ACK') throw new Error('connection lost'); + if (outcome === 'consumed') await queue.committed('local'); + return { deleted: false }; + }); + await queue.suspend(); expect(queue.state('local')).toBe(outcome === 'consumed' ? 'consumed' : 'unknown'); + const restarted = new SharedCodexQueue(client, 'thread', join(dir, 'ledger.json'), consumed, uncertain); + await restarted.load(); rpc.mockResolvedValue({ data: [] }); await restarted.enqueue('local', input); + expect(rpc.mock.calls.filter(([method]) => method === 'thread/queue/add')).toHaveLength(1); + }); + it('never repeats a completed or uncertain slash-command mutation after execution replacement', async () => { + const { queue, client, dir, consumed, uncertain } = await fixture(); + const create = vi.fn(async () => null); + await queue.command('clear', create); + await expect(queue.command('unknown', async () => { throw new Error('lost binding ACK'); })).rejects.toThrow(); + const restarted = new SharedCodexQueue(client, 'thread', join(dir, 'ledger.json'), consumed, uncertain); await restarted.load(); + await restarted.command('clear', create); + await expect(restarted.command('unknown', create)).rejects.toThrow('not replaying'); + expect(create).toHaveBeenCalledOnce(); expect(consumed).toHaveBeenCalledWith(['clear']); + }); + it('remembers cold-history acceptance before redelivery from the hub', async () => { + const { queue, rpc, client, dir, consumed, uncertain } = await fixture(); + await queue.committed('history-only'); + const restarted = new SharedCodexQueue(client, 'thread', join(dir, 'ledger.json'), consumed, uncertain); await restarted.load(); + await restarted.enqueue('history-only', input); + expect(rpc).not.toHaveBeenCalled(); expect(consumed).toHaveBeenCalledWith(['history-only']); + }); + it('adopts native queued input, mirrors edits and withdraws only after a proven native delete', async () => { + const { rpc, client, dir, consumed, uncertain } = await fixture(); const mirror = vi.fn(); + const queue = new SharedCodexQueue(client, 'thread', join(dir, 'native.json'), consumed, uncertain, mirror); await queue.load(); + rpc.mockResolvedValue({ data: [{ id: 'n', clientUserMessageId: 'native', input }], nextCursor: null }); + await queue.reconcile(); expect(mirror).toHaveBeenCalledWith('native', input); + rpc.mockResolvedValue({ data: [], nextCursor: null }); await queue.reconcile(); + expect(queue.state('native')).toBe('unknown'); expect(mirror).not.toHaveBeenCalledWith('native', null); + await queue.deleted('n'); expect(mirror).toHaveBeenCalledWith('native', null); + }); + it('treats a failed reconciliation during cancel as indeterminate, not a failed ACK', async () => { + const { queue, rpc } = await fixture(); + rpc.mockResolvedValueOnce({ queuedSubmission: { id: 'n', input, clientUserMessageId: 'local' } }); await queue.enqueue('local', input); + rpc.mockRejectedValue(new Error('disconnected')); + expect(await queue.cancel('local')).toBe('indeterminate'); + }); + it('uses queue/add only, with no enqueue-time model/settings or second drainer', async () => { + const { queue, rpc } = await fixture(); + rpc.mockResolvedValue({ queuedSubmission: { id: 'native', input, clientUserMessageId: 'local' } }); + await queue.enqueue('local', input); + expect(rpc.mock.calls).toEqual([['thread/queue/add', { threadId: 'thread', input, clientUserMessageId: 'local' }]]); + }); + it('does not downgrade a committed event arriving before the queue ACK', async () => { + const { queue, rpc, consumed } = await fixture(); + rpc.mockImplementation(async () => { await queue.committed('local'); return { queuedSubmission: { id: 'native', input, clientUserMessageId: 'local' } }; }); + await queue.enqueue('local', input); + expect(queue.state('local')).toBe('consumed'); expect(consumed).toHaveBeenCalledWith(['local']); + }); + it('persists uncertainty before dispatch and never blindly re-enqueues after a crash', async () => { + const { queue, rpc, client, dir, consumed, uncertain } = await fixture(); + rpc.mockResolvedValueOnce({ invalidResponse: true }); + await expect(queue.enqueue('local', input)).rejects.toThrow(); + const restarted = new SharedCodexQueue(client, 'thread', join(dir, 'ledger.json'), consumed, uncertain); await restarted.load(); + rpc.mockResolvedValue({ data: [], nextCursor: null }); + await restarted.enqueue('local', input); + expect(restarted.state('local')).toBe('unknown'); + expect(rpc.mock.calls.filter(([method]) => method === 'thread/queue/add')).toHaveLength(1); + }); + it('waits for a definitive native delete before acknowledging cancellation', async () => { + const { queue, rpc } = await fixture(); + rpc.mockResolvedValueOnce({ queuedSubmission: { id: 'native', input, clientUserMessageId: 'local' } }); await queue.enqueue('local', input); + rpc.mockResolvedValueOnce({ data: [{ id: 'native', input, clientUserMessageId: 'local' }], nextCursor: null }).mockResolvedValueOnce({ deleted: false }); + expect(await queue.cancel('local')).toBe('indeterminate'); + }); + it('pins steer to one turn and restores only on explicit rejection', async () => { + const { queue, rpc } = await fixture(); + const queued = { id: 'native', input, clientUserMessageId: 'local' }; + rpc.mockResolvedValueOnce({ queuedSubmission: queued }); await queue.enqueue('local', input); + rpc.mockResolvedValueOnce({ data: [queued], nextCursor: null }).mockResolvedValueOnce({ deleted: true }) + .mockRejectedValueOnce(new Error('expected active turn id mismatch')).mockResolvedValueOnce({ queuedSubmission: { ...queued, id: 'restored' } }); + expect(await queue.steer('local', 'turn-a')).toMatchObject({ steered: false }); + expect(rpc.mock.calls[3]).toEqual(['turn/steer', { threadId: 'thread', expectedTurnId: 'turn-a', input, clientUserMessageId: 'local' }]); + expect(queue.state('local')).toBe('queued'); + }); +}); diff --git a/cli/src/codex/shared/queue.ts b/cli/src/codex/shared/queue.ts new file mode 100644 index 00000000..0ddb01bc --- /dev/null +++ b/cli/src/codex/shared/queue.ts @@ -0,0 +1,262 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import type { CodexAppServerClient } from '../codexAppServerClient'; +import { isIndeterminateError } from '../codexAppServerClient'; + +const InputSchema = z.array(z.object({ type: z.string() }).passthrough()); +export const SubmissionSchema = z.object({ id: z.string(), input: InputSchema, clientUserMessageId: z.string() }); +const LedgerSchema = z.record(z.string(), z.object({ + input: InputSchema, state: z.enum(['unknown', 'queued', 'consumed', 'canceled', 'rejected', 'released']), nativeId: z.string().optional() +})); +export type QueueInput = z.infer; +type Entry = z.infer[string]; + +/** Native queue is the only drainer. The ledger records uncertainty, not a second queue. */ +export class SharedCodexQueue { + private entries: Record = {}; + private operations: Promise = Promise.resolve(); + private writes = Promise.resolve(); + constructor(private readonly client: Pick, readonly threadId: string, + private readonly file: string, private readonly consumed: (ids: string[], steered?: boolean) => void, + private readonly uncertain: (ids: string[]) => void, + private readonly mirror?: (id: string, input: QueueInput | null) => void, + private readonly requeued?: (ids: string[]) => Promise) {} + + async load(): Promise { + try { this.entries = LedgerSchema.parse(JSON.parse(await readFile(this.file, 'utf8'))); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + } + + private save(): Promise { + const snapshot = JSON.stringify(this.entries); + this.writes = this.writes.catch(() => {}).then(async () => { + await mkdir(dirname(this.file), { recursive: true, mode: 0o700 }); + const temp = `${this.file}.${randomUUID()}.tmp`; + await writeFile(temp, snapshot, { mode: 0o600 }); + await rename(temp, this.file); + }); + return this.writes; + } + private serial(work: () => Promise): Promise { + const next = this.operations.catch(() => {}).then(work); this.operations = next; return next; + } + owns(id: string): boolean { return id in this.entries; } + state(id: string): Entry['state'] | undefined { return this.entries[id]?.state; } + /** Slash commands can mutate native state too. Crash/redelivery must not + * repeat /new, /compact, or a settings change whose outcome was lost. */ + command(id: string, work: () => Promise): Promise { + return this.serial(async () => { + const existing = this.entries[id]; + if (existing?.state === 'consumed') { this.consumed([id]); return null; } + if (existing && !['rejected', 'canceled'].includes(existing.state)) { + this.uncertain([id]); throw new Error('Previous command outcome is unknown; not replaying'); + } + this.entries[id] = { input: [], state: 'unknown' }; await this.save(); + try { + const result = await work(); + if (result === null) await this.committed(id); + else { delete this.entries[id]; await this.save(); } + return result; + } catch (error) { this.uncertain([id]); throw error; } + }); + } + async committed(id: string): Promise { + // History may prove acceptance after execution replacement, before hub + // redelivery. Remember it even when this generation did not enqueue it. + const entry = this.entries[id] ??= { state: 'consumed', input: [] }; + entry.state = 'consumed'; + await this.save(); this.consumed([id]); + } + + async list(): Promise>> { + const items: Array> = []; + let cursor: string | undefined; + do { + const page = z.object({ data: z.array(SubmissionSchema), nextCursor: z.string().nullish() }).parse( + await this.client.request('thread/queue/list', { threadId: this.threadId, cursor })); + items.push(...page.data); cursor = page.nextCursor ?? undefined; + } while (cursor); + return items; + } + reconcile(): Promise { return this.serial(() => this.reconcileNow()); } + private async reconcileNow(): Promise { + const present = new Set(); + for (const item of await this.list()) { + const id = item.clientUserMessageId; present.add(id); + const entry = this.entries[id] ??= { input: item.input, state: 'queued' }; + if (entry.state !== 'consumed' && entry.state !== 'canceled') { + const changed = entry.nativeId !== item.id || JSON.stringify(entry.input) !== JSON.stringify(item.input); + entry.nativeId = item.id; entry.input = item.input; entry.state = 'queued'; + if (changed) this.mirror?.(id, item.input); + } + } + // Absence could mean consumed, removed, or lost during engine restart. + // Only an item event/history or a successful delete can decide which. + for (const [id, entry] of Object.entries(this.entries)) { + if (entry.state === 'queued' && !present.has(id)) entry.state = 'unknown'; + } + await this.save(); + const unknown = Object.entries(this.entries).filter(([, entry]) => entry.state === 'unknown').map(([id]) => id); + if (unknown.length) this.uncertain(unknown); + await this.publishReleased(); + } + + private async publishReleased(): Promise { + const released = Object.entries(this.entries).filter(([, entry]) => entry.state === 'released'); + for (const [id, entry] of released) this.mirror?.(id, entry.input); + // A crash between the ledger write and hub ACK is recovered on bind, + // before the existing hub replay delivers these messages again. + if (released.length) await this.requeued?.(released.map(([id]) => id)); + } + + /** Return proven unexecuted input to the hub's existing resume queue. + * Run only after all frontends stop submitting; no second native drainer. */ + suspend(): Promise { + return this.serial(async () => { + try { await this.reconcileNow(); } catch { + // Without a fresh snapshot even the input may have been edited. + // Do not delete and later restore stale contents from the ledger. + const unknown: string[] = []; + for (const [id, entry] of Object.entries(this.entries)) { + if (entry.state === 'queued') entry.state = 'unknown'; + if (entry.state === 'unknown') unknown.push(id); + } + await this.save(); if (unknown.length) this.uncertain(unknown); + return; + } + for (const [id, entry] of Object.entries(this.entries)) { + if (!['queued', 'unknown'].includes(entry.state)) continue; + // Persist uncertainty before deletion; a lost ACK cannot authorize replay. + entry.state = 'unknown'; await this.save(); + if (entry.nativeId) { + try { + const result = z.object({ deleted: z.boolean() }).parse(await this.client.request('thread/queue/delete', { + threadId: this.threadId, queuedSubmissionId: entry.nativeId + })); + if (result.deleted && this.state(id) !== 'consumed') entry.state = 'released'; + } catch { /* Keep unknown unless an item event proved consumption. */ } + } + await this.save(); + if (entry.state === 'unknown') this.uncertain([id]); + } + await this.publishReleased(); + }); + } + + /** Successful native deletion, observed at the gateway response barrier. */ + deleted(nativeId: string): Promise { + return this.serial(async () => { + for (const [id, entry] of Object.entries(this.entries)) { + if (entry.nativeId !== nativeId || entry.state === 'consumed') continue; + entry.state = 'canceled'; await this.save(); this.mirror?.(id, null); + } + }); + } + + replay(): void { + for (const [id, entry] of Object.entries(this.entries)) { + if (entry.state === 'queued' || entry.state === 'released') this.mirror?.(id, entry.input); + if (entry.state === 'canceled') this.mirror?.(id, null); + } + } + + enqueue(id: string, input: QueueInput, resumeInterrupted = false): Promise { + return this.serial(async () => { + const existing = this.entries[id]; + if (existing && !['rejected', 'canceled', 'released'].includes(existing.state)) { + if (existing.state === 'consumed') this.consumed([id]); + else await this.reconcileNow(); + return; + } + // Preserve native edits and non-text input when the hub redelivers. + if (existing?.state === 'released') input = existing.input; + const entry: Entry = { state: 'unknown', input }; + this.entries[id] = entry; await this.save(); + try { + const response = z.object({ queuedSubmission: SubmissionSchema }).parse(await this.client.request('thread/queue/add', { + threadId: this.threadId, input, clientUserMessageId: id + })); + if (entry.state !== 'consumed') { entry.state = 'queued'; entry.nativeId = response.queuedSubmission.id; } + await this.save(); + if (resumeInterrupted) { + // Atomic idle precondition upstream: a competing terminal cannot turn this into steer. + await this.client.request('thread/queue/start', { threadId: this.threadId }).catch(() => {}); + } + } catch (error) { + if (entry.state !== 'consumed') { + entry.state = isIndeterminateError(error) || error instanceof z.ZodError ? 'unknown' : 'rejected'; + await this.save(); + if (entry.state === 'unknown') this.uncertain([id]); + } + throw error; + } + }); + } + + cancel(id: string): Promise { + return this.serial(async () => { + const entry = this.entries[id]; + if (!entry) return 'indeterminate'; + if (entry.state === 'consumed') return 'consumed'; + if (entry.state === 'canceled' || entry.state === 'rejected' || entry.state === 'released') { + entry.state = 'canceled'; await this.save(); return true; + } + try { await this.reconcileNow(); } + catch { this.uncertain([id]); return 'indeterminate'; } + if (this.state(id) === 'consumed') return 'consumed'; + if (!entry.nativeId) return 'indeterminate'; + entry.state = 'unknown'; await this.save(); + try { + const result = z.object({ deleted: z.boolean() }).parse(await this.client.request('thread/queue/delete', { + threadId: this.threadId, queuedSubmissionId: entry.nativeId + })); + if (this.state(id) === 'consumed') return 'consumed'; + if (result.deleted) { entry.state = 'canceled'; await this.save(); return true; } + this.uncertain([id]); return 'indeterminate'; + } catch { this.uncertain([id]); return 'indeterminate'; } + }); + } + + steer(id: string, expectedTurnId: string, freshInput?: QueueInput): Promise<{ steered: boolean; indeterminate?: boolean; error?: string }> { + return this.serial(async () => { + let entry = this.entries[id]; + if (entry?.state === 'consumed') return { steered: false, error: 'Message already consumed' }; + if (entry) { + try { await this.reconcileNow(); } + catch { this.uncertain([id]); return { steered: false, indeterminate: true }; } + if (this.state(id) === 'consumed') return { steered: false, error: 'Message already consumed' }; + if (!entry.nativeId || entry.state !== 'queued') return { steered: false, indeterminate: true }; + entry.state = 'unknown'; await this.save(); + try { + const response = z.object({ deleted: z.boolean() }).parse(await this.client.request('thread/queue/delete', { + threadId: this.threadId, queuedSubmissionId: entry.nativeId + })); + if (!response.deleted) { this.uncertain([id]); return { steered: false, indeterminate: true }; } + } catch { this.uncertain([id]); return { steered: false, indeterminate: true }; } + } else if (freshInput) { + entry = { input: freshInput, state: 'unknown' }; this.entries[id] = entry; await this.save(); + } else return { steered: false, error: 'Message not queued' }; + try { + await this.client.request('turn/steer', { threadId: this.threadId, expectedTurnId, input: entry.input, clientUserMessageId: id }); + entry.state = 'consumed'; await this.save(); this.consumed([id], true); return { steered: true }; + } catch (error) { + if (this.state(id) === 'consumed') return { steered: true }; + if (isIndeterminateError(error)) { this.uncertain([id]); return { steered: false, indeterminate: true }; } + // Definitely rejected; restoring a removed queued item is safe. Never restore an unknown dispatch. + if (!freshInput) { + try { + const response = z.object({ queuedSubmission: SubmissionSchema }).parse(await this.client.request('thread/queue/add', { + threadId: this.threadId, input: entry.input, clientUserMessageId: id + })); + if (entry.state !== 'consumed') { entry.state = 'queued'; entry.nativeId = response.queuedSubmission.id; } + } catch { if (this.state(id) !== 'consumed') { entry.state = 'unknown'; this.uncertain([id]); } } + } else entry.state = 'rejected'; + await this.save(); return { steered: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + } + + async flush(): Promise { await this.operations.catch(() => {}); await this.writes; } +} diff --git a/cli/src/codex/shared/registry.test.ts b/cli/src/codex/shared/registry.test.ts new file mode 100644 index 00000000..dec3063e --- /dev/null +++ b/cli/src/codex/shared/registry.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const state = vi.hoisted(() => ({ home: '', auth: 'token', processes: new Map() })); +vi.mock('@/configuration', () => ({ configuration: { get happyHomeDir() { return state.home; }, apiUrl: 'hub', get cliApiToken() { return state.auth; } } })); +vi.mock('@/utils/process', () => ({ isProcessAlive: (pid: number) => state.processes.has(pid), getProcessStartMarker: (pid: number) => state.processes.get(pid) })); +import { findRuntime, runtimeAlive, runtimeAuthHash, runtimeMayBeAlive, saveRuntime, withThreadOwnership, type CodexRuntimeRecord } from './registry'; + +const directories: string[] = []; +afterEach(async () => { state.processes.clear(); state.auth = 'token'; await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); +async function fixture(): Promise { + const home = await mkdtemp(join(tmpdir(), 'hapi-owner-')); directories.push(home); state.home = join(home, 'hapi'); + return { id: 'owner', pid: 1111, marker: 'worker-start', serverPid: 2222, serverMarker: 'server-start', command: 'codex', args: [], + codexHome: join(home, 'codex'), endpoint: 'unix://private', hub: 'hub', authHash: runtimeAuthHash(), + sessions: { sid: { threadId: 'thread', namespace: 'ns', active: true } } }; +} +describe('shared runtime ownership', () => { + it('distinguishes PID reuse from an unknown generation and a live orphan engine', async () => { + const owner = await fixture(); state.processes.set(1111, 'different-start'); + expect(runtimeAlive(owner)).toBe(false); expect(runtimeMayBeAlive(owner)).toBe(false); + state.processes.set(1111, undefined); expect(runtimeMayBeAlive(owner)).toBe(true); + state.processes.delete(1111); state.processes.set(2222, 'server-start'); expect(runtimeMayBeAlive(owner)).toBe(true); + await saveRuntime(owner); + await expect(withThreadOwnership(owner.codexHome, 'thread', 'new', async () => {})).rejects.toThrow('orphaned'); + }); + it('protects one native store even across HAPI homes and scopes attach by authentication', async () => { + const owner = await fixture(); state.processes.set(1111, owner.marker); await saveRuntime(owner); + expect(await findRuntime('sid')).toMatchObject({ id: owner.id }); + state.auth = 'other'; expect(await findRuntime('sid')).toBeUndefined(); + state.home = join(directories[0], 'different-hapi'); + await expect(withThreadOwnership(owner.codexHome, 'thread', 'new', async () => {})).rejects.toThrow('hapi resume sid'); + state.processes.clear(); expect(await withThreadOwnership(owner.codexHome, 'thread', 'new', async () => 'safe')).toBe('safe'); + }); + it('fails closed on corrupt records and unknown thread IDs after a creation timeout', async () => { + const owner = await fixture(); owner.pendingCreations = ['unbound']; state.processes.set(1111, owner.marker); await saveRuntime(owner); + await expect(withThreadOwnership(owner.codexHome, 'unknown-thread', 'new', async () => {})).rejects.toThrow('unconfirmed creation'); + const directory = join(owner.codexHome, 'hapi-runtime-owners'); await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'broken.json'), '{'); + await expect(withThreadOwnership(owner.codexHome, 'thread', 'new', async () => {})).rejects.toThrow('Cannot verify'); + }); +}); diff --git a/cli/src/codex/shared/registry.ts b/cli/src/codex/shared/registry.ts new file mode 100644 index 00000000..0f627b16 --- /dev/null +++ b/cli/src/codex/shared/registry.ts @@ -0,0 +1,109 @@ +import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID, createHash } from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import { z } from 'zod'; +import { configuration } from '@/configuration'; +import { getProcessStartMarker, isProcessAlive } from '@/utils/process'; +import { withSettingsFileLock } from '@hapi/protocol/settingsFileLock'; + +const RuntimeSchema = z.object({ + id: z.string(), pid: z.number().int().positive(), marker: z.string(), + serverPid: z.number().int().positive().optional(), serverMarker: z.string().optional(), + endpoint: z.string(), token: z.string().optional(), + command: z.string(), args: z.array(z.string()), codexHome: z.string(), hub: z.string(), authHash: z.string(), + pendingCreations: z.array(z.string()).optional(), + sessions: z.record(z.string(), z.object({ threadId: z.string(), namespace: z.string(), active: z.boolean() })) +}); +export type CodexRuntimeRecord = z.infer; + +export function runtimeDirectory(): string { + return join(configuration.happyHomeDir, 'codex-runtimes'); +} +export function runtimeAuthHash(): string { return createHash('sha256').update(configuration.cliApiToken).digest('hex'); } + +export function codexHome(): string { + const home = resolve(process.env.CODEX_HOME || join(homedir(), '.codex')); + try { return realpathSync(home); } catch { return home; } +} + +export function runtimeAlive(record: CodexRuntimeRecord): boolean { + return isProcessAlive(record.pid) && getProcessStartMarker(record.pid) === record.marker; +} + +function generationMayBeAlive(pid: number, marker?: string): boolean { + if (!isProcessAlive(pid)) return false; + const actual = getProcessStartMarker(pid); + // A failed OS probe is not evidence that the engine exited. + return !actual || !marker || actual === marker; +} +export function runtimeMayBeAlive(owner: CodexRuntimeRecord): boolean { + return generationMayBeAlive(owner.pid, owner.marker) + || Boolean(owner.serverPid && generationMayBeAlive(owner.serverPid, owner.serverMarker)); +} + +export async function readRuntimes(): Promise { + return readRecords(runtimeDirectory()); +} +async function readRecords(directory: string, strict = false): Promise { + const names = await readdir(directory).catch((error: NodeJS.ErrnoException) => { + if (strict && error.code !== 'ENOENT') throw error; + return [] as string[]; + }); + const records = await Promise.all(names.filter(name => name.endsWith('.json')).map(async name => { + try { return RuntimeSchema.parse(JSON.parse(await readFile(join(directory, name), 'utf8'))); } + catch (error) { + if (strict) throw new Error(`Cannot verify Codex ownership record ${join(directory, name)}. Inspect it and its processes before recovery.`, { cause: error }); + return null; + } + })); + return records.filter((record): record is CodexRuntimeRecord => record !== null); +} + +export async function saveRuntime(record: CodexRuntimeRecord): Promise { + // Ownership follows the native store, even when two launches use different + // HAPI_HOME / hub namespaces. These are files, not a global agent daemon. + for (const directory of [join(record.codexHome, 'hapi-runtime-owners'), runtimeDirectory()]) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const target = join(directory, `${record.id}.json`); + const temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, JSON.stringify(record), { mode: 0o600 }); + await rename(temporary, target); + } +} + +/** The store-wide lock spans cold resume, not merely registry writes. */ +export async function withThreadOwnership(home: string, threadId: string, ownId: string, work: () => Promise): Promise { + const directory = join(home, 'hapi-runtime-owners'); + await mkdir(directory, { recursive: true, mode: 0o700 }); + return withSettingsFileLock(join(directory, 'ownership'), async () => { + for (const owner of await readRecords(directory, true)) { + if (owner.id === ownId || owner.codexHome !== home) continue; + if (owner.pendingCreations?.length && runtimeMayBeAlive(owner)) { + throw new Error(`Codex runtime ${owner.id} has an unconfirmed creation. Inspect/stop that runtime before cold resume; its thread ID may be unknown.`); + } + const match = Object.entries(owner.sessions).find(([, session]) => session.threadId === threadId && session.active); + if (!match) continue; + const orphanAlive = owner.serverPid && generationMayBeAlive(owner.serverPid, owner.serverMarker); + if (generationMayBeAlive(owner.pid, owner.marker) || orphanAlive) { + throw new Error(`Thread belongs to another Codex runtime. Use hapi resume ${match[0]}.${orphanAlive && !runtimeAlive(owner) ? ' Its wrapper exited; stop the orphaned runtime before recovery.' : ''}`); + } + } + return work(); + }); +} + +export async function findRuntime(sessionId: string): Promise { + return (await readRuntimes()).find(record => record.hub === configuration.apiUrl && record.authHash === runtimeAuthHash() + && record.sessions[sessionId]?.active && runtimeAlive(record)); +} + +export async function findColdBinding(home: string, threadId: string): Promise { + for (const owner of await readRuntimes()) { + if (owner.codexHome !== home || owner.hub !== configuration.apiUrl || owner.authHash !== runtimeAuthHash()) continue; + const match = Object.entries(owner.sessions).find(([, session]) => session.threadId === threadId); + if (match) return match[0]; + } + return undefined; +} diff --git a/cli/src/codex/shared/root.test.ts b/cli/src/codex/shared/root.test.ts new file mode 100644 index 00000000..9bfc6725 --- /dev/null +++ b/cli/src/codex/shared/root.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentState, Metadata } from '@/api/types'; +import type { SessionBootstrapResult } from '@/agent/sessionFactory'; +import { SharedCodexRoot, type RootHost } from './root'; + +vi.mock('../codexAppServerClient', () => ({ + CodexAppServerClient: class { + initialized = false; + thread = { id: 'thread', turns: [] as Array<{ id: string; status: string; items: unknown[] }> }; + notify?: (method: string, params: unknown) => void; + abandoned?: () => void; + setNotificationHandler(handler: typeof this.notify) { this.notify = handler; } + setTransportAbandonedHandler(handler: (() => void) | null) { this.abandoned = handler ?? undefined; } + setServerRequestHandler() {} + async connect() {} + async initialize() { this.initialized = true; } + isInitialized() { return this.initialized; } + async disconnect() { this.initialized = false; } + async request(method: string) { + if (method === 'thread/read' || method === 'thread/resume') return { model: 'mock', thread: this.thread }; + if (method === 'thread/list' || method === 'thread/queue/list') return { data: [] }; + throw new Error(`Unexpected request: ${method}`); + } + }, + isIndeterminateError: () => false +})); +vi.mock('../utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async () => ({ + mcpServers: {}, server: { stop() {} } +}) })); + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); + +async function fixture() { + const directory = await mkdtemp('/tmp/hapi-shared-root-'); + let state: AgentState = { steeringActive: true }; + let metadata: Metadata = { path: directory, host: 'test', flavor: 'codex' }; + let reconnect: (() => void) | null = null; + const updateState = vi.fn((fn: (value: AgentState) => AgentState) => { state = fn(state); }); + const session = { + sessionId: 'sid', getMetadata: () => metadata, + updateMetadata: (fn: (value: Metadata) => Metadata) => { metadata = fn(metadata); }, + updateAgentState: updateState, keepAlive() {}, + onUserMessage() {}, onCancelQueuedMessage() {}, onRetryQueuedMessage() {}, + onReconnect: (fn: (() => void) | null) => { reconnect = fn; }, + rpcHandlerManager: { registerHandler() {} }, sendSessionEvent() {}, sendAgentMessage() {}, + sendSessionDeath() {}, async flush() {}, close() {} + } as unknown as ApiSessionClient; + const root = new SharedCodexRoot({ session, workingDirectory: directory } as SessionBootstrapResult, { + directory, generation: 'test', endpoint: 'mock', settingsFor: () => undefined, + create: async () => { throw new Error('Unexpected root creation'); }, + end: async () => { throw new Error('Unexpected root archive'); } + } satisfies RootHost); + cleanups.push(async () => { await root.close(false); await rm(directory, { recursive: true, force: true }); }); + await root.prepare(); + await root.bind('thread', { model: 'mock', thread: { turns: [] } }, false); + const native = root.client as unknown as { + initialized: boolean; + thread: { id: string; turns: Array<{ id: string; status: string; items: unknown[] }> }; + notify(method: string, params: unknown): void; + abandoned(): void; + }; + return { root, native, state: () => state, updateState, reconnect: () => reconnect?.() }; +} + +describe('shared steering availability', () => { + it('publishes root turn transitions, ignores child turns, and clears on shutdown', async () => { + const f = await fixture(); + expect(f.state().steeringActive).toBe(false); + f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'turn' } }); + expect(f.state().steeringActive).toBe(true); + f.native.notify('turn/completed', { threadId: 'thread', turn: { id: 'old-turn' } }); + expect(f.state().steeringActive).toBe(true); + f.native.notify('turn/completed', { threadId: 'thread', turn: { id: 'turn', status: 'completed' } }); + expect(f.state().steeringActive).toBe(false); + f.native.notify('turn/started', { threadId: 'child', turn: { id: 'child-turn' } }); + expect(f.state().steeringActive).toBe(false); + f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'next' } }); + f.root.stopAccepting(); + expect(f.state().steeringActive).toBe(false); + }); + + it('reconciles native and Hub reconnects without publishing on every refresh', async () => { + const f = await fixture(); + const updates = f.updateState.mock.calls.length; + await f.root.refresh(); await f.root.refresh(); + expect(f.updateState).toHaveBeenCalledTimes(updates); + f.native.thread.turns = [{ id: 'busy', status: 'inProgress', items: [] }]; + f.reconnect(); + await vi.waitFor(() => expect(f.state().steeringActive).toBe(true)); + f.native.initialized = false; f.native.abandoned(); + expect(f.state().steeringActive).toBe(false); + await vi.waitFor(() => expect(f.state().steeringActive).toBe(true)); + f.native.thread.turns = [{ id: 'busy', status: 'completed', items: [] }]; + f.reconnect(); + await vi.waitFor(() => expect(f.state().steeringActive).toBe(false)); + }); +}); diff --git a/cli/src/codex/shared/root.ts b/cli/src/codex/shared/root.ts new file mode 100644 index 00000000..9bd5cca0 --- /dev/null +++ b/cli/src/codex/shared/root.ts @@ -0,0 +1,507 @@ +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import type { SessionBootstrapResult } from '@/agent/sessionFactory'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { logger } from '@/ui/logger'; +import { listSlashCommands } from '@/modules/common/slashCommands'; +import { normalizeCodexModel } from '@/modules/common/codexModels'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import { CodexAppServerClient } from '../codexAppServerClient'; +import { buildHapiMcpBridge, type HapiMcpBridge } from '../utils/buildHapiMcpBridge'; +import { buildUserInputFromMessage } from '../utils/appServerConfig'; +import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig'; +import { LunaReserve } from '../utils/lunaReserve'; +import { resolveCodexSlashCommand } from '../utils/slashCommands'; +import { parseReasoningEffortValue } from '../utils/reasoningEffort'; +import { SharedCodexPermissions } from './permissions'; +import { SharedCodexQueue } from './queue'; +import { SharedCodexProjection, inputText } from './projection'; +import { getCodexSystemPrompt } from '../utils/systemPrompt'; +import { record, string } from './gateway'; +import { initializeSharedClient, type SharedLaunchOptions } from './launch'; +import { inheritedSandbox, settingsMatch } from './settings'; + +type RuntimeSettings = NonNullable[2]>; +export type RootHost = { + directory: string; generation: string; endpoint: string; token?: string; + settingsFor(threadId: string): Record | undefined; + create(method: 'thread/start' | 'thread/fork', params: Record, parent?: SharedCodexRoot): Promise; + end(root: SharedCodexRoot, nativeArchive?: boolean): Promise; +}; +const SettingsSchema = z.object({ + permissionMode: z.enum(['default', 'read-only', 'safe-yolo', 'yolo']).optional(), + model: z.string().min(1).optional(), modelReasoningEffort: z.string().nullable().optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), + serviceTier: z.enum(['fast', 'standard']).nullable().optional(), + personality: z.enum(['friendly', 'pragmatic', 'none']).optional() +}); + +/** One immutable HAPI identity + independent subscription for one root. */ +export class SharedCodexRoot { + readonly client: CodexAppServerClient; + readonly session: ApiSessionClient; + bridge!: HapiMcpBridge; + threadId = ''; + private permissions!: SharedCodexPermissions; + private queue!: SharedCodexQueue; + private projection!: SharedCodexProjection; + private readonly children = new Map(); + private readonly ancestry = new Map(); + private readonly reserve: LunaReserve; + private heartbeat?: ReturnType; + private usageTimer?: ReturnType; + private work: Promise = Promise.resolve(); + private notifications = Promise.resolve(); + private currentTurn: string | undefined; + private steeringActive: boolean | undefined; + private turnRevision = 0; + private settingsRevision = 0; + private refreshing?: Promise; + private interrupted = false; + private closed = false; + private stopping = false; + private closing?: Promise; + private readonly controls = new Set>(); + private reconnecting = false; + private settings: RuntimeSettings = {}; + private settingsNative: Record = {}; + private settingsNotification?: Record; + private readonly settingsListeners = new Set<() => void>(); + private ready!: () => void; + private readonly bound = new Promise(resolve => { this.ready = resolve; }); + + constructor(readonly bootstrap: SessionBootstrapResult, private readonly host: RootHost) { + this.session = bootstrap.session; + this.client = new CodexAppServerClient({ endpoint: host.endpoint, token: host.token, cwd: bootstrap.workingDirectory }); + this.reserve = new LunaReserve(this.client, + (model, effort, serviceTier) => { this.settings = { ...this.settings, model, modelReasoningEffort: effort, serviceTier }; }, + codexUsage => this.session.updateAgentState(state => ({ ...state, codexUsage })), message => this.notice(message)); + this.client.setNotificationHandler((method, params) => { + if (method === 'serverRequest/resolved') { + this.permissions?.resolved(string(record(params).threadId) ?? '', record(params).requestId); return; + } + if (method === 'thread/settings/updated' && record(params).threadId === this.threadId) this.acceptSettings(record(record(params).threadSettings)); + if (record(params).threadId === this.threadId) { + if (method === 'turn/started') { + this.turnRevision++; this.currentTurn = string(record(record(params).turn).id); this.interrupted = false; + this.publishSteering(); + } + if (method === 'turn/completed' && (!this.currentTurn || record(record(params).turn).id === this.currentTurn)) { + this.turnRevision++; this.currentTurn = undefined; this.interrupted = record(record(params).turn).status === 'interrupted'; + this.publishSteering(); + } + } + const modelAtReceipt = record(params).threadId === this.threadId ? this.settings.model ?? undefined : undefined; + this.notifications = this.notifications.then(() => this.notification(method, params, modelAtReceipt)).catch(error => logger.debug('[Codex shared] projection', error)); + }); + this.client.setTransportAbandonedHandler(() => { void this.reconnect(); }); + this.session.onUserMessage((message, localId) => { + this.work = this.work.catch(() => {}).then(async () => { + await this.bound; + if (this.closed || this.stopping) return; + const id = localId ?? randomUUID(); + const text = formatMessageWithAttachments(message.content.text, message.content.attachments); + const resolved = text.trim().startsWith('/') ? await this.queue.command(id, () => this.command(text)) : text; + if (resolved === null) { this.session.emitMessagesConsumed([id], { clearQueuedThinkingGrace: true }); return; } + await this.refreshUsage(); + await this.queue.enqueue(id, buildUserInputFromMessage(resolved), this.interrupted); + }).catch(error => this.notice(`Message not confirmed: ${error instanceof Error ? error.message : error}. Inspect the queue before retrying.`)); + }); + this.session.onCancelQueuedMessage(async id => { await this.bound; return this.stopping ? 'indeterminate' : this.queue.cancel(id); }); + // Absence from history is NOT proof an uncertain mutation failed. + this.session.onRetryQueuedMessage(async id => { + await this.bound; + if (this.stopping) return false; + await this.refresh(); + return ['rejected', 'canceled', 'released'].includes(this.queue.state(id) ?? ''); + }); + this.session.onReconnect(() => { + if (!this.threadId || this.closed || this.stopping) return; + // An emitted socket packet is not a durable hub acknowledgement. + // Replay final native history with stable IDs after every reconnect. + this.projection?.reset(); + this.steeringActive = undefined; + void this.refresh().then(() => this.refreshChildren(false)).then(() => this.queue.replay()) + .catch(error => logger.debug('[Codex shared] hub resync', error)); + }); + } + + async prepare(): Promise { + this.bridge = await buildHapiMcpBridge(this.session, { exportSessionEnv: false, emitTitleSummary: false, + skillLookup: { workingDirectory: this.bootstrap.workingDirectory, flavor: 'codex' } }); + await initializeSharedClient(this.client); + this.permissions = new SharedCodexPermissions(this.session, this.client, this.host.generation); + this.client.setServerRequestHandler(request => { void this.receiveRequest(request); }); + } + private async receiveRequest(request: { id: string | number; method: string; params: unknown }): Promise { + await this.bound; + if (this.closed || this.stopping) return; + const threadId = string(record(request.params).threadId); + // App-server automatically subscribes every initialized connection to + // newly created threads, including siblings. Never answer their calls. + if (threadId && await this.ownsThread(threadId)) this.permissions.receive(request); + } + private async ownsThread(threadId: string): Promise { + const visited = new Set(); + let candidate: string | null = threadId; + while (candidate && !visited.has(candidate)) { + if (candidate === this.threadId) return true; + visited.add(candidate); + if (!this.ancestry.has(candidate)) { + try { + const thread = record(record(await this.client.request('thread/read', { threadId: candidate, includeTurns: false })).thread); + this.ancestry.set(candidate, string(thread.parentThreadId) ?? null); + } catch { return false; } + } + candidate = this.ancestry.get(candidate) ?? null; + } + return false; + } + config(params: Record): Record { + return { ...params, cwd: params.cwd ?? this.bootstrap.workingDirectory, + developerInstructions: params.developerInstructions ?? getCodexSystemPrompt(), config: { + ...record(params.config), 'mcp_servers.hapi': this.bridge.mcpServers.hapi, + 'shell_environment_policy.set.HAPI_SESSION_ID': this.session.sessionId + } }; + } + async bind(threadId: string, response: Record, subscribe: boolean): Promise { + if (this.threadId && this.threadId !== threadId) throw new Error('Cannot retarget a shared HAPI session'); + this.threadId = threadId; + this.queue = new SharedCodexQueue(this.client, threadId, join(this.host.directory, `${this.session.sessionId}.queue.json`), + (ids, steered) => this.session.emitMessagesConsumed(ids, { steered }), ids => this.session.emitSteerIndeterminate(ids), + (id, input) => this.session.syncNativeQueuedMessage(id, input === null ? null : inputText(input)), + ids => this.session.setSteerDeliveryState(ids, 'queued')); + await this.queue.load(); + this.projection = new SharedCodexProjection(this.session, threadId, id => this.queue.committed(id)); + this.session.updateMetadata(metadata => ({ ...metadata, codexSessionId: threadId, capabilities: { + ...metadata.capabilities, concurrentClients: true, terminal: true, + // In-place rewind needs a native + hub commit barrier. Do not + // advertise a destructive operation we cannot make atomic yet. + conversationHistory: { forkCurrent: true, forkAtMessage: true, rewindToMessage: false } + } })); + this.session.updateAgentState(state => ({ ...state, controlledByUser: false, startingMode: undefined, requests: {}, + completedRequests: { ...state.completedRequests, ...Object.fromEntries(Object.entries(state.requests ?? {}).map(([id, request]) => + [id, { ...request, completedAt: Date.now(), status: 'canceled' as const }])) } + })); + if (subscribe) response = record(await this.client.request('thread/resume', { threadId })); + this.acceptSettings(response); this.acceptSettings(this.host.settingsFor(threadId) ?? {}); this.reserve.attach(threadId, response); + await this.projection.history(response.thread); await this.refresh(); await this.refreshChildren(true); + } + async activate(options: SharedLaunchOptions = {}): Promise { + // Restored input cannot run before the cold-resume settings are applied. + await this.initialSettings(options); + if (this.stopping) throw new Error('Codex execution is stopping'); + this.registerControls(); this.ready(); + this.heartbeat = setInterval(() => this.alive(), 2_000); this.alive(); this.session.emitSessionReady(); + void this.reserve.initialize().catch(error => logger.debug('[Codex shared] usage initialization', error)); + this.usageTimer = setInterval(() => { void this.refreshUsage(); }, 60_000); + } + private publishSteering(): void { + const active = Boolean(this.currentTurn) && !this.stopping && !this.closed && !this.reconnecting && this.client.isInitialized(); + if (this.steeringActive === active) return; + this.steeringActive = active; + this.session.updateAgentState(state => ({ ...state, steeringActive: active })); + } + private alive(): void { + this.publishSteering(); + if (!this.closed) this.session.keepAlive(Boolean(this.currentTurn), undefined, this.settings); + } + acceptSettings(value: Record): void { + if (typeof value.model !== 'string') return; + this.settingsRevision++; + if ('collaborationMode' in value && 'sandboxPolicy' in value) this.settingsNotification = value; + this.settingsNative = { ...this.settingsNative, ...value }; + const sandbox = record(value.sandboxPolicy ?? value.sandbox).type; + this.settings = { ...this.settings, model: value.model, + modelReasoningEffort: string(value.effort ?? value.reasoningEffort) ?? null, + ...(sandbox ? { permissionMode: sandbox === 'dangerFullAccess' ? 'yolo' : sandbox === 'readOnly' ? 'read-only' : 'default' } : {}), + ...('serviceTier' in value ? { serviceTier: value.serviceTier === 'priority' ? 'fast' : 'standard' } : {}), + ...('collaborationMode' in value ? { collaborationMode: record(value.collaborationMode).mode === 'plan' ? 'plan' as const : 'default' as const } : {}) + }; + this.reserve.onSettings(this.threadId, value); + for (const listener of this.settingsListeners) listener(); + } + private async notification(method: string, params: unknown, modelAtReceipt?: string): Promise { + if (!this.threadId || this.closed) return; + const p = record(params); + if (method === 'thread/started') { + const thread = record(p.thread); const id = string(thread.id); + if (id) this.ancestry.set(id, string(thread.parentThreadId) ?? null); + } + const eventThread = string(p.threadId) ?? string(record(p.thread).id); + if (eventThread && eventThread !== this.threadId) { + if (!await this.ownsThread(eventThread)) return; + let projection = this.children.get(eventThread); + if (!projection) { + projection = new SharedCodexProjection(this.session, eventThread, async () => {}, this.threadId); + this.children.set(eventThread, projection); + } + await projection.notification(method, params); return; + } + if (method === 'turn/completed') this.session.sendSessionEvent({ type: 'ready' }); + if (method === 'thread/name/updated' && (typeof p.threadName === 'string' || p.threadName === null)) { + const name = p.threadName ?? undefined; this.session.updateMetadata(metadata => ({ ...metadata, name })); + } + if (method === 'thread/archived') { await this.host.end(this, false); return; } + if (method === 'thread/queue/changed') await this.queue.reconcile(); + await this.projection.notification(method, params, modelAtReceipt); this.alive(); + } + async readThread(threadId = this.threadId): Promise> { + let thread = record(record(await this.client.request('thread/read', { threadId, includeTurns: false })).thread); + if (thread.historyMode === 'paginated') { + const turns: unknown[] = []; let cursor: string | undefined; + do { + const page = record(await this.client.request('thread/turns/list', { threadId, cursor, sortDirection: 'asc', itemsView: 'full' })); + if (!Array.isArray(page.data)) throw new Error('Invalid Codex history'); + turns.push(...page.data); cursor = string(page.nextCursor); + } while (cursor); + thread.turns = turns; + } else thread = record(record(await this.client.request('thread/read', { threadId, includeTurns: true })).thread); + return thread; + } + refresh(): Promise { + return this.refreshing ??= this.refreshNow().finally(() => { this.refreshing = undefined; }); + } + private async refreshNow(): Promise { + if (!this.threadId || this.closed || !this.client.isInitialized()) return; + const revision = this.turnRevision; + const thread = await this.readThread(); + const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : []; + if (revision === this.turnRevision) { + this.currentTurn = string(turns.find(turn => turn.status === 'inProgress')?.id); + this.interrupted = turns.at(-1)?.status === 'interrupted'; + } + await this.projection.history(thread); await this.queue.reconcile(); this.alive(); + } + private async refreshChildren(subscribe: boolean): Promise { + let cursor: string | undefined; + do { + const page = record(await this.client.request('thread/list', { ancestorThreadId: this.threadId, cursor, + sourceKinds: ['subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther'] })); + for (const value of Array.isArray(page.data) ? page.data : []) { + const thread = record(value); const id = string(thread.id); + if (id && id !== this.threadId) this.ancestry.set(id, string(thread.parentThreadId) ?? null); + if (id && id !== this.threadId && await this.ownsThread(id) && !this.children.has(id)) { + this.children.set(id, new SharedCodexProjection(this.session, id, async () => {}, this.threadId)); + } + } + cursor = string(page.nextCursor); + } while (cursor); + const loaded = new Set(); cursor = undefined; + if (subscribe && this.children.size) do { + const page = record(await this.client.request('thread/loaded/list', { cursor })); + for (const id of Array.isArray(page.data) ? page.data : []) if (typeof id === 'string') loaded.add(id); + cursor = string(page.nextCursor); + } while (cursor); + for (const [id, projection] of this.children) { + // Replaying a completed/unloaded child must not start its engine. + try { + if (subscribe && loaded.has(id)) await this.client.request('thread/resume', { threadId: id }); + projection.reset(); await projection.history(await this.readThread(id)); + } catch (error) { logger.debug('[Codex shared] child history unavailable', { id, error }); } + } + } + private refreshUsage(): Promise { + return this.reserve.refresh({ permissionMode: this.settings.permissionMode === 'yolo' ? 'yolo' : 'default', + collaborationMode: this.settings.collaborationMode ?? 'default', model: this.settings.model ?? undefined }, + () => !this.closed && !this.currentTurn); + } + nativeQueueDeleted(nativeId: string): Promise { return this.queue.deleted(nativeId); } + replaySettings(): Array<{ method: string; params: unknown }> { + return this.settingsNotification ? [{ method: 'thread/settings/updated', params: { + threadId: this.threadId, threadSettings: this.settingsNotification + } }] : []; + } + private async reconnect(): Promise { + if (this.closed || this.stopping || this.reconnecting || !this.threadId) return; + this.reconnecting = true; this.publishSteering(); this.permissions.close(); + try { + while (!this.closed && !this.stopping) { + try { + await initializeSharedClient(this.client); + if (this.stopping) return; + this.permissions = new SharedCodexPermissions(this.session, this.client, `${this.host.generation}:${randomUUID()}`); + this.client.setServerRequestHandler(request => { void this.receiveRequest(request); }); + const settingsRevision = this.settingsRevision; + const response = record(await this.client.request('thread/resume', { threadId: this.threadId })); + const observedSettings = this.settingsRevision !== settingsRevision; + this.acceptSettings(response); + if (!observedSettings) this.acceptSettings(this.host.settingsFor(this.threadId) ?? {}); + this.projection.reset(); await this.refresh(); this.queue.replay(); await this.refreshChildren(true); + return; + } catch (error) { + this.permissions.close(); + logger.debug('[Codex shared] reconnect', error); await new Promise(resolve => setTimeout(resolve, 1_000)); + } + } + } finally { this.reconnecting = false; this.publishSteering(); } + } + async applySettings(raw: unknown): Promise<{ applied: RuntimeSettings }> { + const config = SettingsSchema.parse(raw); + if (config.permissionMode === 'safe-yolo') throw new Error('safe-yolo is not a native shared permission mode; choose default, read-only, or yolo'); + if (Object.keys(config).length === 0) return { applied: this.settings }; + if (typeof config.modelReasoningEffort === 'string') config.modelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort); + if (config.modelReasoningEffort === null) { + // Native public null means "leave effort alone", not reset. Resolve + // the selected model's real default instead of publishing a false reset. + let cursor: string | undefined; + do { + const page = await this.client.listModels({ cursor, includeHidden: true }); + const model = page.data?.find(model => (model.model ?? model.id) === (config.model ?? this.settingsNative.model)); + if (model?.defaultReasoningEffort) { config.modelReasoningEffort = model.defaultReasoningEffort; break; } + cursor = page.nextCursor ?? undefined; + } while (cursor); + if (config.modelReasoningEffort === null) throw new Error('Codex did not report a default reasoning effort; choose an explicit effort'); + } + const revision = this.settingsRevision; + const permission = config.permissionMode && resolveCodexPermissionModeConfig(config.permissionMode); + const params = { threadId: this.threadId, + ...(config.model ? { model: config.model } : {}), + ...(config.personality ? { personality: config.personality } : {}), + ...(config.modelReasoningEffort !== undefined ? { effort: config.modelReasoningEffort } : {}), + ...(config.serviceTier !== undefined ? { serviceTier: config.serviceTier === 'fast' ? 'priority' : null } : {}), + ...(permission ? { approvalPolicy: permission.approvalPolicy, sandboxPolicy: permission.sandboxPolicy } : {}), + ...(config.collaborationMode ? { collaborationMode: { mode: config.collaborationMode, settings: { + model: config.model ?? this.settings.model, reasoning_effort: config.modelReasoningEffort ?? this.settings.modelReasoningEffort, + developer_instructions: null + } } } : {}) + }; + let changed!: () => void; + let timer: ReturnType | undefined; + const accepted = new Promise((resolve, reject) => { + changed = () => { if (this.settingsRevision > revision && settingsMatch(this.settingsNative, params)) resolve(); }; + this.settingsListeners.add(changed); + timer = setTimeout(() => reject(new Error('Native settings update is not confirmed; reconnect before retrying')), 15_000); + }); + void accepted.catch(() => {}); + try { + await this.client.request('thread/settings/update', params); + // The RPC only acknowledges queueing the settings operation. The + // native notification, not our submitted candidate, establishes state. + await accepted; this.alive(); return { applied: this.settings }; + } finally { clearTimeout(timer); this.settingsListeners.delete(changed); } + } + async initialSettings(options: SharedLaunchOptions): Promise { + if (options.collaborationMode) await this.applySettings({ collaborationMode: options.collaborationMode }); + } + private registerControls(): void { + const rpc = { registerHandler: (method: string, handler: (raw: unknown) => Promise) => { + this.session.rpcHandlerManager.registerHandler(method, async (raw: unknown) => { + if (this.stopping) throw new Error('Codex execution is stopping; resume when inactive'); + const pending = handler(raw); this.controls.add(pending); + try { return await pending; } finally { this.controls.delete(pending); } + }); + } }; + rpc.registerHandler(RPC_METHODS.ListCodexModels, async raw => { + const models = []; let cursor: string | undefined; + do { + const response = await this.client.listModels({ includeHidden: record(raw).includeHidden === true, cursor }); + models.push(...(response.data ?? []).map(normalizeCodexModel).filter(model => model !== null)); cursor = response.nextCursor ?? undefined; + } while (cursor); + return { success: true, models }; + }); + rpc.registerHandler(RPC_METHODS.Switch, async () => { throw new Error('control_mode_not_applicable'); }); + rpc.registerHandler(RPC_METHODS.HandoffLocal, async () => { throw new Error('control_mode_not_applicable'); }); + rpc.registerHandler(RPC_METHODS.Abort, async () => { + const turnId = this.currentTurn; + if (turnId) await this.client.request('turn/interrupt', { threadId: this.threadId, turnId }); + }); + rpc.registerHandler(RPC_METHODS.KillSession, async () => { await this.host.end(this); return { success: true }; }); + rpc.registerHandler(RPC_METHODS.SetSessionConfig, raw => this.applySettings(raw)); + rpc.registerHandler(RPC_METHODS.SteerQueuedMessage, async raw => { + const { localId } = z.object({ localId: z.string().min(1) }).parse(raw); + const expectedTurnId = this.currentTurn; + if (!expectedTurnId) return { steered: false, error: 'No active turn' }; + return this.queue.steer(localId, expectedTurnId); + }); + rpc.registerHandler(RPC_METHODS.ClearConversation, async () => { + const child = await this.newConversation(); return { sessionId: child.session.sessionId }; + }); + rpc.registerHandler(RPC_METHODS.ForkConversation, async raw => { + const { messageLocalId } = z.object({ messageLocalId: z.string().optional() }).parse(raw); + if (this.currentTurn) throw new Error('Session is busy'); + const beforeTurnId = messageLocalId ? this.projection.turnFor(messageLocalId) : undefined; + if (messageLocalId && !beforeTurnId) throw new Error('No native history point for this message'); + if (messageLocalId) await this.assertBoundary(messageLocalId, beforeTurnId!); + const child = await this.host.create('thread/fork', { ...this.freshParams(), threadId: this.threadId, + ...(beforeTurnId ? { beforeTurnId } : {}) }, this); + await child.initialSettings({ collaborationMode: this.settings.collaborationMode }); + return { nativeSessionId: child.threadId, sessionId: child.session.sessionId }; + }); + rpc.registerHandler(RPC_METHODS.RewindConversation, async () => { + throw new Error('In-place rewind is not supported for concurrent Codex clients; fork at the message instead'); + }); + } + private async assertBoundary(localId: string, turnId: string): Promise> { + const thread = await this.readThread(); + const turn = (Array.isArray(thread.turns) ? thread.turns.map(record) : []).find(turn => turn.id === turnId); + const first = (Array.isArray(turn?.items) ? turn.items.map(record) : []).find(item => item.type === 'userMessage'); + const firstId = string(first?.clientId ?? first?.clientUserMessageId) ?? (first?.id ? `codex:${this.threadId}:user:${first.id}` : undefined); + if (firstId !== localId) throw new Error('Cannot cut inside a steered native turn. Select its first message.'); + return thread; + } + freshParams(): Record { + const sandbox = inheritedSandbox(this.settingsNative); + return { ...sandbox, cwd: this.settingsNative.cwd ?? this.bootstrap.workingDirectory, model: this.settingsNative.model, modelProvider: this.settingsNative.modelProvider, + approvalPolicy: this.settingsNative.approvalPolicy, serviceTier: this.settingsNative.serviceTier, + approvalsReviewer: this.settingsNative.approvalsReviewer, personality: this.settingsNative.personality, + developerInstructions: getCodexSystemPrompt(), + config: { ...record(sandbox.config), model_reasoning_effort: this.settings.modelReasoningEffort ?? undefined } }; + } + private notice(message: string): void { this.session.sendSessionEvent({ type: 'message', message }); } + private async newConversation(): Promise { + const child = await this.host.create('thread/start', this.freshParams()); + await child.initialSettings({ collaborationMode: this.settings.collaborationMode }); return child; + } + private async command(text: string): Promise { + const command = text.trim(); + if (command === '/clear' || command === '/new') { + const child = await this.newConversation(); this.notice(`New conversation: /sessions/${child.session.sessionId}`); return null; + } + if (command === '/compact') { await this.client.request('thread/compact/start', { threadId: this.threadId }); return null; } + if (command === '/exit') { this.notice('/exit closes a native terminal, not this Web view. Use End session to archive this conversation.'); return null; } + if (!command.startsWith('/')) return text; + const slash = resolveCodexSlashCommand(text, { commands: await listSlashCommands('codex', this.bootstrap.workingDirectory), + permissionMode: this.settings.permissionMode === 'yolo' ? 'yolo' : this.settings.permissionMode === 'read-only' ? 'read-only' : 'default', + collaborationMode: this.settings.collaborationMode ?? 'default', model: this.settings.model ?? undefined, + modelReasoningEffort: parseReasoningEffortValue(this.settings.modelReasoningEffort), serviceTier: this.settings.serviceTier }); + if (slash.kind === 'passthrough') return text; + if (slash.kind === 'goal') { + const params = { threadId: this.threadId }; + const response = slash.action === 'show' ? await this.client.request('thread/goal/get', params) + : slash.action === 'clear' ? await this.client.request('thread/goal/clear', params) + : await this.client.request('thread/goal/set', { ...params, ...(slash.action === 'set' ? { objective: slash.objective } : { status: slash.action === 'pause' ? 'paused' : 'active' }) }); + this.notice(JSON.stringify(response)); return null; + } + if (slash.updates?.proactiveMultiAgent !== undefined) throw new Error('This Codex version uses Ultra reasoning effort instead of a multi-agent toggle'); + if (slash.updates?.model === null) throw new Error('Choose an explicit model in a shared thread'); + if (slash.updates) await this.applySettings(slash.updates); + if (slash.message) this.notice(slash.message); + return slash.kind === 'replace' ? slash.text : null; + } + stopAccepting(): void { + this.stopping = true; this.ready(); clearInterval(this.usageTimer); + this.publishSteering(); + this.session.onReconnect(null); this.client.setTransportAbandonedHandler(null); this.reserve.dispose(); + } + async suspend(): Promise { + this.stopAccepting(); + await this.work.catch(() => {}); await Promise.allSettled([...this.controls]); + await this.queue?.suspend(); + // Capture any dequeue/turn that won the race with native deletion. + if (this.threadId && this.client.isInitialized()) await this.refresh(); + } + close(archived: boolean): Promise { + return this.closing ??= (async () => { + this.stopAccepting(); this.closed = true; clearInterval(this.heartbeat); + this.permissions?.close(); + await this.client.disconnect(); await this.notifications; await this.queue?.flush(); this.bridge?.server.stop(); + if (archived) this.session.updateMetadata(metadata => ({ ...metadata, lifecycleState: 'archived', lifecycleStateSince: Date.now() })); + // Inactive is resumable; uploads referenced by pending input must survive. + this.session.sendSessionDeath(undefined, { preserveUploads: !archived }); + await this.session.flush(); this.session.close(); + })(); + } +} diff --git a/cli/src/codex/shared/runtime.integration.test.ts b/cli/src/codex/shared/runtime.integration.test.ts new file mode 100644 index 00000000..1b2a9583 --- /dev/null +++ b/cli/src/codex/shared/runtime.integration.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { AgentState, Metadata } from '@/api/types'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { CodexAppServerClient } from '../codexAppServerClient'; +import { initializeSharedClient } from './launch'; +import { record } from './gateway'; +import { isProcessAlive } from '@/utils/process'; + +const state = vi.hoisted(() => ({ home: '', sessions: new Map(), beforeBootstrap: undefined as (() => Promise) | undefined })); +class MockSession { + readonly sessionId = randomUUID(); + state: AgentState = { requests: { 'old-worker': { tool: 'request_user_input', arguments: {}, createdAt: 0 } } }; metadata: Metadata; + messages: unknown[] = []; consumed: string[] = []; dead = false; + user?: (message: { content: { text: string } }, id?: string) => void; + rpc = new Map Promise>(); + rpcHandlerManager = { registerHandler: (name: string, fn: (params: unknown) => Promise) => { this.rpc.set(name, fn); } }; + constructor(cwd: string) { this.metadata = { path: cwd, host: 'test', hostPid: process.pid, machineId: 'test', flavor: 'codex', capabilities: { concurrentClients: true } }; } + getMetadata() { return this.metadata; } + updateMetadata(fn: (m: Metadata) => Metadata) { this.metadata = fn(this.metadata); } + updateAgentState(fn: (s: AgentState) => AgentState) { this.state = fn(this.state); } + onUserMessage(fn: MockSession['user']) { this.user = fn; } + onCancelQueuedMessage() {} onRetryQueuedMessage() {} onReconnect() {} + sendUserMessage(text: string) { this.messages.push({ user: text }); } + sendAgentMessage(body: unknown) { this.messages.push(body); } + sendSessionEvent(body: unknown) { this.messages.push(body); } + emitMessagesConsumed(ids: string[]) { this.consumed.push(...ids); } + emitSteerIndeterminate() {} keepAlive() {} emitSessionReady() {} + async setSteerDeliveryState() { return true; } + syncNativeQueuedMessage() {} + sendSessionDeath() { this.dead = true; } async flush() {} close() {} isPending() { return false; } +} +vi.mock('@/configuration', () => ({ configuration: { get happyHomeDir() { return state.home; }, apiUrl: 'http://mock-hub', cliApiToken: 'test' } })); +vi.mock('@/ui/logger', () => ({ logger: { debug: (...args: unknown[]) => { if (process.env.HAPI_DEBUG_SHARED_TEST) console.log(...args); }, warn: vi.fn(), info: vi.fn(), debugLargeJson: vi.fn() } })); +vi.mock('@/agent/sessionFactory', () => ({ bootstrapSession: async (options: { workingDirectory: string; metadataOverrides: Partial }) => { + await state.beforeBootstrap?.(); + const session = new MockSession(options.workingDirectory); session.metadata = { ...session.metadata, ...options.metadataOverrides }; + state.sessions.set(session.sessionId, session); + return { session: session as unknown as ApiSessionClient, sessionInfo: { id: session.sessionId, namespace: 'test' }, metadata: session.metadata, + workingDirectory: options.workingDirectory, machineId: 'test', startedBy: 'terminal', api: {} }; +}, bootstrapExistingSession: vi.fn() })); +vi.mock('@/runner/controlClient', () => ({ notifyRunnerSessionStarted: vi.fn(async () => ({})) })); +vi.mock('../utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async () => ({ server: { url: 'http://unused', stop() {} }, mcpServers: {} }) })); + +// Explicit opt-in: installed official binary, entirely isolated CODEX_HOME, mock Responses API only. +describe.skipIf(process.env.HAPI_RUN_SHARED_CODEX_TESTS !== '1')('installed Codex shared runtime', () => { + afterEach(() => { vi.unstubAllEnvs(); state.sessions.clear(); state.beforeBootstrap = undefined; }); + it('rejects a child ID before creating a HAPI binding or calling native resume', async () => { + const home = await mkdtemp('/tmp/hapi-shared-child-'); state.home = home; + const ch = join(home, 'codex'); await mkdir(ch); + await writeFile(join(ch, 'config.toml'), 'model = "mock-model"\nmodel_provider = "mock"\n[model_providers.mock]\nname = "No model calls"\nbase_url = "http://127.0.0.1:1/v1"\nwire_api = "responses"\nrequires_openai_auth = false\n[analytics]\nenabled = false\n[feedback]\nenabled = false\n'); + vi.stubEnv('CODEX_HOME', ch); vi.stubEnv('HOME', home); + // Only the read-only ancestry reply is stubbed; startup/shutdown use + // the installed native engine. No child is actually resumed. + const originalRequest = CodexAppServerClient.prototype.request; + const requests = vi.spyOn(CodexAppServerClient.prototype, 'request').mockImplementation(function(this: CodexAppServerClient, method: string, params?: unknown): Promise { + if (method === 'thread/read' && record(params).threadId === 'child') { + return Promise.resolve({ thread: { id: 'child', parentThreadId: 'parent' } } as T); + } + return originalRequest.call(this, method, params) as Promise; + }); + const ready = vi.fn(); + try { + const { runSharedRuntime } = await import('./runtime'); + await expect(runSharedRuntime({ workingDirectory: home, resumeSessionId: 'child' }, ready)) + .rejects.toThrow('Cannot cold-resume a child agent independently'); + expect(ready).not.toHaveBeenCalled(); expect(state.sessions.size).toBe(0); + expect(requests.mock.calls.some(([method]) => method === 'thread/resume')).toBe(false); + } finally { requests.mockRestore(); await rm(home, { recursive: true, force: true }); } + }, 30_000); + it.each(['abort', 'failure'])('cleans up the native engine and partial roots on startup %s', async outcome => { + const home = await mkdtemp('/tmp/hapi-shared-startup-'); state.home = home; + const ch = join(home, 'codex'); await mkdir(ch); + await writeFile(join(ch, 'config.toml'), 'model = "mock-model"\nmodel_provider = "mock"\n[model_providers.mock]\nname = "No model calls"\nbase_url = "http://127.0.0.1:1/v1"\nwire_api = "responses"\nrequires_openai_auth = false\n[analytics]\nenabled = false\n[feedback]\nenabled = false\n'); + vi.stubEnv('CODEX_HOME', ch); vi.stubEnv('HOME', home); + let entered!: () => void; let release!: () => void; + const reached = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + state.beforeBootstrap = async () => { entered(); await gate; if (outcome === 'failure') throw new Error('bootstrap failure'); }; + const abort = new AbortController(); const ready = vi.fn(); + const { runSharedRuntime } = await import('./runtime'); + const running = runSharedRuntime({ workingDirectory: home }, ready, abort.signal); + const result = outcome === 'failure' ? expect(running).rejects.toThrow('bootstrap failure') : expect(running).resolves.toBeUndefined(); + try { + await Promise.race([reached, running]); + if (outcome === 'abort') abort.abort(); + release(); await result; + expect(ready).not.toHaveBeenCalled(); + const { readRuntimes } = await import('./registry'); + const records = await readRuntimes(); expect(records).toHaveLength(1); + expect(isProcessAlive(records[0].serverPid!)).toBe(false); + for (const session of state.sessions.values()) { + expect(session.dead).toBe(true); expect(session.metadata.lifecycleState).not.toBe('archived'); + } + } finally { release(); abort.abort(); await running.catch(() => {}); await rm(home, { recursive: true, force: true }); } + }, 30_000); + it('binds empty roots, exchanges messages, isolates /new, and archives only the selected root', async () => { + const home = await mkdtemp('/tmp/hapi-shared-test-'); state.home = home; + const ch = join(home, 'codex'); const cwd = join(home, 'work'); await mkdir(ch); await mkdir(cwd); + const modelRequests: unknown[] = []; + const http = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', chunk => chunks.push(Buffer.from(chunk))); + request.on('end', () => { + if (!request.url?.endsWith('/responses')) { response.writeHead(404).end(); return; } + modelRequests.push(JSON.parse(Buffer.concat(chunks).toString())); + const id = randomUUID(); + const events = [{ type: 'response.created', response: { id } }, + { type: 'response.output_item.done', item: { type: 'message', role: 'assistant', id: `msg_${id}`, content: [{ type: 'output_text', text: 'MOCK ANSWER' }] } }, + { type: 'response.completed', response: { id, usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 } } }]; + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.end(events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join('')); + }); + }); + await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); + const port = (http.address() as { port: number }).port; + await writeFile(join(ch, 'config.toml'), `model = "mock-model"\nmodel_provider = "mock_provider"\napproval_policy = "never"\nsandbox_mode = "read-only"\ncheck_for_update_on_startup = false\n[model_providers.mock_provider]\nname = "Isolated mock"\nbase_url = "http://127.0.0.1:${port}/v1"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n[analytics]\nenabled = false\n[feedback]\nenabled = false\n[projects."${cwd}"]\ntrust_level = "trusted"\n`); + vi.stubEnv('CODEX_HOME', ch); vi.stubEnv('HOME', home); vi.stubEnv('HAPI_SESSION_ID', 'parent-must-not-leak'); + const { runSharedRuntime } = await import('./runtime'); + let ready!: (value: import('./runtime').RuntimeReady) => void; + const readiness = new Promise(resolve => { ready = resolve; }); + const abort = new AbortController(); + const running = runSharedRuntime({ workingDirectory: cwd }, ready, abort.signal); + const clients: CodexAppServerClient[] = []; + let roots: string[] = []; + try { + const { runtime, sessionId } = await Promise.race([readiness, running.then(() => { throw new Error('Runtime stopped before ready'); })]); + const connect = async () => { const client = new CodexAppServerClient({ endpoint: runtime.endpoint, token: runtime.token }); + client.setServerRequestHandler(() => {}); clients.push(client); await initializeSharedClient(client); return client; }; + const first = await connect(); const second = await connect(); + const initial = runtime.sessions[sessionId].threadId; roots.push(initial); + const resumed = record(await first.request('thread/resume', { threadId: initial })); + await second.request('thread/resume', { threadId: initial }); + expect(record(resumed.thread).turns).toEqual([]); expect(modelRequests).toHaveLength(0); + expect(state.sessions.size).toBe(1); + const web = state.sessions.get(sessionId)!; + expect(web.state.requests).toEqual({}); + expect(web.state.completedRequests?.['old-worker']?.status).toBe('canceled'); + web.user?.({ content: { text: 'HELLO FROM WEB' } }, 'web-1'); + await vi.waitFor(() => expect(web.consumed).toContain('web-1'), { timeout: 15_000 }); + await vi.waitFor(() => expect(web.messages).toContainEqual(expect.objectContaining({ type: 'message', message: 'MOCK ANSWER' })), { timeout: 15_000 }); + const newResponse = record(await first.request('thread/start', { cwd })); + const next = String(record(newResponse.thread).id); roots.push(next); + expect(next).not.toBe(initial); expect(state.sessions.size).toBe(2); + const nextSession = [...state.sessions.values()].find(session => session.metadata.codexSessionId === next)!; + expect(web.metadata.codexSessionId).toBe(initial); + expect(process.env.HAPI_SESSION_ID).toBe('parent-must-not-leak'); + await first.request('thread/archive', { threadId: next }); roots = [initial]; + await vi.waitFor(() => expect(nextSession.dead).toBe(true)); expect(web.dead).toBe(false); + await second.request('turn/start', { threadId: initial, model: 'second-mock', input: [{ type: 'text', text: 'HELLO FROM TERMINAL', text_elements: [] }], clientUserMessageId: 'native-1' }); + await vi.waitFor(() => expect(web.messages).toContainEqual({ user: 'HELLO FROM TERMINAL' }), { timeout: 15_000 }); + await vi.waitFor(() => { + const usageModels = web.messages.map(record).filter(body => body.type === 'token_count').map(body => body.model); + expect(usageModels).toContain('mock-model'); + expect(usageModels).toContain('second-mock'); + }, { timeout: 15_000 }); + await first.disconnect(); expect(web.dead).toBe(false); + await second.request('thread/archive', { threadId: initial }); roots = []; + await running; + } finally { + for (const threadId of roots) { + await clients.at(-1)?.request('thread/archive', { threadId }).catch(() => {}); + } + abort.abort(); await running.catch(() => {}); + await Promise.all(clients.map(client => client.disconnect())); + await new Promise(resolve => http.close(() => resolve())); + await rm(home, { recursive: true, force: true, maxRetries: 3 }); + } + }, 60_000); +}); diff --git a/cli/src/codex/shared/runtime.ts b/cli/src/codex/shared/runtime.ts new file mode 100644 index 00000000..53dfcf20 --- /dev/null +++ b/cli/src/codex/shared/runtime.ts @@ -0,0 +1,374 @@ +import { spawn, execFileSync, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { mkdir, mkdtemp, chmod, rm, realpath } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { configuration } from '@/configuration'; +import { bootstrapSession, bootstrapExistingSession } from '@/agent/sessionFactory'; +import { notifyRunnerSessionStarted } from '@/runner/controlClient'; +import { getProcessStartMarker, killProcessByChildProcess } from '@/utils/process'; +import { logger } from '@/ui/logger'; +import { CodexAppServerClient, isIndeterminateError } from '../codexAppServerClient'; +import { codexHome, saveRuntime, runtimeDirectory, runtimeAuthHash, findColdBinding, withThreadOwnership, type CodexRuntimeRecord } from './registry'; +import { startCodexGateway, record, string, type Envelope } from './gateway'; +import { resolveSharedCodex, sharedLaunchConfig, initializeSharedClient, checkSharedCapabilities, type SharedLaunchOptions } from './launch'; +import { SharedCodexRoot } from './root'; + +export type RuntimeReady = { sessionId: string; runtime: CodexRuntimeRecord }; +type Reservation = { root: SharedCodexRoot; resumeId?: string }; + +function gitInfo(cwd: string): { sha: string | null; branch: string | null; originUrl: string | null } { + const git = (...args: string[]) => { + try { return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } + catch { return null; } + }; + let originUrl = git('remote', 'get-url', 'origin'); + if (originUrl && /^https?:/.test(originUrl)) { + try { const url = new URL(originUrl); url.username = ''; url.password = ''; url.search = ''; url.hash = ''; originUrl = url.toString(); } + catch { originUrl = null; } + } + return { sha: git('rev-parse', 'HEAD'), branch: git('symbolic-ref', '--short', 'HEAD'), originUrl }; +} +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No private port'); + await new Promise(resolve => server.close(() => resolve())); return address.port; +} + +/** One ordinary CLI execution owns one private engine, with concurrent frontends. */ +export async function runSharedRuntime(options: SharedLaunchOptions, onReady?: (ready: RuntimeReady) => void, abortSignal?: AbortSignal): Promise { + abortSignal?.throwIfAborted(); + const command = resolveSharedCodex(); + const launch = sharedLaunchConfig(options, options.workingDirectory ?? process.cwd()); + const id = randomUUID(); + await mkdir(codexHome(), { recursive: true, mode: 0o700 }); + const home = await realpath(codexHome()); + await mkdir(runtimeDirectory(), { recursive: true, mode: 0o700 }); + // Unix sockaddr_un has a small path limit; don't nest sockets under CODEX_HOME. + const sockets = await mkdtemp(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'hapi-cx-')); await chmod(sockets, 0o700); + const token = randomBytes(32).toString('hex'); + const upstream = process.platform === 'win32' ? `ws://127.0.0.1:${await freePort()}` : `unix://${join(sockets, 'engine.sock')}`; + const upstreamToken = process.platform === 'win32' ? randomBytes(32).toString('hex') : undefined; + const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: home }; + // Every thread injects its own identity. A launch from another agent must not inherit that root's identity. + delete env.HAPI_SESSION_ID; + let server: ChildProcess | undefined; + let gateway: Awaited> | undefined; + let stopping = false; + let failure: unknown; + let shutdownPromise: Promise | undefined; + let startup: Promise = Promise.resolve(); + const operations = new Set>(); + const assertRunning = () => { if (stopping) throw new Error('Codex execution is stopping'); }; + const operation = async (work: () => Promise): Promise => { + assertRunning(); + const pending = work(); operations.add(pending); + try { return await pending; } finally { operations.delete(pending); } + }; + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + const ending = new Set(); + const roots = new Map(); + const prepared = new Set(); + const reservations = new Map(); + const runtime: CodexRuntimeRecord = { id, pid: process.pid, marker: getProcessStartMarker(process.pid) ?? '', + endpoint: '', command: command.command, args: command.args, codexHome: home, hub: configuration.apiUrl, authHash: runtimeAuthHash(), sessions: {} }; + if (!runtime.marker) throw new Error('Cannot verify the runtime process generation'); + let writes = Promise.resolve(); + const persist = () => { writes = writes.catch(() => {}).then(() => saveRuntime(runtime)); return writes; }; + const control = new CodexAppServerClient({ endpoint: upstream, token: upstreamToken, cwd: launch.cwd }); + control.setServerRequestHandler(() => {}); + // An independent observer preserves settings when a root's side-client + // reconnects: native resume responses omit collaboration mode. + const nativeSettings = new Map>(); + control.setNotificationHandler((method, params) => { + if (method !== 'thread/settings/updated') return; + const threadId = string(record(params).threadId); + if (!threadId) return; + const settings = record(record(params).threadSettings); + nativeSettings.set(threadId, settings); + // Never interleave unversioned streams while the root is healthy: + // an older control packet must not replace a newer root notification. + const root = roots.get(threadId); + if (root && !root.client.isInitialized()) root.acceptSettings(settings); + }); + let reconnectingControl = false; + const reconnectControl = () => { + if (stopping || reconnectingControl) return; + reconnectingControl = true; + void (async () => { + while (!stopping) { + try { + await initializeSharedClient(control); + // Roots remain loaded by their independent side-clients. + for (const threadId of roots.keys()) await control.request('thread/resume', { threadId }); + return; + } catch (error) { + logger.debug('[Codex shared] control reconnect', error); + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + } + })().finally(() => { reconnectingControl = false; }); + }; + + const shutdown = (error?: unknown): Promise => { + if (shutdownPromise) return shutdownPromise; + stopping = true; failure = error; + if (error) logger.debug('[Codex shared] runtime stopped', error); + control.setTransportAbandonedHandler(null); + for (const root of prepared) root.stopAccepting(); + shutdownPromise = (async () => { + // Close frontend admission first, then drain binding/startup work. + // No root may materialize after cleanup took its snapshot. + await gateway?.close().catch(() => {}); + await startup.catch(() => {}); + while (operations.size) await Promise.allSettled([...operations]); + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled([...prepared].filter(root => !ending.has(root)).map(root => root.suspend())), + new Promise(resolve => { timer = setTimeout(resolve, 5_000); }) + ]); + } finally { clearTimeout(timer); } + await control.disconnect(); + if (server && server.exitCode === null && server.signalCode === null) { + if (!await killProcessByChildProcess(server)) throw new Error('Codex engine did not stop; ownership retained for orphan recovery'); + } + // Release ownership only after the engine is dead, BEFORE inactive + // reaches the hub. Web may immediately resume through the Runner. + for (const binding of Object.values(runtime.sessions)) binding.active = false; + runtime.pendingCreations = []; + await persist(); + })().catch(error => { + failure ??= error; + logger.debug('[Codex shared] shutdown', error); + }).finally(async () => { + await Promise.allSettled([...prepared].map(root => root.close(ending.has(root)))); + await rm(sockets, { recursive: true, force: true }).catch(error => logger.debug('[Codex shared] socket cleanup', error)); + finish(); + }); + return shutdownPromise; + }; + const end = async (root: SharedCodexRoot, nativeArchive = true): Promise => { + if (!roots.has(root.threadId) || ending.has(root)) return; + ending.add(root); + try { if (nativeArchive) await root.client.request('thread/archive', { threadId: root.threadId }); } catch (error) { ending.delete(root); throw error; } + roots.delete(root.threadId); + runtime.sessions[root.session.sessionId].active = false; await persist(); + // Let the RPC acknowledgement leave both gateway and hub sockets before detach. + setTimeout(() => { + void root.close(true).then(async () => { + prepared.delete(root); + await notifyRunnerSessionStarted(root.session.sessionId, { ...root.bootstrap.metadata, lifecycleState: 'archived' }); + if (roots.size === 0 && reservations.size === 0) await shutdown(); + }).catch(error => logger.debug('[Codex shared] root cleanup', error)); + }, 100); + }; + const prepare = async (cwd: string, existingSessionId?: string, parent?: SharedCodexRoot): Promise => { + assertRunning(); + const shared = { flavor: 'codex', startedBy: options.startedBy ?? 'terminal', workingDirectory: cwd, + exportSessionEnv: false, reportStarted: false, metadataOverrides: { capabilities: { terminal: true, concurrentClients: true }, + ...(parent ? { forkedFrom: parent.session.sessionId } : {}) } } as const; + const bootstrap = existingSessionId + ? await bootstrapExistingSession({ ...shared, sessionId: existingSessionId }) + : await bootstrapSession({ ...shared, agentState: { controlledByUser: false } }); + const root = new SharedCodexRoot(bootstrap, { directory: join(runtimeDirectory(), 'queues'), generation: id, endpoint: upstream, token: upstreamToken, + settingsFor: threadId => nativeSettings.get(threadId), create, end }); + prepared.add(root); + try { assertRunning(); await root.prepare(); assertRunning(); return root; } + catch (error) { await root.close(!stopping && !existingSessionId); prepared.delete(root); throw error; } + }; + const reserveRecord = async (root: SharedCodexRoot, threadId: string) => { + assertRunning(); + if (roots.has(threadId) || Object.entries(runtime.sessions).some(([sid, binding]) => sid !== root.session.sessionId && binding.active && binding.threadId === threadId)) { + throw new Error('Thread is already being attached in this runtime; retry after it is ready'); + } + runtime.sessions[root.session.sessionId] = { threadId, namespace: root.bootstrap.sessionInfo.namespace, active: true }; + runtime.pendingCreations = runtime.pendingCreations?.filter(sid => sid !== root.session.sessionId); await persist(); + }; + const reserve = async (root: SharedCodexRoot, threadId: string) => withThreadOwnership(home, threadId, id, () => reserveRecord(root, threadId)); + const bind = async (root: SharedCodexRoot, response: Record, subscribe: boolean, initialOptions?: SharedLaunchOptions) => { + assertRunning(); + const threadId = string(record(response.thread).id); + if (!threadId) throw new Error('Codex lifecycle response has no thread ID'); + const reserved = runtime.sessions[root.session.sessionId]; + if (reserved && reserved.threadId !== threadId) throw new Error('Codex retargeted a reserved thread'); + if (!reserved) await reserve(root, threadId); + // No fake user turn/name. Metadata write materializes an empty legacy rollout for native resume. + await control.request('thread/metadata/update', { threadId, gitInfo: gitInfo(string(record(response.thread).cwd) ?? root.bootstrap.workingDirectory) }); + assertRunning(); + roots.set(threadId, root); + await root.bind(threadId, response, subscribe); + assertRunning(); + // Cold-resumed threads predate the control connection's automatic + // new-thread subscription. Subscribe once without changing settings. + await control.request('thread/resume', { threadId }); + await root.activate(initialOptions); + await root.session.flush(); + await persist(); + await notifyRunnerSessionStarted(root.session.sessionId, root.session.getMetadata() ?? root.bootstrap.metadata); + }; + const create = (method: 'thread/start' | 'thread/fork', params: Record, parent?: SharedCodexRoot, initialOptions?: SharedLaunchOptions): Promise => operation(async () => { + const root = await prepare(string(params.cwd) ?? launch.cwd, undefined, parent); + let nativeSucceeded = false; + try { + runtime.pendingCreations = [...runtime.pendingCreations ?? [], root.session.sessionId]; await persist(); + const response = record(await root.client.request(method, root.config({ ...params, ...(method === 'thread/fork' ? { deferGoalContinuation: true } : {}) }))); + nativeSucceeded = true; + await bind(root, response, true, initialOptions); return root; + } catch (error) { + // A timed-out native mutation may have succeeded. Never replay it or kill unrelated roots. + if (!nativeSucceeded && !isIndeterminateError(error)) { + runtime.pendingCreations = runtime.pendingCreations?.filter(sid => sid !== root.session.sessionId); await persist(); + prepared.delete(root); await root.close(true); + } + else root.session.sendSessionEvent({ type: 'message', message: 'Native thread creation/binding is unconfirmed. Do not retry blindly; inspect the runtime before recovery.' }); + throw error; + } + }); + const key = (connection: string, request: Envelope) => `${connection}:${typeof request.id}:${request.id}`; + const before = (request: Envelope, connection: string): Promise => operation(async () => { + const params = record(request.params); + if (['thread/revert', 'thread/rollback'].includes(request.method ?? '')) { + throw new Error('In-place rewind is unavailable with concurrent HAPI clients. Use /fork or Fork at message instead.'); + } + if (!['thread/start', 'thread/resume', 'thread/fork'].includes(request.method ?? '') || params.ephemeral === true) return request; + if (request.id === undefined) throw new Error('Lifecycle operations require a JSON-RPC request ID'); + if (params.history || params.path) throw new Error('Use a native thread ID to resume through HAPI'); + const threadId = string(params.threadId); + let existing = threadId ? roots.get(threadId) : undefined; + if (threadId && !existing) { + let candidate = threadId; const visited = new Set(); + for (;;) { + if (visited.has(candidate)) throw new Error('Invalid native thread ancestry'); visited.add(candidate); + await withThreadOwnership(home, candidate, id, async () => {}); + const thread = record(record(await control.request('thread/read', { threadId: candidate, includeTurns: false })).thread); + const parent = string(thread.parentThreadId); + if (!parent) break; + existing = roots.get(parent); + if (existing) break; + candidate = parent; + } + if (candidate !== threadId && !existing) throw new Error('Resume the parent HAPI session before attaching a child agent'); + } + if (request.method === 'thread/resume' && existing) return { ...request, params: existing.config(params) }; + if (threadId) await withThreadOwnership(home, threadId, id, async () => {}); + const cwd = string(params.cwd) ?? existing?.bootstrap.workingDirectory ?? launch.cwd; + const root = request.method === 'thread/resume' && threadId + ? await withThreadOwnership(home, threadId, id, async () => { + const root = await prepare(cwd, await findColdBinding(home, threadId)); + await reserveRecord(root, threadId); return root; + }) : await prepare(cwd, undefined, request.method === 'thread/fork' ? existing : undefined); + try { + if (request.method !== 'thread/resume') { + runtime.pendingCreations = [...runtime.pendingCreations ?? [], root.session.sessionId]; await persist(); + } + reservations.set(key(connection, request), { root, resumeId: request.method === 'thread/resume' ? threadId : undefined }); + return { ...request, params: root.config({ ...params, ...(request.method === 'thread/fork' ? { deferGoalContinuation: true } : {}) }) }; + } catch (error) { prepared.delete(root); await root.close(true); throw error; } + }); + const after = (request: Envelope, response: Envelope, connection: string): Promise => operation(async () => { + const reservation = reservations.get(key(connection, request)); reservations.delete(key(connection, request)); + if (reservation) { + if (response.error) { + const binding = runtime.sessions[reservation.root.session.sessionId]; if (binding) binding.active = false; + runtime.pendingCreations = runtime.pendingCreations?.filter(sid => sid !== reservation.root.session.sessionId); + await persist(); prepared.delete(reservation.root); await reservation.root.close(true); return; + } + await bind(reservation.root, record(response.result), true); + } else if (request.method === 'thread/archive' && !response.error) { + const root = roots.get(string(record(request.params).threadId) ?? ''); if (root) await end(root, false); + } else if (request.method === 'thread/queue/delete' && !response.error && record(response.result).deleted === true) { + const root = roots.get(string(record(request.params).threadId) ?? ''); + const nativeId = string(record(request.params).queuedSubmissionId); + if (root && nativeId) await root.nativeQueueDeleted(nativeId); + } + if (!response.error && ['thread/start', 'thread/resume', 'thread/fork'].includes(request.method ?? '')) { + const threadId = string(record(record(response.result).thread).id); + // Native 0.154 resume responses omit collaboration mode. Replay + // the authoritative settings notification after the response so + // a newly attached TUI doesn't reset a Web-selected Plan mode. + return roots.get(threadId ?? '')?.replaySettings(); + } + return undefined; + }); + const signal = () => { void shutdown(); }; + abortSignal?.addEventListener('abort', signal, { once: true }); + process.on('SIGTERM', signal); process.on('SIGINT', signal); process.on('SIGHUP', signal); + startup = (async () => { + if (abortSignal?.aborted) throw new Error('Codex startup canceled'); + assertRunning(); + server = spawn(command.command, [...command.args, 'app-server', ...launch.serverArgs, '--listen', upstream, + ...(upstreamToken ? ['--ws-auth', 'capability-token', '--ws-token-sha256', createHash('sha256').update(upstreamToken).digest('hex')] : [])], + { cwd: launch.cwd, env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true }); + server.stderr?.on('data', data => logger.debug('[Codex shared] app-server', data.toString())); + server.once('error', error => { void shutdown(error); }); + server.once('exit', (code, sig) => { void shutdown(new Error(`app-server exited (${code}, ${sig})`)); }); + runtime.serverPid = server.pid; + runtime.serverMarker = server.pid ? getProcessStartMarker(server.pid) ?? undefined : undefined; + if (!runtime.serverPid || !runtime.serverMarker) throw new Error('Cannot verify app-server generation'); + await persist(); + const deadline = Date.now() + 20_000; + for (;;) { + try { await initializeSharedClient(control); break; } + catch (error) { if (Date.now() > deadline || stopping) throw error; await new Promise(resolve => setTimeout(resolve, 100)); } + } + assertRunning(); + control.setTransportAbandonedHandler(reconnectControl); + await checkSharedCapabilities(control); + assertRunning(); + gateway = await startCodexGateway({ upstream, upstreamToken, path: process.platform === 'win32' ? undefined : join(sockets, 'clients.sock'), token, + hooks: { before, after, disconnected: connection => { + for (const [key, reservation] of reservations) { + if (!key.startsWith(`${connection}:`)) continue; + reservation.root.session.sendSessionEvent({ type: 'message', message: 'Native lifecycle outcome is unknown after transport loss. Do not retry blindly; inspect the shared runtime log.' }); + logger.debug('[Codex shared] quarantined lifecycle reservation', { runtimeId: id, sessionId: reservation.root.session.sessionId }); + } + }, control: (method, params) => operation(async () => { + const sid = string(record(params).sessionId); const root = [...roots.values()].find(root => root.session.sessionId === sid); + if (!root) throw new Error('Shared session not found'); + if (method === 'hapi/stopSession') { await end(root); return { stopped: true }; } + if (method === 'hapi/attach') return { threadId: root.threadId }; + throw new Error('Unknown runtime control'); + }) } }); + if (stopping) { await gateway.close(); assertRunning(); } + runtime.endpoint = gateway.endpoint; if (process.platform === 'win32') runtime.token = token; await persist(); + let root: SharedCodexRoot; + if (options.resumeLast) { + const response = record(await control.request('thread/list', { limit: 1, sortKey: 'updated_at', sortDirection: 'desc', + ...(!options.resumeAll ? { cwd: launch.cwd } : {}) })); + options.resumeSessionId = string(record(Array.isArray(response.data) ? response.data[0] : undefined).id); + if (!options.resumeSessionId) throw new Error('No Codex session to resume'); + } + if (options.resumeSessionId) { + const threadId = options.resumeSessionId; + const thread = record(record(await control.request('thread/read', { threadId, includeTurns: false })).thread); + if (string(thread.parentThreadId)) { + throw new Error('Cannot cold-resume a child agent independently. Resume its parent HAPI session instead.'); + } + // Prove ownership before bootstrap can alter an existing HAPI + // row's hostPid/metadata. Hold the lock until the reservation is durable. + root = await withThreadOwnership(home, threadId, id, async () => { + const existing = options.existingSessionId ?? await findColdBinding(home, threadId); + const root = await prepare(launch.cwd, existing); + await reserveRecord(root, threadId); return root; + }); + const response = record(await root.client.request('thread/resume', root.config({ ...launch.threadParams, threadId }))); + await bind(root, response, false, options); + } else root = await create('thread/start', { ...launch.threadParams, cwd: launch.cwd }, undefined, options); + assertRunning(); + onReady?.({ sessionId: root.session.sessionId, runtime }); + })(); + void startup.catch(error => { if (!stopping) void shutdown(error); }); + if (abortSignal?.aborted) void shutdown(); + try { + await done; + if (failure) throw failure; + } finally { + abortSignal?.removeEventListener('abort', signal); + process.off('SIGTERM', signal); process.off('SIGINT', signal); process.off('SIGHUP', signal); + } +} diff --git a/cli/src/codex/shared/settings.test.ts b/cli/src/codex/shared/settings.test.ts new file mode 100644 index 00000000..0b6a1aef --- /dev/null +++ b/cli/src/codex/shared/settings.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { inheritedSandbox, settingsMatch } from './settings'; + +describe('native shared settings', () => { + it('does not acknowledge another client changing an unrelated field', () => { + expect(settingsMatch({ model: 'a', effort: 'high' }, { threadId: 't', model: 'b' })).toBe(false); + expect(settingsMatch({ model: 'b', effort: 'high' }, { threadId: 't', model: 'b' })).toBe(true); + expect(settingsMatch({ serviceTier: 'priority' }, { serviceTier: null })).toBe(false); + }); + it('matches collaboration settings including model and effort, but accepts expanded built-in instructions', () => { + const expected = { collaborationMode: { mode: 'plan', settings: { model: 'b', reasoning_effort: 'high', developer_instructions: null } } }; + expect(settingsMatch({ collaborationMode: { mode: 'plan', settings: { model: 'a', reasoning_effort: 'high' } } }, expected)).toBe(false); + expect(settingsMatch({ collaborationMode: { mode: 'plan', settings: { model: 'b', reasoning_effort: 'high', developer_instructions: 'native instructions' } } }, expected)).toBe(true); + }); + it('preserves workspace sandbox restrictions and never widens an external or named profile', () => { + expect(inheritedSandbox({ sandboxPolicy: { type: 'workspaceWrite', writableRoots: ['/project'], networkAccess: false, + excludeTmpdirEnvVar: true, excludeSlashTmp: true } })).toEqual({ sandbox: 'workspace-write', config: { + 'sandbox_workspace_write.writable_roots': ['/project'], 'sandbox_workspace_write.network_access': false, + 'sandbox_workspace_write.exclude_tmpdir_env_var': true, 'sandbox_workspace_write.exclude_slash_tmp': true + } }); + expect(() => inheritedSandbox({ sandboxPolicy: { type: 'externalSandbox' } })).toThrow('Cannot inherit'); + expect(() => inheritedSandbox({ activePermissionProfile: { id: 'restricted' } })).toThrow('named permission profile'); + }); +}); diff --git a/cli/src/codex/shared/settings.ts b/cli/src/codex/shared/settings.ts new file mode 100644 index 00000000..4ac380d8 --- /dev/null +++ b/cli/src/codex/shared/settings.ts @@ -0,0 +1,37 @@ +import { isDeepStrictEqual } from 'node:util'; +import { record } from './gateway'; + +/** Native updates are asynchronous; an unrelated notification is not our ACK. */ +export function settingsMatch(actual: Record, expected: Record): boolean { + return Object.entries(expected).every(([key, value]) => { + if (key === 'threadId') return true; + // null developer instructions select the native built-in instructions; + // the effective snapshot is allowed to contain their expanded text. + if (key === 'developer_instructions' && value === null) return true; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return settingsMatch(record(actual[key]), record(value)); + } + return isDeepStrictEqual(actual[key], value); + }); +} + +/** Never silently convert a named/external sandbox into workspace-write. */ +export function inheritedSandbox(settings: Record): Record { + if (settings.activePermissionProfile) throw new Error('Create this conversation in the native TUI: named permission profile inheritance is not supported'); + const sandbox = record(settings.sandboxPolicy ?? settings.sandbox); + if (!sandbox.type) return {}; + if (sandbox.type === 'dangerFullAccess') return { sandbox: 'danger-full-access' }; + if (sandbox.type === 'readOnly') { + // Native readOnly can carry a restricted filesystem view, which the + // public thread/start sandbox enum cannot express. + if (sandbox.access && record(sandbox.access).type !== 'fullAccess') throw new Error('Cannot inherit a restricted read-only filesystem; create this conversation in the native TUI'); + return { sandbox: 'read-only' }; + } + if (sandbox.type !== 'workspaceWrite') throw new Error(`Cannot inherit sandbox ${String(sandbox.type)}; create this conversation in the native TUI`); + return { sandbox: 'workspace-write', config: { + ...(Array.isArray(sandbox.writableRoots) ? { 'sandbox_workspace_write.writable_roots': sandbox.writableRoots } : {}), + ...(typeof sandbox.networkAccess === 'boolean' ? { 'sandbox_workspace_write.network_access': sandbox.networkAccess } : {}), + ...(typeof sandbox.excludeTmpdirEnvVar === 'boolean' ? { 'sandbox_workspace_write.exclude_tmpdir_env_var': sandbox.excludeTmpdirEnvVar } : {}), + ...(typeof sandbox.excludeSlashTmp === 'boolean' ? { 'sandbox_workspace_write.exclude_slash_tmp': sandbox.excludeSlashTmp } : {}) + } }; +} diff --git a/cli/src/codex/utils/appServerPermissionAdapter.ts b/cli/src/codex/utils/appServerPermissionAdapter.ts index 08294000..3dfd877b 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.ts @@ -22,6 +22,7 @@ type ElicitationSchemaProperty = { }; type UserInputAnswer = Record | Record; +export const IGNORE_SHARED_REQUEST = Symbol('ignore-shared-request'); function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object') { @@ -376,8 +377,9 @@ function isHapiBridgeElicitation(params: unknown): boolean { } export function registerAppServerPermissionHandlers(args: { - client: CodexAppServerClient; - permissionHandler: CodexPermissionHandler; + client: Pick; + permissionHandler: Pick; + shared?: boolean; getPermissionMode?: () => CodexPermissionMode | undefined; onUserInputRequest?: (request: { id: string; input: unknown }) => Promise< | { decision: 'accept'; answers: UserInputAnswer } @@ -504,6 +506,7 @@ export function registerAppServerPermissionHandlers(args: { const input = buildElicitationUserInput(params); if (!onUserInputRequest || !input) { + if (args.shared) return IGNORE_SHARED_REQUEST; logger.debug('[CodexAppServer] Cancelling unsupported MCP elicitation request', { serverName: record.serverName, mode: request.mode, diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 85d1c47e..0d6ae485 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -45,6 +45,7 @@ export interface HapiMcpBridge { } export interface HapiMcpBridgeOptions { + exportSessionEnv?: boolean; emitTitleSummary?: boolean; enableChangeTitle?: boolean; skillLookup?: { @@ -76,7 +77,7 @@ export async function buildHapiMcpBridge( } } // Belt-and-suspenders: onMaterialized already exports; keep env set for non-lazy too. - exportHapiSessionEnv(client.sessionId); + if (options.exportSessionEnv !== false) exportHapiSessionEnv(client.sessionId); const happyServer = await startHappyServer(client, { emitTitleSummary: options.emitTitleSummary, diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index 4a3b2be8..7dca715b 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -26,8 +26,8 @@ vi.mock('@/ui/auth', () => ({ authAndSetupMachineIfNeeded: authAndSetupMachineIfNeededMock })) -vi.mock('@/codex/utils/codexVersion', () => ({ - assertCodexLocalSupported: assertCodexLocalSupportedMock +vi.mock('@/codex/shared/launch', () => ({ + resolveSharedCodex: assertCodexLocalSupportedMock })) vi.mock('@/codex/runCodex', () => ({ @@ -85,19 +85,20 @@ describe('codexCommand', () => { }) }) - it('passes native resume selectors through for Codex to resolve', async () => { + it('resolves --last in the runtime instead of appending a second resume command to the TUI', async () => { await codexCommand.run(createCommandContext(['resume', '--last', 'continue here'])) expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce() expect(runCodexMock).toHaveBeenCalledWith({ - codexArgs: ['resume', '--last', 'continue here'] + resumeLast: true, + codexArgs: ['continue here'] }) }) - it('skips the local version check for runner-started sessions', async () => { + it('checks shared protocol baseline for runner-started sessions', async () => { await codexCommand.run(createCommandContext(['--started-by', 'runner'])) - expect(assertCodexLocalSupportedMock).not.toHaveBeenCalled() + expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce() expect(runCodexMock).toHaveBeenCalledWith({ startedBy: 'runner' }) @@ -160,14 +161,14 @@ describe('codexCommand', () => { }) as never) assertCodexLocalSupportedMock.mockImplementationOnce(() => { - throw new Error('Codex CLI 0.124.0+ is required') + throw new Error('Codex CLI 0.154.0+ is required') }) try { await expect(codexCommand.run(createCommandContext([]))).rejects.toThrow('process.exit:1') expect(runCodexMock).not.toHaveBeenCalled() - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Codex CLI 0.124.0+ is required') + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Codex CLI 0.154.0+ is required') } finally { consoleErrorSpy.mockRestore() exitSpy.mockRestore() diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 1df35dcc..67cc03be 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -7,8 +7,8 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import { CodexCollaborationModeSchema } from '@hapi/protocol/schemas' import type { ReasoningEffort } from '@/codex/appServerTypes' -import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort' +import { resolveSharedCodex } from '@/codex/shared/launch' // Mirror the web /service-tier endpoint's enum so the internal resume spawn // path can never seed/persist an unsupported tier string. @@ -40,6 +40,8 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + resumeLast?: boolean + resumeAll?: boolean existingSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort @@ -54,14 +56,24 @@ export const codexCommand: CommandDefinition = { if (i === 0 && arg === 'resume') { const candidate = commandArgs[i + 1] if (!candidate || candidate.startsWith('-')) { - unknownArgs.push(arg) + if (!commandArgs.includes('--last')) throw new Error('Use hapi resume to choose a HAPI session, or hapi codex resume / --last') + options.resumeLast = true continue } options.resumeSessionId = candidate i += 1 continue } - if (arg === '--started-by') { + if (arg === '--last' && options.resumeLast) { + continue + } else if (arg === '--all' && options.resumeLast) { + options.resumeAll = true + } else if (arg === '--') { + unknownArgs.push(...commandArgs.slice(i)) + break + } else if (i === 0 && ['fork', 'exec', 'review', 'login', 'logout'].includes(arg)) { + throw new Error(`Use native codex ${arg} outside HAPI, or /${arg} in an attached terminal`) + } else if (arg === '--started-by') { options.startedBy = commandArgs[++i] as 'runner' | 'terminal' } else if (arg === '--existing-session-id') { const sessionId = commandArgs[++i] @@ -112,10 +124,7 @@ export const codexCommand: CommandDefinition = { options.codexArgs = unknownArgs } - if (options.startedBy !== 'runner') { - assertCodexLocalSupported() - } - + resolveSharedCodex() await initializeToken() if (options.startedBy === 'runner') { await maybeAutoStartServer() diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index 9f954ca3..43492a88 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -106,8 +106,8 @@ describe('resumeCommand', () => { await resumeCommand.run(createContext(['hapi-session-1'])) - expect(handoffSessionToLocalMock).toHaveBeenCalledWith('hapi-session-1') - expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce() + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(assertCodexLocalSupportedMock).not.toHaveBeenCalled() expect(runCodexMock).toHaveBeenCalledWith({ existingSessionId: 'hapi-session-1', workingDirectory: '/tmp/project', diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 0bfd79ac..4582d0f4 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -18,7 +18,6 @@ import type { ReasoningEffort } from '@/codex/appServerTypes' import { authAndSetupMachineIfNeeded } from '@/ui/auth' import { initializeToken } from '@/ui/tokenInit' import { maybeAutoStartServer } from '@/utils/autoStartServer' -import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' import { ResumeSessionPicker } from '@/ui/ink/ResumeSessionPicker' import type { CommandDefinition } from './types' @@ -90,7 +89,6 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { } if (target.flavor === 'codex') { - assertCodexLocalSupported() const { runCodex } = await import('@/codex/runCodex') await runCodex({ existingSessionId: base.existingSessionId, @@ -254,6 +252,11 @@ export const resumeCommand: CommandDefinition = { throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') } + if (target.flavor === 'codex') { + await dispatchLocalResume(target) + return + } + if (target.active && target.controlledByUser) { throw new Error('Session is already controlled by a local terminal') } diff --git a/cli/src/modules/common/codexModels.ts b/cli/src/modules/common/codexModels.ts index 2ee774fe..fd39a069 100644 --- a/cli/src/modules/common/codexModels.ts +++ b/cli/src/modules/common/codexModels.ts @@ -59,7 +59,7 @@ function normalizeServiceTiers(value: unknown): string[] | undefined { return tokens.size > 0 ? [...tokens] : undefined; } -function normalizeModel(entry: unknown): CodexModelSummary | null { +export function normalizeCodexModel(entry: unknown): CodexModelSummary | null { if (!entry || typeof entry !== 'object') { return null; } @@ -143,7 +143,7 @@ async function fetchCodexModelsFromAppServer(includeHidden: boolean): Promise model !== null) + ? response.data.map(normalizeCodexModel).filter((model): model is CodexModelSummary => model !== null) : []; } catch (error) { throw new Error(getErrorMessage(error, 'Failed to list Codex models')); diff --git a/cli/src/modules/common/handlers/uploads.ts b/cli/src/modules/common/handlers/uploads.ts index 17c3428d..ce85618f 100644 --- a/cli/src/modules/common/handlers/uploads.ts +++ b/cli/src/modules/common/handlers/uploads.ts @@ -26,6 +26,7 @@ const uploadDirs = new Map() const uploadFileIdentities = new Map>() const uploadDirPromises = new Map>() const uploadDirCleanupRequested = new Set() +const retainedUploadDirs = new Set() let cleanupRegistered = false const MAX_FILENAME_COMPONENT_BYTES = 255 @@ -123,6 +124,7 @@ async function getOrCreateUploadDir(sessionId?: string): Promise { export async function cleanupUploadDir(sessionId?: string): Promise { const sessionKey = getSessionKey(sessionId) + retainedUploadDirs.delete(sessionKey) uploadDirCleanupRequested.add(sessionKey) try { @@ -154,12 +156,19 @@ export async function cleanupUploadDir(sessionId?: string): Promise { } } +/** Pending native input still references these temporary files after suspension. + * Explicit cleanup still removes them; otherwise normal OS temp cleanup applies. */ +export function preserveUploadDirOnExit(sessionId: string): void { + retainedUploadDirs.add(getSessionKey(sessionId)) +} + function cleanupUploadDirsSync(): void { - const dirs = Array.from(uploadDirs.values()) + const dirs = Array.from(uploadDirs.entries()).filter(([key]) => !retainedUploadDirs.has(key)).map(([, dir]) => dir) uploadDirs.clear() uploadFileIdentities.clear() uploadDirPromises.clear() uploadDirCleanupRequested.clear() + retainedUploadDirs.clear() for (const dir of dirs) { try { diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index a09b291b..81c6a761 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -33,6 +33,7 @@ const defaultSettings: Settings = {} * This is written to disk by the runner to track its local process state */ export interface RunnerLocallyPersistedState { + sharedCodexRuntime?: boolean; pid: number; httpPort: number; startTime: string; diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 8cf7be59..4630d29c 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -125,8 +125,6 @@ describe('buildCliArgs', () => { 'codex', 'resume', 'codex-thread-1', - '--hapi-starting-mode', - 'remote', '--started-by', 'runner', '--existing-session-id', diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 41b04c82..a9b9055c 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -28,6 +28,7 @@ import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity'; +import { readRuntimes, runtimeMayBeAlive, runtimeAuthHash } from '@/codex/shared/registry'; import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm'; import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree'; import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability'; @@ -414,7 +415,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): }; // Helper functions - const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); + const getCurrentChildren = () => Array.from(pidToTrackedSession.values()).flatMap(session => session.sharedSessions + ? Object.entries(session.sharedSessions).map(([happySessionId, metadata]) => ({ ...session, happySessionId, happySessionMetadataFromLocalWebhook: metadata })) + : [session]); // Handle webhook from HAPI session reporting itself const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => { @@ -432,6 +435,18 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Check if we already have this PID (runner-spawned) const existingSession = pidToTrackedSession.get(pid); + if (existingSession && sessionMetadata.capabilities?.concurrentClients) { + existingSession.sharedSessions ??= {}; + if (sessionMetadata.lifecycleState === 'archived') { + delete existingSession.sharedSessions[sessionId]; + return; + } + existingSession.sharedSessions[sessionId] = sessionMetadata; + invalidateVerifiedExit(sessionId); + // Native /new or /fork cannot replace the primary spawn confirmation. + if (existingSession.happySessionId && existingSession.happySessionId !== sessionId) return; + } + if (existingSession && existingSession.startedBy === 'runner') { // Update runner-spawned session with reported data invalidateVerifiedExit(sessionId); @@ -466,6 +481,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // anything claiming `'runner'` here must be the second case and // should be ignored + terminated instead of silently promoted. if (sessionMetadata.startedBy === 'runner') { + // A shared root can report /new after a Runner restart. Unknown is + // not proof of an orphan: never kill its sibling roots. Known spawn + // timeouts already terminate their ChildProcess tree at the source. + // No registry scan/adoption lifecycle is needed for live attachment. + if (sessionMetadata.capabilities?.concurrentClients) return; logger.debug( `[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.` ); @@ -482,6 +502,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // New session started externally (terminal) const trackedSession: TrackedSession = { + ...(sessionMetadata.capabilities?.concurrentClients ? { sharedSessions: { [sessionId]: sessionMetadata } } : {}), startedBy: 'hapi directly - likely by user from terminal', happySessionId: sessionId, happySessionMetadataFromLocalWebhook: sessionMetadata, @@ -968,6 +989,22 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const stopSession = async (sessionId: string): Promise<'stopped' | 'already_gone' | 'still_alive'> => { logger.debug(`[RUNNER RUN] Attempting to stop session ${sessionId}`); + const { findRuntime } = await import('@/codex/shared/registry'); + const sharedRuntime = await findRuntime(sessionId); + if (sharedRuntime) { + try { + const { runtimeControl } = await import('@/codex/shared/frontend'); + await runtimeControl(sharedRuntime, 'hapi/stopSession', sessionId); + const tracked = pidToTrackedSession.get(sharedRuntime.pid); + if (tracked?.sharedSessions) delete tracked.sharedSessions[sessionId]; + return 'stopped'; + } catch { return 'still_alive'; } + } + if ((await readRuntimes()).some(runtime => runtime.hub === configuration.apiUrl && runtime.authHash === runtimeAuthHash() + && runtime.sessions[sessionId]?.active && runtimeMayBeAlive(runtime))) return 'still_alive'; + // Missing registry is not permission to kill siblings in a live execution. + if ([...pidToTrackedSession.values()].some(session => session.sharedSessions?.[sessionId])) return 'still_alive'; + // Try to find by sessionId first for (const [pid, session] of pidToTrackedSession.entries()) { if (session.happySessionId === sessionId || @@ -1081,6 +1118,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Handle child process exit const onChildExited = (pid: number) => { const session = pidToTrackedSession.get(pid); + for (const id of Object.keys(session?.sharedSessions ?? {})) rememberVerifiedExit(id); const requestedSessionId = session?.requestedHappySessionId ?? pidToRequestedSessionId.get(pid); if (requestedSessionId) rememberVerifiedExit(requestedSessionId); const confirmedSessionId = session?.happySessionId ?? pidToConfirmedSessionId.get(pid); @@ -1145,6 +1183,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Write initial runner state (no lock needed for state file) const fileState: RunnerLocallyPersistedState = { + sharedCodexRuntime: true, pid: process.pid, httpPort: controlPort, startTime: new Date().toLocaleString(), @@ -1567,7 +1606,9 @@ export function buildCliArgs( args.push('--fork-session'); } const startingMode = options.startingMode || 'remote'; - args.push('--hapi-starting-mode', startingMode, '--started-by', 'runner'); + // Codex shares one engine; Runner owns the wrapper, not a remote mode. + if (agent !== 'codex') args.push('--hapi-starting-mode', startingMode); + args.push('--started-by', 'runner'); // Codex, Cursor ACP, OpenCode, Pi native resume, and Claude message-level // forks reuse the original HAPI row via --existing-session-id. if (agent === 'codex' || agent === 'cursor' || agent === 'pi' diff --git a/cli/src/runner/types.ts b/cli/src/runner/types.ts index 5c8907ca..a9bae897 100644 --- a/cli/src/runner/types.ts +++ b/cli/src/runner/types.ts @@ -9,6 +9,8 @@ import { ChildProcess } from 'child_process'; * Session tracking for runner */ export interface TrackedSession { + /** All immutable HAPI roots in this shared execution; primary ID remains the spawn ACK. */ + sharedSessions?: Record; startedBy: 'runner' | string; happySessionId?: string; /** HAPI row requested for this process generation before its webhook arrives. */ diff --git a/docs/api/client-contract/rest.md b/docs/api/client-contract/rest.md index d2c60177..786cd0ff 100644 --- a/docs/api/client-contract/rest.md +++ b/docs/api/client-contract/rest.md @@ -47,6 +47,7 @@ Source: `hub/src/web/routes/sessions.ts`; request schemas in `shared/src/apiType | `PATCH /api/sessions/:id/summary` | `{text}` (1–255 chars) | `{ok: true}` | | `PUT /api/sessions/:id/pin` | `{mode: 'none'\|'project'\|'global'}` | `{ok: true}` | | `POST /api/sessions/:id/switch` | `{}` | `{ok: true}` — hands terminal-controlled session over to remote control | +| `POST /api/sessions/:id/clear` | `{}` | `{sessionId}` — shared sessions only; resume inactive sessions through the Runner first, then create a new root; navigate only the initiating view | | `POST /api/sessions/:id/title-suggestion` | — | `{title}`; errors pass through 422/429/502/503 | | `GET /api/sessions/:id/slash-commands` | — | `SlashCommandsResponse` `{success, commands?, error?}` | | `GET /api/sessions/:id/skills` | — | `SkillsResponse`-shaped `{success, ...}` | @@ -76,6 +77,11 @@ The hub stamps `sentFrom: 'webapp'` on REST-sent messages server-side; the reque Source: `hub/src/web/routes/permissions.ts`. Pending requests are **not messages**: they live in `session.agentState.requests` (keyed by request id) and move to `agentState.completedRequests` when resolved — schemas `AgentStateRequestSchema` / `AgentStateCompletedRequestSchema` in `shared/src/schemas.ts`. +Shared Codex completed requests may have `status: 'resolved'`: native completion +is known, but the winner/answer is not. Render a neutral status, not Approved or +an inferred selection. A successful approve HTTP response only submitted a +candidate. `canceled` can instead mean withdrawal after a transport disconnect. + | Method & path | Request | Response | |---|---|---| | `POST /api/sessions/:id/permissions/:requestId/approve` | `{mode?, allowTools?: string[], decision?, answers?}` | `{ok: true}` | @@ -99,6 +105,17 @@ Source: `hub/src/web/routes/sessions.ts`; flavor gates in `shared/src/modes.ts` | `POST /api/sessions/:id/collaboration-mode` | `{mode: 'default' \| 'plan'}` | codex (remote-only) | | `POST /api/sessions/:id/copilot-agent-mode` | `{mode}` | copilot (remote-only) | +`metadata.capabilities.concurrentClients === true`: ignore the legacy +`agentState.controlledByUser` input/settings gate. Shared keepalives have no +ownership mode. `/switch` returns HTTP 409 with +`code: 'control_mode_not_applicable'`; do not offer takeover. The remote-only +Codex configuration restrictions above do not apply to these sessions. + +Shared `/clear` has no global `supersededBySessionId` change; clients other than +the caller stay on the original thread. Fork may return an already-bound shared +child; the hub must not spawn a second engine. Use advertised history +capabilities: shared Codex currently supports fork, not in-place rewind. + All respond `{ok: true}`; apply-failures return 409 with a message. Model/effort **catalogs** (RPC-wrapped; all return `{success, ...} \| {success: false, error}`): | Method & path | Notes | diff --git a/docs/api/client-contract/sse.md b/docs/api/client-contract/sse.md index bbfe937f..3f6a7caa 100644 --- a/docs/api/client-contract/sse.md +++ b/docs/api/client-contract/sse.md @@ -147,7 +147,7 @@ Schema: `SyncEventSchema` in `shared/src/schemas.ts` (discriminated on `type`). | `machine-agy-models-updated` | `machineId` | The machine's `agy models` listing changed on a background re-check. Refetch `GET /api/machines/:id/agy-models` for that machine (answered from the machine's cache; it starts no `agy` run and produces no further event). Emitted when the re-check changes what that route would answer — a different listing, or a sign-in warning that appeared or cleared — never for the machine's first listing. | | `toast` | `data: {title, body, sessionId, url}` | Show as in-app toast/banner. Only delivered to visible connections (see [Visibility](#visibility)). | | `messages-consumed` | `sessionId`, `localIds: string[]`, `invokedAt: number` | The agent consumed queued user messages: stamp `invokedAt`, flip status to `sent`, remove from the queued bar. | -| `messages-indeterminate` | `sessionId`, `localIds: string[]` | A steer was dispatched but its outcome is unknown. Keep the row uninvoked, show an explicit Retry/Cancel resolution, and do not auto-replay it. | +| `messages-indeterminate` | `sessionId`, `localIds: string[]` | A native dispatch/queue mutation has an unknown outcome. Keep the row uninvoked, show an explicit Retry/Cancel resolution, and do not auto-replay it. Shared-engine Retry/Cancel can remain unavailable until reconciliation proves the outcome. | | `messages-requeued` | `sessionId`, `localIds: string[]` | An explicit Retry restored delivery to the normal queue. Clear the indeterminate marker. | | `message-cancelled` | `sessionId`, `messageId`, `localId?` | A queued message was cancelled: remove the row (match by `messageId` **or** `localId`). | | `heartbeat` | `data?: {timestamp}` | Feed the staleness watchdog. No other action. **Carries no `id`.** | diff --git a/docs/guide/codex-shared-sessions.md b/docs/guide/codex-shared-sessions.md new file mode 100644 index 00000000..588ad847 --- /dev/null +++ b/docs/guide/codex-shared-sessions.md @@ -0,0 +1,150 @@ +# Codex shared sessions + +Codex **0.154.0 or newer**: the official terminal UI and HAPI Web / native +clients share one app-server. No local/remote ownership switch. Both can send +messages, approve tools and answer `request_user_input`. + +```bash +hapi codex # launch an execution owned by this terminal +hapi resume # attach to a live execution on this machine +hapi codex resume # cold resume; rejects another live owner +hapi codex resume --last # latest native thread in the current directory +``` + +The execution uses the launch's environment, authentication and `CODEX_HOME`. +Independent launches get independent app-servers, not a global agent +service. Reattaching uses the **same executable** as the running app-server, even if PATH +has since changed. A compatible runner is required when one is already running. +Update CLI, Runner and Hub together: the Hub must preserve shared pending input +on execution exit rather than applying the legacy session-end queue sweep. + +## Lifecycle + +- **Terminal-created execution**: the original terminal owns its lifetime. + `/exit` or closing that terminal stops its app-server. All roots in that + execution become **inactive, not archived**; attached terminals disconnect. +- **Web-created/resumed execution**: the existing Runner spawns the ordinary + HAPI wrapper. No primary terminal is required. +- **Additional terminals**: `hapi resume ` attaches to the existing engine. + Exiting an attached terminal only detaches; it never takes lifetime ownership. +- **Web after an execution stops**: the existing resume-on-send flow asks an + online Runner to restore the same HAPI session/native thread, then delivers + the message. The Runner must have access to the same native store (`CODEX_HOME`) + and credentials. No new background Codex daemon or handoff path, and no attempt + to serialize/recreate an arbitrary terminal environment. +- **Stop**: interrupt the current turn; retain the thread and native queue. +- **End session**: archive that root and its descendants. Sibling roots keep working. +- `/new`, `/clear`: create a new root/HAPI session. Other clients remain on the old root. +- Native `/resume` and `/fork`: bind the selected/new root before exposing the + lifecycle reply. One HAPI identity is never retargeted to a different thread. +- Web/mobile `/new` and `/clear`: only the initiating view follows the returned + session ID. No global superseded-session redirect. +- Navigating to a thread owned by another live execution is rejected with a + `hapi resume ` instruction. No duplicate live engine or automatic roaming. + +The last archived root shuts down its app-server. Otherwise, lifetime follows +its original terminal or Runner-spawned wrapper, **not frontend count**. +`/new` and fork do not transfer ownership: when the original terminal exits, +its sibling roots also become inactive. Each can later resume independently. +Other executions are unaffected. Interrupted/in-flight work is not blindly +replayed; resume restores durable history, not the old process's memory. + +## Messages and questions + +Ordinary HAPI messages enter **Codex's native queue**. There is no competing +HAPI queue drainer. Settings are read when a submission executes, not frozen +when it is enqueued. Explicit **Steer** targets the currently observed turn; +a stale turn ID is rejected, not redirected to the next turn. + +Native pending queue text is mirrored to HAPI. Native edits and acknowledged +removals update it. Losing a queue entry during transport recovery is not proof +that it was canceled or executed. + +Codex arbitrates terminal/Web answers. A submitted Web answer is only a +candidate. Native `serverRequest/resolved` closes the prompt with neutral +**Resolved in Codex** status; HAPI does not invent the winner or their answer. +Disconnect withdraws the old controls; reconnect replays native pending requests. +Canceling a question interrupts its native turn instead of sending an empty +fabricated answer. Unknown side-client request types remain terminal-only. + +Each root has its own HAPI socket, MCP bridge and shell `HAPI_SESSION_ID`. +Child-agent traces/permissions are attributed by native ancestry, not merely by +being received on the same app-server connection. + +## Recovery and local data + +- Hub outage: existing native threads continue. Reconnect reconciles history, + queue and pending state with stable IDs. +- Runner restart: use existing Runner process tracking/recovery. Live attachment + uses private endpoint records directly; no extra scan/adoption loop. Unknown + shared-root webhooks are not grounds to kill a sibling execution. +- Ordinary execution exit: stop new submissions, remove native queued input + where Codex confirms it has not executed, and retain it for normal Hub replay + on resume (including native edits and pending upload files). Withdraw pending + permission/question controls. Stop the engine before publishing inactivity. +- Wrapper/app-server crash: no automatic replay of accepted or uncertain writes. + A durable per-session queue ledger records unknown outcomes before dispatch. +- Cold resume checks wrapper **and** app-server process generations. A live + orphan or an unverifiable generation blocks recovery. Inspect and explicitly + stop that orphan before resuming; never just delete its ownership record. +- Unconfirmed lifecycle operations are quarantined and logged; blindly repeating + `/new`, fork, or send can otherwise create duplicate work. +- Active legacy local/remote sessions are not hot-migrated. Stop them explicitly + before cold-resuming with the new CLI. + +Private files: `$HAPI_HOME/codex-runtimes` (live endpoint/ownership records, queue +ledgers), `$CODEX_HOME/hapi-runtime-owners` (store-wide ownership, including +across different HAPI homes). Unix sockets live in a short private temporary +directory. Windows uses bearer-authenticated loopback WebSockets. App-server +endpoints/tokens never go into HAPI session metadata or SSE. + +## Current boundaries + +- Cold resume accepts root threads only. To revisit a child agent, resume its + parent HAPI session; HAPI does not independently start a child execution. +- HAPI's legacy `safe-yolo` auto-approval policy is not a native Codex mode; + shared sessions reject it instead of silently weakening or misreporting it. + Use `default`, `read-only`, `yolo`, or native `--approve-for-me`. +- Web-created new/fork roots preserve the standard native sandbox restrictions. + Named permission profiles and external sandboxes must be created in the + native TUI; HAPI never converts them silently to workspace-write. +- `--profile` / `-p`: rejected. Standalone app-server 0.154 cannot select the + native profile-v2 loader; flattening it into `-c` changes configuration + precedence. Use a dedicated `CODEX_HOME` or explicit `-c` overrides. +- Native in-place rewind/rollback and Web Rewind are unavailable for concurrent + sessions: a native mutation plus hub transcript truncation needs a commit + barrier across every sender. Use **Fork at message** instead. Cuts inside a + steered turn are rejected; select its first user message. +- `--worktree` and unsupported launch flags fail explicitly; create/select the + working directory before launch. `--oss` requires an explicit + `--local-provider`. Bare resume pickers use `hapi resume` or the attached TUI. +- Unknown MCP elicitation forms are not answered by HAPI. A side-client error + response could consume the native callback and dismiss a valid TUI prompt. +- Native image-only prompts retain image placeholders in HAPI; they do not + upload the terminal's local image as a Web attachment preview. +- macOS real-binary/full-stack tests use isolated homes and a local mock + Responses endpoint; no Claude subscription or paid model calls required. + Windows transport authentication is unit-tested; native Windows execution + still needs platform validation. + +## Tests + +```bash +bun typecheck +bun run test +HAPI_RUN_SHARED_CODEX_TESTS=1 bun run --cwd cli test -- \ + src/codex/shared/runtime.integration.test.ts \ + src/codex/shared/frontend.integration.test.ts +``` + +With Node 26's global Web Storage enabled, the existing Web test setup can +conflict with jsdom's `StorageEvent` type. Run the suite with +`NODE_OPTIONS=--no-experimental-webstorage bun run test` in that environment. + +The opt-in tests require the installed official Codex binary. The full-stack +terminal test also uses Python 3's standard-library PTY support (POSIX only). +It exercises the actual HAPI CLI/Runner, hub REST/Socket.IO, MCP, app-server and native TUI, +not a fake Codex executable. Coverage includes keyboard/Web answers, sibling +question isolation, native queue editing/deletion, fork, primary-terminal exit, +secondary attachment/detachment, Web→Runner resume with the same HAPI/native +identity, and safe pending-message recovery. diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index c44c5ce4..c705baa5 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -1,5 +1,9 @@ # How it Works +**Codex 0.154.0+** uses a [shared app-server](./codex-shared-sessions.md): +terminal and Web/phone can act simultaneously, without switching ownership. +The local/remote handoff descriptions below apply to other agent integrations. + HAPI consists of three interconnected components that work together to provide remote AI agent control. ## Architecture Overview diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 29e01193..cec001db 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, mock } from 'bun:test' import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' import { Store, type StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' @@ -53,6 +53,42 @@ function reasoningTextOf(message: { content: unknown }): string { } describe('cli session handlers', () => { + it.each([undefined, 'terminated', 'error'] as const)('preserves shared Codex pending input on execution exit (%s)', reason => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('shared-end', { flavor: 'codex', capabilities: { concurrentClients: true } }, null, 'default') + store.messages.addMessage(session.id, { text: 'pending' }, 'pending') + const socket = new FakeSocket(); const sweep = mock(); const end = mock() + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, resolveSessionAccess: () => ({ ok: true, value: session }), emitAccessError() {}, + onSweepImmediateQueued: sweep, onSessionEnd: end + }) + socket.trigger('session-end', { sid: session.id, time: Date.now(), reason }) + expect(sweep).not.toHaveBeenCalled(); expect(end).toHaveBeenCalledTimes(1) + expect(store.messages.getAllMessages(session.id)[0]).toMatchObject({ localId: 'pending', invokedAt: null }) + store.close() + }) + it('mirrors native queued edits/removal without overwriting or deleting a consumed row', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('shared-queue', { capabilities: { concurrentClients: true } }, null, 'default') + const socket = new FakeSocket(); const events: SyncEvent[] = [] + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, resolveSessionAccess: () => ({ ok: true, value: session }), emitAccessError() {}, onWebappEvent: event => events.push(event) + }) + const sync = (localId: string, text: string | null) => socket.trigger('native-queue-message', { sid: session.id, localId, text }) + sync('queued', 'one'); sync('queued', 'edited') + expect(store.messages.getAllMessages(session.id)).toHaveLength(1) + expect(store.messages.getAllMessages(session.id)[0]).toMatchObject({ invokedAt: null, content: { + content: { text: 'edited' }, meta: { sentFrom: 'cli', isNativeQueuedMessage: true } + } }) + sync('queued', null); expect(events.at(-1)).toMatchObject({ type: 'message-cancelled', localId: 'queued' }) + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'original', attachments: [{ name: 'image.png' }] }, meta: { sentFrom: 'web' } }, 'attachment') + sync('attachment', 'edited') + expect(store.messages.getAllMessages(session.id)[0]).toMatchObject({ content: { content: { attachments: [{ name: 'image.png' }], text: 'edited' }, meta: { sentFrom: 'web' } } }) + sync('attachment', null) + sync('consumed', 'executed'); store.messages.markMessagesInvoked(session.id, ['consumed'], Date.now()) + sync('consumed', 'stale'); sync('consumed', null) + expect(store.messages.getAllMessages(session.id)[0]).toMatchObject({ content: { content: { text: 'executed' } } }) + }) it('preserves immediate queued rows for cleared handoff transfer', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('clear-end', {}, null, 'default') diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 3a7d29eb..b15e5f34 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -103,6 +103,25 @@ export type SessionHandlersDeps = { export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void { const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps + socket.on('native-queue-message', data => { + const parsed = z.object({ sid: z.string(), localId: z.string().min(1), text: z.string().nullable() }).safeParse(data) + if (!parsed.success) return + const { sid, localId, text } = parsed.data + const access = resolveSessionAccess(sid) + if (!access.ok) { emitAccessError('session', sid, access.reason); return } + const metadata = access.value.metadata as Metadata | null + if (!metadata?.capabilities?.concurrentClients) return + if (text === null) { + const prior = store.messages.lookupQueuedMessage(sid, localId) + if ('resolvedId' in prior && store.messages.deleteQueuedMessageById(sid, localId)) { + onWebappEvent?.({ type: 'message-cancelled', sessionId: sid, messageId: prior.resolvedId, localId }) + } + } else { + const message = store.messages.syncNativeQueuedMessage(sid, localId, text) + onWebappEvent?.({ type: 'message-received', sessionId: sid, message }) + } + }) + socket.on('message', (data: unknown) => { const parsed = messageSchema.safeParse(data) if (!parsed.success) { @@ -505,7 +524,11 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session // rows after the CLI exits — there is no longer an ack path, so they would // stay queued forever. The 5-second tick in syncEngine.expireInactive // emits scheduled rows when they mature, regardless of session end. - if (data.reason !== 'cleared') { + // Shared Codex execution exit is suspension, not consumption. Its native + // queue ledger proves which messages can be replayed on ordinary resume. + // Never stamp pending/uncertain input as executed, including on archive. + const sharedCodex = (sessionAccess.value.metadata as Metadata | null)?.capabilities?.concurrentClients + if (data.reason !== 'cleared' && !sharedCodex) { try { onSweepImmediateQueued?.(data.sid, Date.now()) } catch (err) { diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index c2b854c6..d56d5224 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -6,6 +6,7 @@ import { decodeMessageContent } from './contentCodec' import type { StoredMessage } from './types' import { addMessage, + syncNativeQueuedMessage, addImportedMessage, cancelQueuedMessage, deleteLiveReasoningSnapshots, @@ -58,6 +59,10 @@ export class MessageStore { return addMessage(this.db, sessionId, content, localId, scheduledAt, createdAt) } + syncNativeQueuedMessage(sessionId: string, localId: string, text: string): StoredMessage { + return syncNativeQueuedMessage(this.db, sessionId, localId, text) + } + deleteLiveReasoningSnapshots(sessionId: string, streamId: string, keepMessageId?: string): number { return deleteLiveReasoningSnapshots(this.db, sessionId, streamId, keepMessageId) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 5480897f..eb8ae62b 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -1,6 +1,7 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' +import { z } from 'zod' import { getLiveReasoningStreamId } from '@hapi/protocol/messages' @@ -177,6 +178,22 @@ export function addMessage( })() } +/** Shared engines own pending native text. Never overwrite an invoked row. */ +export function syncNativeQueuedMessage(db: Database, sessionId: string, localId: string, text: string): StoredMessage { + return db.transaction(() => { + const initial = { role: 'user', content: { type: 'text', text }, meta: { sentFrom: 'cli', isNativeQueuedMessage: true } } + const message = addMessage(db, sessionId, initial, localId) + if (message.invokedAt !== null) return message + // Native text edits must not erase Web attachments or origin metadata. + const prior = z.object({ role: z.literal('user'), content: z.object({ type: z.literal('text') }).passthrough() }).passthrough().safeParse(message.content) + const content = prior.success ? { ...prior.data, content: { ...prior.data.content, text } } : initial + const encoded = encodeMessageContent(truncateOversizedMessageContent(content)) + db.prepare('UPDATE messages SET content = ? WHERE session_id = ? AND local_id = ? AND invoked_at IS NULL') + .run(encoded, sessionId, localId) + return { ...message, content } + })() +} + export function copyMessageToSession( db: Database, sessionId: string, diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 90fcc542..7f8a68e0 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -12,6 +12,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { MessageService } from './messageService' +import type { EventPublisher } from './eventPublisher' import { Store } from '../store' import type { Server } from 'socket.io' import { SESSION_EXPORT_MESSAGE_LIMIT } from '@hapi/protocol/sessionExport' @@ -102,6 +103,24 @@ function makePublisher() { // --------------------------------------------------------------------------- describe('MessageService goal status filtering', () => { + it('does not discard or replay an uncertain shared input on a not-found cancellation ACK', async () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession('shared-unknown', { capabilities: { concurrentClients: true } }, null, 'default') + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'possibly executing' } }, 'shared-q') + store.messages.setMessagesDeliveryState(session.id, ['shared-q'], 'indeterminate') + const service = new MessageService(store, makeIo(ack => ack(null, [{ removed: false }])), makePublisher() as unknown as EventPublisher) + expect(await service.cancelQueuedMessage(session.id, 'shared-q')).toEqual({ status: 'busy', localId: 'shared-q' }) + expect(await service.retryIndeterminateMessage(session.id, 'shared-q')).toEqual({ status: 'retry-unavailable', localId: 'shared-q' }) + expect(store.messages.lookupQueuedMessage(session.id, 'shared-q').status).toBe('indeterminate') + }) + it('does not discard a shared native queue when the worker is disconnected from the hub', async () => { + const store = makeStore() + const session = store.sessions.getOrCreateSession('shared-offline', { capabilities: { concurrentClients: true } }, null, 'default') + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'may be executing' } }, 'shared-q') + const service = new MessageService(store, makeIo(() => { throw new Error('offline') }, 0), makePublisher() as unknown as EventPublisher) + expect(await service.cancelQueuedMessage(session.id, 'shared-q')).toEqual({ status: 'busy', localId: 'shared-q' }) + expect(store.messages.lookupQueuedMessage(session.id, 'shared-q').status).toBe('indeterminate') + }) function redundantGoalStatusContent(message: string): unknown { return { role: 'agent', diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index bc2008d6..cd0cfbda 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -5,7 +5,7 @@ import { type HapiSessionExport, type HapiSessionExportResult } from '@hapi/protocol/sessionExport' -import type { AttachmentMetadata, DecryptedMessage, Session } from '@hapi/protocol/types' +import type { AttachmentMetadata, DecryptedMessage, Metadata, Session } from '@hapi/protocol/types' import { isClaudeChatVisibleMessage, isRedundantGoalStatusEventContent, @@ -496,6 +496,17 @@ export class MessageService { // Phase 2: row is still queued. Ask the CLI whether it already shifted the item // (race window between collectBatch() shift and messages-consumed ack). const { localId, resolvedId, scheduledAt } = lookup + const shared = (this.store.sessions.getSession(sessionId)?.metadata as Metadata | null)?.capabilities?.concurrentClients === true + const connected = (this.io.of('/cli').adapter.rooms.get(`session:${sessionId}`)?.size ?? 0) > 0 + if (shared && localId && !connected && (scheduledAt === null || scheduledAt <= Date.now())) { + // A shared worker keeps running through hub outages. Offline does + // not mean its native queue is empty; never claim cancellation. + if (localId) { + this.store.messages.setMessagesDeliveryState(sessionId, [localId], 'indeterminate') + this.publisher.emit({ type: 'messages-indeterminate', sessionId, localIds: [localId] }) + } + return { status: 'busy', localId } + } const isDispatching = lookup.status === 'dispatching' const isIndeterminate = lookup.status === 'indeterminate' @@ -539,7 +550,8 @@ export class MessageService { if (ackResult === 'consumed') { return this.recordConsumedAcknowledgement(sessionId, localId) } - if (ackResult === 'in-flight' || ackResult === 'indeterminate' || (ackResult === 'timeout' && cliCount > 0)) { + if (ackResult === 'in-flight' || ackResult === 'indeterminate' || (ackResult === 'timeout' && cliCount > 0) + || (shared && ackResult !== 'removed')) { return { status: 'busy', localId } } this.store.messages.deleteQueuedMessageById(sessionId, resolvedId) @@ -698,6 +710,10 @@ export class MessageService { if (cancelResult === 'in-flight' || cancelResult === 'timeout') { return { status: 'retry-unavailable', localId: lookup.localId } } + if ((this.store.sessions.getSession(sessionId)?.metadata as Metadata | null)?.capabilities?.concurrentClients + && cancelResult !== 'removed') { + return { status: 'retry-unavailable', localId: lookup.localId } + } const refreshed = this.store.messages.lookupQueuedMessage(sessionId, messageId) if (refreshed.status === 'invoked') { return { status: 'invoked', message: toDecryptedMessage(refreshed.message) } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 4d4684cd..627a8ef9 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -487,6 +487,10 @@ export class RpcGateway { ) as import('@hapi/protocol/apiTypes').ForkConversationRpcResult } + async clearConversation(sessionId: string): Promise<{ sessionId: string }> { + return await this.sessionRpc(sessionId, RPC_METHODS.ClearConversation, {}, 120_000) as { sessionId: string } + } + async rewindConversation( sessionId: string, params: { messageLocalId: string } diff --git a/hub/src/sync/sharedCodex.test.ts b/hub/src/sync/sharedCodex.test.ts new file mode 100644 index 00000000..cf0233d1 --- /dev/null +++ b/hub/src/sync/sharedCodex.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test' +import type { Metadata } from '@hapi/protocol/types' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' +import type { RpcGateway } from './rpcGateway' + +function fixture() { + const store = new Store(':memory:') + const engine = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never) + const metadata: Metadata = { path: '/tmp/work', host: 'test', machineId: 'machine', hostPid: 42, flavor: 'codex', + capabilities: { concurrentClients: true, conversationHistory: { forkCurrent: true, forkAtMessage: true } } } + const create = (name: string, more: Partial = {}) => { + const session = engine.getOrCreateSession(name, { ...metadata, codexSessionId: `native-${name}`, ...more }, { controlledByUser: false }, 'default') + engine.handleSessionAlive({ sid: session.id, time: Date.now() }) + engine.handleSessionReady({ sid: session.id, time: Date.now() }) + return session + } + return { store, engine, create, rpc: (engine as unknown as { rpcGateway: RpcGateway }).rpcGateway } +} + +describe('shared Codex hub binding', () => { + it('uses the already-bound fork child without spawning a second engine', async () => { + const { engine, create, rpc } = fixture() + try { + const source = create('source'); const child = create('child', { forkedFrom: source.id }) + rpc.forkConversation = async () => ({ nativeSessionId: 'native-child', sessionId: child.id }) + expect(await engine.forkConversation(source.id, 'default')).toEqual({ type: 'success', sessionId: child.id }) + expect(engine.getSession(source.id)?.metadata?.codexSessionId).toBe('native-source') + } finally { engine.stop() } + }) + it('clear returns a new root without superseding other clients and mode switching is inapplicable', async () => { + const { engine, create, rpc } = fixture() + try { + const source = create('source'); const child = create('child') + rpc.clearConversation = async () => ({ sessionId: child.id }) + expect(await engine.clearConversation(source.id, 'default')).toEqual({ sessionId: child.id }) + expect(engine.getSession(source.id)?.metadata?.supersededBySessionId).toBeUndefined() + await expect(engine.switchSession(source.id, 'remote')).rejects.toThrow('control_mode_not_applicable') + await expect(engine.clearConversation(source.id, 'other-namespace')).rejects.toThrow() + } finally { engine.stop() } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 49879e8c..b3f4af20 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -110,7 +110,7 @@ export type LocalResumeTargetResult = export type LocalHandoffResult = | { type: 'success' } - | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' | 'control_mode_not_applicable' } export type ClearOpencodeSessionResult = | { type: 'success'; sessionId: string } @@ -1065,7 +1065,7 @@ export class SyncEngine { if (!isSteeringSupportedForSession(session.metadata)) { return { status: 'failed', error: 'Steering is only supported for Pi, Codex, and Cursor ACP sessions', localId: null } } - if (session.agentState?.controlledByUser === true) { + if (session.agentState?.controlledByUser === true && !session.metadata?.capabilities?.concurrentClients) { return { status: 'failed', error: 'Steering is only available for remote sessions', localId: null } } @@ -1146,7 +1146,7 @@ export class SyncEngine { if (!session.active) { throw new Error('Session must be active') } - if (session.agentState?.controlledByUser === true) { + if (session.agentState?.controlledByUser === true && !session.metadata?.capabilities?.concurrentClients) { throw new Error('Conversation history actions require a remote session') } if (session.thinking) { @@ -1410,6 +1410,11 @@ export class SyncEngine { if (!rpcResult?.nativeSessionId) { return { type: 'error', message: 'Native fork did not return a session id' } } + if (rpcResult.sessionId) { + const child = await this.validateSharedChild(source, rpcResult.sessionId, rpcResult.nativeSessionId) + if (!child || child.metadata?.forkedFrom !== sessionId) return { type: 'error', message: 'Invalid shared-runtime fork binding' } + return { type: 'success', sessionId: child.id } + } // Native fork RPC can race CLI metadata/transcript updates. Construct // the child only from a fresh source snapshot, never the pre-RPC row. @@ -1875,7 +1880,35 @@ export class SyncEngine { }) } + private async validateSharedChild(source: Session, id: string, nativeId?: string): Promise { + if (!source.metadata?.capabilities?.concurrentClients || id === source.id) return null + const deadline = Date.now() + 5_000 + do { + const child = this.sessionCache.refreshSession(id) + if (child && child.namespace === source.namespace + && child.metadata?.machineId === source.metadata.machineId + && child.metadata?.hostPid === source.metadata.hostPid + && child.metadata?.capabilities?.concurrentClients + && child.metadata.codexSessionId && (!nativeId || child.metadata.codexSessionId === nativeId)) return child + await new Promise(resolve => setTimeout(resolve, 50)) + } while (Date.now() < deadline) + return null + } + + async clearConversation(sessionId: string, namespace: string): Promise<{ sessionId: string }> { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok || !access.session.active || !access.session.metadata?.capabilities?.concurrentClients) { + throw new Error('Clear requires an active shared session') + } + const result = await this.rpcGateway.clearConversation(access.sessionId) + const child = await this.validateSharedChild(access.session, result.sessionId) + if (!child) throw new Error('Invalid shared-runtime clear binding') + // No superseded-session redirect: only the initiating client navigates. + return { sessionId: child.id } + } + async switchSession(sessionId: string, to: 'remote' | 'local'): Promise { + if (this.getSession(sessionId)?.metadata?.capabilities?.concurrentClients) throw new Error('control_mode_not_applicable') if (this.historyActionsInFlight.has(sessionId)) { throw new Error('Conversation history action already in progress') } @@ -3387,11 +3420,14 @@ export class SyncEngine { } } + if (access.session.metadata?.capabilities?.concurrentClients) { + return { type: 'error', message: 'Shared sessions attach without handoff', code: 'control_mode_not_applicable' } + } if (!access.session.active) { return { type: 'success' } } - if (access.session.agentState?.controlledByUser === true) { + if (access.session.agentState?.controlledByUser === true && !access.session.metadata?.capabilities?.concurrentClients) { return { type: 'error', message: 'Session is already controlled by a local terminal', diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 5e234586..d52e9969 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -182,7 +182,7 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono ({ type: 'success', sessionId: 'child-1' })), + clearConversation: opts?.clearConversation, rewindConversation: opts?.rewindConversation ?? (async () => ({ type: 'success' })), suggestSessionTitle: opts?.suggestSessionTitle ?? (async () => 'Generated title'), updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}) @@ -178,6 +180,41 @@ function createApp(session: Session, opts?: { } describe('sessions routes', () => { + it.each([true, false])('clears shared sessions after resuming only when inactive (active: %s)', async active => { + const calls: string[] = [] + const { app } = createApp(createSession({ active, permissionMode: 'read-only', metadata: { + path: '/tmp/project', host: 'localhost', flavor: 'codex', capabilities: { concurrentClients: true } + } }), { + resumeSession: async (id, namespace, opts) => { + calls.push('resume') + expect([id, namespace, opts]).toEqual(['session-1', 'default', { permissionMode: 'read-only' }]) + return { type: 'success', sessionId: 'resumed-session' } + }, + clearConversation: async (id, namespace) => { + calls.push('clear') + expect([id, namespace]).toEqual([active ? 'session-1' : 'resumed-session', 'default']) + return { sessionId: 'new-root' } + } + }) + const response = await app.request('/api/sessions/session-1/clear', { method: 'POST' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ sessionId: 'new-root' }) + expect(calls).toEqual(active ? ['clear'] : ['resume', 'clear']) + }) + + it('does not clear or retry when resume fails', async () => { + let clears = 0 + const { app } = createApp(createSession({ active: false, metadata: { + path: '/tmp/project', host: 'localhost', flavor: 'codex', capabilities: { concurrentClients: true } + } }), { + resumeSession: async () => ({ type: 'error', code: 'no_machine_online', message: 'No Runner online' }), + clearConversation: async () => { clears++; return { sessionId: 'unexpected' } } + }) + const response = await app.request('/api/sessions/session-1/clear', { method: 'POST' }) + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ error: 'No Runner online', code: 'no_machine_online' }) + expect(clears).toBe(0) + }) it('generates a title suggestion without changing session metadata', async () => { const suggest = async (sessionId: string) => { expect(sessionId).toBe('session-1') diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index d1dc8207..544e315d 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -509,6 +509,30 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json(outcome, status) }) + app.post('/sessions/:id/clear', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const session = requireSessionFromParam(c, engine) + if (session instanceof Response) return session + if (!session.session.metadata?.capabilities?.concurrentClients) return c.json({ error: 'Shared session required' }, 409) + let sessionId = session.sessionId + const namespace = c.get('namespace') + if (!session.session.active) { + // All clients intercept /clear and /new. Resume here so they share + // the normal Runner lifecycle instead of reimplementing it. + const result = await engine.resumeSession(sessionId, namespace, { permissionMode: session.session.permissionMode }) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : result.code === 'resume_unavailable' ? 409 : 500 + return c.json({ error: result.message, code: result.code }, status) + } + sessionId = result.sessionId + } + return c.json(await engine.clearConversation(sessionId, namespace)) + }) + app.post('/sessions/:id/switch', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { @@ -520,6 +544,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return sessionResult } + if (sessionResult.session.metadata?.capabilities?.concurrentClients) return c.json({ error: 'Shared sessions do not switch modes', code: 'control_mode_not_applicable' }, 409) await engine.switchSession(sessionResult.sessionId, 'remote') return c.json({ ok: true }) }) @@ -552,7 +577,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!isPermissionModeAllowedForFlavor(mode, flavor)) { return c.json({ error: 'Invalid permission mode for session flavor' }, 400) } - if (flavor === 'opencode' && mode === 'plan' && sessionResult.session.agentState?.controlledByUser === true) { + if (flavor === 'opencode' && mode === 'plan' && sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'OpenCode plan mode is only supported for remote sessions' }, 409) } @@ -580,7 +605,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (flavor !== 'codex') { return c.json({ error: 'Collaboration mode is only supported for Codex sessions' }, 400) } - if (sessionResult.session.agentState?.controlledByUser === true) { + if (sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'Collaboration mode can only be changed for remote Codex sessions' }, 409) } @@ -614,7 +639,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (flavor !== 'copilot') { return c.json({ error: 'Copilot agent mode is only supported for Copilot sessions' }, 400) } - if (sessionResult.session.agentState?.controlledByUser === true) { + if (sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'Copilot agent mode can only be changed for remote Copilot sessions' }, 409) } @@ -654,7 +679,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!supportsModelChange(flavor)) { return c.json({ error: 'Model selection is not supported for this session' }, 400) } - if (sessionResult.session.agentState?.controlledByUser === true) { + if (sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { if (flavor === 'codex') { return c.json({ error: 'Model selection can only be changed for remote Codex sessions' }, 409) } @@ -690,7 +715,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (flavor !== 'codex' && flavor !== 'opencode') { return c.json({ error: 'Model reasoning effort is only supported for Codex and OpenCode sessions' }, 400) } - if (sessionResult.session.agentState?.controlledByUser === true) { + if (sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'Model reasoning effort can only be changed for remote sessions' }, 409) } @@ -732,7 +757,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!supportsEffort(flavor)) { return c.json({ error: 'Effort selection is not supported for this session type' }, 400) } - if (flavor === 'grok' && sessionResult.session.agentState?.controlledByUser === true) { + if (flavor === 'grok' && sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'Effort can only be changed for remote Grok sessions' }, 409) } @@ -760,7 +785,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (flavor !== 'codex') { return c.json({ error: 'Fast mode is only supported for Codex sessions' }, 400) } - if (sessionResult.session.agentState?.controlledByUser === true) { + if (sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'Fast mode can only be changed for remote sessions' }, 409) } diff --git a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift index c23cec3e..31d0f733 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift @@ -194,6 +194,8 @@ struct PermissionStateRow: View { text: String(localized: "✕ Denied") + (permission.reason.map { " · \($0)" } ?? ""), isError: true ) + case .resolved: + PermissionLine(text: String(localized: "Resolved in Codex")) case .canceled: PermissionLine(text: String(localized: "— Canceled")) } diff --git a/ios/Hapi/Features/NewSession/NewSessionModel.swift b/ios/Hapi/Features/NewSession/NewSessionModel.swift index 3065f793..724f7865 100644 --- a/ios/Hapi/Features/NewSession/NewSessionModel.swift +++ b/ios/Hapi/Features/NewSession/NewSessionModel.swift @@ -331,7 +331,7 @@ final class NewSessionModel { return .managed } if NewSessionLogic.usesNativePermissionSelect(agent) { - return .nativeSelect(agent.permissionModes.map { PermissionModeOption(mode: $0) }) + return .nativeSelect(agent.launchPermissionModes.map { PermissionModeOption(mode: $0) }) } return .yoloToggle(nativeModeLabel: NewSessionLogic.hapiYoloNativeMode(for: agent)?.label) } diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift b/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift index 14f3f6a6..c85a0b9e 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift @@ -276,9 +276,11 @@ public func buildSessionConfigState( return SessionConfigState( flavor: flavor, active: detail?.active ?? summary?.active ?? false, - controlledByUser: detail?.agentState?.controlledByUser == true, + controlledByUser: detail?.agentState?.controlledByUser == true && detail?.metadata?.capabilities?.concurrentClients != true, permissionMode: detail?.permissionMode, - permissionModes: permissionModeOptions(forFlavor: flavor), + permissionModes: permissionModeOptions(forFlavor: flavor).filter { + detail?.metadata?.capabilities?.concurrentClients != true || $0.mode.rawValue != "safe-yolo" + }, model: model, modelOptions: modelOptions, modelOptionsLoading: modelOptionsLoading, diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift b/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift index c53708fa..b06717e6 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift @@ -202,6 +202,19 @@ public final class ChatInteractor { Task { [weak self] in guard let self else { return } self.drafts?.clear(sessionId: self.sessionId) + if attachmentMetadata == nil && (text == "/clear" || text == "/new") + && self.sessionStore.detail(for: self.sessionId)?.metadata?.capabilities?.concurrentClients == true { + defer { self.isSending = false } + do { + let result = try await self.api.clearConversation(id: self.sessionId) + self.sessionStore.scheduleRefresh() + self.emit(.sessionSuperseded(sessionId: result.sessionId)) + } catch { + self.setComposerText(text) + self.emit(.notice(Self.errorMessage(error, fallback: "Failed to create conversation"))) + } + return + } await self.performSend( text: text, localId: localId, diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift b/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift index 57f50644..40d8649c 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift @@ -63,6 +63,11 @@ extension APIClient { ) } + /// Create a new shared root; only the caller follows the returned ID. + public func clearConversation(id: String) async throws -> ResumeSessionResponse { + try await request(.post, "/api/sessions/\(encodePathComponent(id))/clear", body: EmptyRequestBody()) + } + /// `POST /api/sessions/:id/abort` (active sessions only). public func abortSession(id: String) async throws { try await requestVoid( diff --git a/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift b/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift index 7120ecce..7529aaf5 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift @@ -386,7 +386,7 @@ public enum NewSessionLogic { sessionType: draft.sessionType, worktreeName: draft.worktreeName ) - if !base.agent.permissionModes.contains(base.permissionMode) { + if !base.agent.launchPermissionModes.contains(base.permissionMode) { base.permissionMode = .default } return base diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift index 1a32837c..6c894235 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift @@ -82,6 +82,11 @@ public struct PermissionModeOption: Equatable, Sendable { } extension AgentFlavor { + /// New Codex sessions use the shared runtime, without HAPI Safe Yolo. + public var launchPermissionModes: [PermissionMode] { + permissionModes.filter { self != .codex || $0 != .safeYolo } + } + /// Permission modes offered for this flavor, in picker order. /// /// Ports the per-flavor arrays in `shared/src/modes.ts` diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift index 9361a880..d84c6409 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ChatTypes.swift @@ -253,6 +253,7 @@ public enum ToolPermissionStatus: String, Equatable, Sendable { case pending case approved case denied + case resolved case canceled } diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTools.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTools.swift index b42faa02..d81d89cc 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTools.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Chat/ReducerTools.swift @@ -71,6 +71,7 @@ func getPermissions(_ agentState: AgentState?) -> PermissionMap { let entry = completed[id]! let status: ToolPermissionStatus = { switch entry.status { + case .resolved: return .resolved case .canceled: return .canceled case .denied: return .denied case .approved: return .approved diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift index 1ac2ce74..d8d70eba 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/AgentState.swift @@ -29,6 +29,7 @@ public struct AgentStateRequest: Codable, Equatable, Sendable { /// Mirrors `AgentStateCompletedRequestSchema` (`shared/src/schemas.ts`). public struct AgentStateCompletedRequest: Codable, Equatable, Sendable { public enum Status: String, Codable, Sendable { + case resolved case canceled case denied case approved diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift index dd4cbf1f..f78f2689 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionMetadata.swift @@ -63,13 +63,16 @@ public struct ConversationHistoryCapabilities: Codable, Equatable, Sendable { /// Per-session capability flags advertised by the CLI. public struct SessionCapabilities: Codable, Equatable, Sendable { + public var concurrentClients: Bool? public var terminal: Bool? public var conversationHistory: ConversationHistoryCapabilities? public init( terminal: Bool? = nil, + concurrentClients: Bool? = nil, conversationHistory: ConversationHistoryCapabilities? = nil ) { + self.concurrentClients = concurrentClients self.terminal = terminal self.conversationHistory = conversationHistory } diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift index 9e232bfd..a57508eb 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift @@ -53,7 +53,7 @@ struct NewSessionSpawnBodyTests { agent: .codex, model: "gpt-5.2-codex", modelReasoningEffort: "high", - permissionMode: .safeYolo, + permissionMode: .readOnly, yolo: true, sessionType: .worktree, worktreeName: " feature-x ", @@ -65,7 +65,7 @@ struct NewSessionSpawnBodyTests { #expect(body == """ {"agent":"codex","collaborationMode":"plan","directory":"/repo",\ "model":"gpt-5.2-codex","modelReasoningEffort":"high",\ - "permissionMode":"safe-yolo","serviceTier":"fast",\ + "permissionMode":"read-only","serviceTier":"fast",\ "sessionType":"worktree","worktreeName":"feature-x"} """) } @@ -269,10 +269,12 @@ struct NewSessionLogicTests { ) #expect(badMode.permissionMode == .default) - let goodMode = NewSessionLogic.sanitizeDraft( + let staleMode = NewSessionLogic.sanitizeDraft( NewSessionForm(agent: .codex, permissionMode: .safeYolo) ) - #expect(goodMode.permissionMode == .safeYolo) + #expect(staleMode.permissionMode == .default) + #expect(AgentFlavor.codex.launchPermissionModes == [.default, .readOnly, .yolo]) + #expect(NewSessionLogic.sanitizeDraft(NewSessionForm(agent: .kimi, permissionMode: .safeYolo)).permissionMode == .safeYolo) } @Test func draftDecodingToleratesMissingKeysAndRoundTrips() throws { diff --git a/shared/fixtures/chat/permission-shared-canceled.json b/shared/fixtures/chat/permission-shared-canceled.json new file mode 100644 index 00000000..491d39be --- /dev/null +++ b/shared/fixtures/chat/permission-shared-canceled.json @@ -0,0 +1,220 @@ +{ + "description": "Disconnect withdraws a request-only shared question without claiming it was answered or losing its history.", + "expected": { + "blocks": [ + { + "createdAt": 1755000000000, + "id": "shared-user", + "kind": "user-text", + "localId": null, + "text": "Choose an option." + }, + { + "createdAt": 1755000001000, + "id": "call", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "call", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "permission": { + "status": "canceled" + }, + "result": { + "status": "canceled" + }, + "state": "completed" + } + } + ], + "hasReadyEvent": false, + "latestUsage": null, + "visibleBlocks": [ + { + "createdAt": 1755000000000, + "id": "shared-user", + "kind": "user-text", + "localId": null, + "text": "Choose an option." + }, + { + "createdAt": 1755000001000, + "id": "call", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "call", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "permission": { + "status": "canceled" + }, + "result": { + "status": "canceled" + }, + "state": "completed" + } + } + ] + }, + "fixtureVersion": 1, + "input": { + "agentState": { + "completedRequests": { + "runtime:thread:number:1": { + "arguments": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "completedAt": 1755000005000, + "createdAt": 1755000001100, + "status": "canceled", + "tool": "request_user_input", + "toolCallId": "call" + } + }, + "requests": {} + }, + "messages": [ + { + "content": { + "content": { + "text": "Choose an option.", + "type": "text" + }, + "role": "user" + }, + "createdAt": 1755000000000, + "id": "shared-user", + "localId": null, + "seq": 1 + }, + { + "content": { + "content": { + "data": { + "callId": "call", + "id": "codex:thread:question:call:start", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000001000, + "id": "shared-question", + "localId": null, + "seq": 2 + }, + { + "content": { + "content": { + "data": { + "callId": "call", + "id": "codex:thread:question:call:canceled", + "is_error": false, + "output": { + "status": "canceled" + }, + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000005000, + "id": "shared-canceled", + "localId": null, + "seq": 3 + } + ], + "options": { + "hasMoreMessages": false + } + }, + "name": "permission-shared-canceled" +} diff --git a/shared/fixtures/chat/permission-shared-pending.json b/shared/fixtures/chat/permission-shared-pending.json new file mode 100644 index 00000000..358770e2 --- /dev/null +++ b/shared/fixtures/chat/permission-shared-pending.json @@ -0,0 +1,190 @@ +{ + "description": "requestUserInput without a native transcript item: CLI persists one question, permission state attaches without duplicating it.", + "expected": { + "blocks": [ + { + "createdAt": 1755000000000, + "id": "shared-user", + "kind": "user-text", + "localId": null, + "text": "Choose an option." + }, + { + "createdAt": 1755000001000, + "id": "call", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "call", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "permission": { + "status": "pending" + }, + "state": "running" + } + } + ], + "hasReadyEvent": false, + "latestUsage": null, + "visibleBlocks": [ + { + "createdAt": 1755000000000, + "id": "shared-user", + "kind": "user-text", + "localId": null, + "text": "Choose an option." + }, + { + "createdAt": 1755000001000, + "id": "call", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "call", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "permission": { + "status": "pending" + }, + "state": "running" + } + } + ] + }, + "fixtureVersion": 1, + "input": { + "agentState": { + "requests": { + "runtime:thread:number:1": { + "arguments": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "createdAt": 1755000001100, + "tool": "request_user_input", + "toolCallId": "call" + } + } + }, + "messages": [ + { + "content": { + "content": { + "text": "Choose an option.", + "type": "text" + }, + "role": "user" + }, + "createdAt": 1755000000000, + "id": "shared-user", + "localId": null, + "seq": 1 + }, + { + "content": { + "content": { + "data": { + "callId": "call", + "id": "codex:thread:question:call:start", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000001000, + "id": "shared-question", + "localId": null, + "seq": 2 + } + ], + "options": { + "hasMoreMessages": false + } + }, + "name": "permission-shared-pending" +} diff --git a/shared/fixtures/chat/permission-shared-resolved.json b/shared/fixtures/chat/permission-shared-resolved.json new file mode 100644 index 00000000..64a0317d --- /dev/null +++ b/shared/fixtures/chat/permission-shared-resolved.json @@ -0,0 +1,220 @@ +{ + "description": "Codex shared request resolved elsewhere: withdraw input, preserve neutral resolved status, never invent the winner or answers.", + "expected": { + "blocks": [ + { + "createdAt": 1755000000000, + "id": "shared-user", + "kind": "user-text", + "localId": null, + "text": "Choose an option." + }, + { + "createdAt": 1755000001000, + "id": "call", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "call", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "permission": { + "status": "resolved" + }, + "result": { + "status": "resolved" + }, + "state": "completed" + } + } + ], + "hasReadyEvent": false, + "latestUsage": null, + "visibleBlocks": [ + { + "createdAt": 1755000000000, + "id": "shared-user", + "kind": "user-text", + "localId": null, + "text": "Choose an option." + }, + { + "createdAt": 1755000001000, + "id": "call", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "call", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "permission": { + "status": "resolved" + }, + "result": { + "status": "resolved" + }, + "state": "completed" + } + } + ] + }, + "fixtureVersion": 1, + "input": { + "agentState": { + "completedRequests": { + "runtime:thread:number:1": { + "arguments": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "completedAt": 1755000005000, + "createdAt": 1755000001100, + "status": "resolved", + "tool": "request_user_input", + "toolCallId": "call" + } + }, + "requests": {} + }, + "messages": [ + { + "content": { + "content": { + "text": "Choose an option.", + "type": "text" + }, + "role": "user" + }, + "createdAt": 1755000000000, + "id": "shared-user", + "localId": null, + "seq": 1 + }, + { + "content": { + "content": { + "data": { + "callId": "call", + "id": "codex:thread:question:call:start", + "input": { + "itemId": "call", + "questions": [ + { + "header": "Choose", + "id": "choice", + "options": [ + { + "description": "A", + "label": "A" + }, + { + "description": "B", + "label": "B" + } + ], + "question": "Which?" + } + ], + "threadId": "thread", + "turnId": "turn" + }, + "name": "request_user_input", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000001000, + "id": "shared-question", + "localId": null, + "seq": 2 + }, + { + "content": { + "content": { + "data": { + "callId": "call", + "id": "codex:thread:question:call:resolved", + "is_error": false, + "output": { + "status": "resolved" + }, + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000005000, + "id": "shared-resolved", + "localId": null, + "seq": 3 + } + ], + "options": { + "hasMoreMessages": false + } + }, + "name": "permission-shared-resolved" +} diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 3020e0ab..2ecea40e 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -571,6 +571,8 @@ export type RewindConversationResponse = { /** CLI → hub RPC result for native fork (before HAPI child binding). */ export type ForkConversationRpcResult = { nativeSessionId: string + /** Shared runtimes bind the child themselves; hub must not spawn another engine. */ + sessionId?: string /** When true, hub must spawn with --fork-session (Claude). */ forkSession?: boolean } diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 1e502383..f12b503d 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -177,6 +177,11 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: return getPermissionModesForFlavor(flavor).includes(mode) } +/** New Codex sessions use the native shared runtime, without HAPI Safe Yolo. */ +export function getLaunchPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] { + return getPermissionModesForFlavor(flavor).filter(mode => flavor !== 'codex' || mode !== 'safe-yolo') +} + export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption[] { return CODEX_COLLABORATION_MODES.map((mode) => ({ mode, diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 824fc65a..c56052b8 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -49,6 +49,7 @@ export const RPC_METHODS = { SteerQueuedMessage: 'steer-queued-message', ForkConversation: 'fork-conversation', RewindConversation: 'rewind-conversation', + ClearConversation: 'clear-conversation', } as const export const RPC_TARGET_MISSING_ERROR_CODE = 'rpc_target_missing' as const diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 9affa1c3..52d1db36 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -15,6 +15,7 @@ import { RPC_METHODS } from './rpcMethods' import { getBuiltinAgentConfigDescriptors } from './agentConfig' export const RUNNER_CAPABILITIES = { + codexSharedRuntime: true as const, piExistingSessionResume: true as const, agentConfigs: getBuiltinAgentConfigDescriptors() } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index bc669cb6..fb5b2ba8 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -35,6 +35,7 @@ export const OpencodeClearOperationSchema = z.object({ export type OpencodeClearOperation = z.infer const SessionCapabilitiesSchema = z.object({ + concurrentClients: z.boolean().optional(), terminal: z.boolean().optional(), conversationHistory: ConversationHistoryCapabilitiesSchema.optional() }) @@ -180,7 +181,7 @@ export const AgentStateCompletedRequestSchema = z.object({ arguments: z.unknown(), createdAt: z.number().nullish(), completedAt: z.number().nullish(), - status: z.enum(['canceled', 'denied', 'approved']), + status: z.enum(['canceled', 'denied', 'approved', 'resolved']), reason: z.string().optional(), mode: z.string().optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), @@ -489,6 +490,7 @@ export const RunnerStateSchema = z.object({ httpPort: z.number().optional(), startedAt: z.number().optional(), capabilities: z.object({ + codexSharedRuntime: z.literal(true).optional(), piExistingSessionResume: z.literal(true).optional(), agentConfigs: z.array(AgentConfigDescriptorSchema).optional() }).optional(), diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 1b9a2aa4..06523808 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -275,6 +275,8 @@ export interface ClientToServerEvents { 'session-end': (data: { sid: string; time: number; reason?: SessionEndReason }) => void 'messages-consumed': (data: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean; steered?: boolean }) => void 'messages-indeterminate': (data: { sid: string; localIds: string[] }) => void + /** Native shared-engine queue projection. null = positively acknowledged removal. */ + 'native-queue-message': (data: { sid: string; localId: string; text: string | null }) => void 'messages-steer-state': (data: { sid: string; localIds: string[]; state: 'queued' | 'dispatching' }, cb: (response: { ok: boolean }) => void) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: UpdateMetadataAck) => void) => void 'update-state': (data: { sid: string; expectedVersion: number; agentState: unknown | null }, cb: (answer: UpdateStateAck) => void) => void diff --git a/web/scripts/fixtures/cases/permissions.ts b/web/scripts/fixtures/cases/permissions.ts index c5cdf25d..30218279 100644 --- a/web/scripts/fixtures/cases/permissions.ts +++ b/web/scripts/fixtures/cases/permissions.ts @@ -19,6 +19,32 @@ const localQuestionMessages = [ ] const localRequest = { tool: 'AskUserQuestion', toolCallId: 'toolu_local_question', arguments: localQuestionInput, createdAt: T0 + 1_100 } +// Shared Codex provides a server request, not a transcript tool_use. These +// messages are the lifecycle persisted by SharedCodexPermissions itself. +const sharedQuestionInput = { threadId: 'thread', turnId: 'turn', itemId: 'call', questions: [{ id: 'choice', header: 'Choose', question: 'Which?', options: [ + { label: 'A', description: 'A' }, { label: 'B', description: 'B' } +] }] } +const sharedRequest = { tool: 'request_user_input', toolCallId: 'call', arguments: sharedQuestionInput, createdAt: T0 + 1_100 } +const sharedQuestionMessages = [ + wireMessage({ id: 'shared-user', seq: 1, createdAt: T0, content: { + role: 'user', content: { type: 'text', text: 'Choose an option.' } + } }), + wireMessage({ id: 'shared-question', seq: 2, createdAt: T0 + 1_000, content: { + role: 'agent', content: { type: 'codex', data: { + type: 'tool-call', name: 'request_user_input', callId: 'call', input: sharedQuestionInput, + id: 'codex:thread:question:call:start' + } } + } }) +] +const sharedQuestionResult = (status: 'resolved' | 'canceled') => wireMessage({ + id: `shared-${status}`, seq: 3, createdAt: T0 + 5_000, content: { + role: 'agent', content: { type: 'codex', data: { + type: 'tool-call-result', callId: 'call', output: { status }, is_error: false, + id: `codex:thread:question:call:${status}` + } } + } +}) + /** * Permission requests are NOT messages: they live in session.agentState * (requests → completedRequests). A pending request whose tool_use message is @@ -28,6 +54,28 @@ const localRequest = { tool: 'AskUserQuestion', toolCallId: 'toolu_local_questio * the oldest loaded message (web/src/chat/reducer.ts). */ export const permissionCases: FixtureCase[] = [ + { + name: 'permission-shared-pending', + description: 'requestUserInput without a native transcript item: CLI persists one question, permission state attaches without duplicating it.', + messages: sharedQuestionMessages, + agentState: { requests: { 'runtime:thread:number:1': sharedRequest } } + }, + { + name: 'permission-shared-resolved', + description: 'Codex shared request resolved elsewhere: withdraw input, preserve neutral resolved status, never invent the winner or answers.', + messages: [...sharedQuestionMessages, sharedQuestionResult('resolved')], + agentState: { requests: {}, completedRequests: { + 'runtime:thread:number:1': { ...sharedRequest, status: 'resolved', completedAt: T0 + 5_000 } + } } + }, + { + name: 'permission-shared-canceled', + description: 'Disconnect withdraws a request-only shared question without claiming it was answered or losing its history.', + messages: [...sharedQuestionMessages, sharedQuestionResult('canceled')], + agentState: { requests: {}, completedRequests: { + 'runtime:thread:number:1': { ...sharedRequest, status: 'canceled', completedAt: T0 + 5_000 } + } } + }, { name: 'permission-local-question-pending', description: 'A local permission has an independent request ID: attach to the existing native toolCallId, but submit answers using permission.id. Do not synthesize a duplicate card.', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e70ad101..fd20bec8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -581,6 +581,10 @@ export class ApiClient { }) } + async clearConversation(sessionId: string): Promise<{ sessionId: string }> { + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear`, { method: 'POST' }) + } + async forkConversation(sessionId: string, messageLocalId?: string): Promise<{ sessionId: string }> { return await this.request<{ sessionId: string }>( `/api/sessions/${encodeURIComponent(sessionId)}/fork`, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 38f2ae7d..e0bcdd3e 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -178,7 +178,7 @@ export type NormalizedMessage = ({ export type ToolPermission = { id: string - status: 'pending' | 'approved' | 'denied' | 'canceled' + status: 'pending' | 'approved' | 'denied' | 'canceled' | 'resolved' reason?: string mode?: string allowedTools?: string[] diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index a9993972..649704e3 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -301,6 +301,7 @@ export function HappyComposer(props: { /** Model for the context-window heuristic; see StatusBar.contextModel. */ contextModel?: string | null controlledByUser?: boolean + concurrentClients?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> /** Full Pi model data with thinkingLevelMap for provider grouping + thinking level filtering */ @@ -407,6 +408,7 @@ export function HappyComposer(props: { contextWindow, contextModel, controlledByUser = false, + concurrentClients = false, agentFlavor, availableModelOptions, piModels, @@ -1002,8 +1004,8 @@ export function HappyComposer(props: { }, [switchDisabled, onSwitchToRemote, haptic]) const permissionModeOptions = useMemo( - () => getPermissionModeOptionsForFlavor(agentFlavor), - [agentFlavor] + () => getPermissionModeOptionsForFlavor(agentFlavor).filter(option => !concurrentClients || option.mode !== 'safe-yolo'), + [agentFlavor, concurrentClients] ) const collaborationModeOptions = useMemo( () => agentFlavor === 'codex' ? getCodexCollaborationModeOptions() : [], diff --git a/web/src/components/NewSession/PermissionField.test.tsx b/web/src/components/NewSession/PermissionField.test.tsx index 959208df..1ec60ecf 100644 --- a/web/src/components/NewSession/PermissionField.test.tsx +++ b/web/src/components/NewSession/PermissionField.test.tsx @@ -20,6 +20,11 @@ function renderField(props: Partial[0]> = {}) } describe('PermissionField', () => { + it('offers only native shared permission modes for Codex', () => { + renderField({ agent: 'codex' }) + expect(screen.getAllByRole('option').map(option => (option as HTMLOptionElement).value)) + .toEqual(['default', 'read-only', 'yolo']) + }) it('renders native permission modes for codex-family agents', () => { const onChange = vi.fn() renderField({ agent: 'copilot', nativeValue: 'default', onNativeChange: onChange }) diff --git a/web/src/components/NewSession/PermissionField.tsx b/web/src/components/NewSession/PermissionField.tsx index 593eecaf..c25d5b0a 100644 --- a/web/src/components/NewSession/PermissionField.tsx +++ b/web/src/components/NewSession/PermissionField.tsx @@ -1,7 +1,7 @@ import { getAgentConfigDescriptor, getPermissionModeLabel, - getPermissionModeOptionsForFlavor, + getLaunchPermissionModesForFlavor, resolveHapiYoloPermissionMode, type PermissionMode } from '@hapi/protocol' @@ -61,7 +61,7 @@ export function PermissionField(props: PermissionFieldProps) { } if (field.kind === 'select' && usesNativePermissionSelect(props.agent)) { - const options = getPermissionModeOptionsForFlavor(props.agent) + const options = getLaunchPermissionModesForFlavor(props.agent) return (