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