@cocorograph/hub-agent 0.7.26 → 0.7.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.26",
3
+ "version": "0.7.28",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * codex app-server (JSON-RPC 2.0) の低レベルクライアント。
3
+ *
4
+ * Claude は @anthropic-ai/claude-agent-sdk をインプロセスで呼べるが、Codex には同等の
5
+ * JS SDK が無く `codex app-server` (Rust バイナリ) をサブプロセスとして起動し
6
+ * JSON-RPC で会話するしかない。このモジュールはその配線 (spawn・フレーミング・
7
+ * リクエスト/レスポンス相関・通知/サーバー発リクエストの emit) のみを担い、
8
+ * Codex 固有のセッション/ターン概念は codex-stream-bridge.mjs 側の責務とする。
9
+ *
10
+ * transport は `--stdio` (デフォルト) を使う。`--listen unix://<path>` も選べるが、
11
+ * ソケットファイルの生成/権限/クリーンアップ管理が要らず、プロセス終了で自動的に
12
+ * パイプも閉じる stdio の方が「1 セッション = 1 codex app-server プロセス」という
13
+ * per-session モデル (claude-stream-bridge.mjs の 1 stream_id = 1 セッションと対称)
14
+ * に単純に収まる。2026-07-02 に実機で initialize → thread/start → turn/start の
15
+ * 一往復を確認済み: 改行区切り JSON、レスポンスは `{id, result|error}` (method 無し)、
16
+ * 通知は `{method, params}` (id 無し)、サーバー発リクエストは `{id, method, params}`。
17
+ */
18
+ import { spawn } from "node:child_process"
19
+ import { EventEmitter } from "node:events"
20
+
21
+ const DEFAULT_CODEX_CMD = "codex"
22
+
23
+ export class CodexAppServerClient extends EventEmitter {
24
+ constructor({ cwd, codexCmd, env, logger, spawnFn } = {}) {
25
+ super()
26
+ this.cwd = cwd
27
+ this.codexCmd = codexCmd || DEFAULT_CODEX_CMD
28
+ this.env = env
29
+ this.logger = logger
30
+ // テストから spawn をスタブ化できるよう注入可能にする (実 codex バイナリ不要で
31
+ // JSON-RPC 相関ロジックだけを検証するため。既定は node:child_process の spawn)。
32
+ this._spawnFn = spawnFn || spawn
33
+ this.child = null
34
+ this.closed = false
35
+ this._buf = ""
36
+ this._reqId = 0
37
+ this._pending = new Map()
38
+ }
39
+
40
+ /** codex app-server プロセスを起動する。二重呼び出しは無害 (no-op)。 */
41
+ start() {
42
+ if (this.child || this.closed) return
43
+ this.child = this._spawnFn(this.codexCmd, ["app-server", "--stdio"], {
44
+ cwd: this.cwd,
45
+ env: this.env || process.env,
46
+ stdio: ["pipe", "pipe", "pipe"],
47
+ })
48
+ this.child.stdout.on("data", (chunk) => this._onStdout(chunk))
49
+ this.child.stderr.on("data", (chunk) => {
50
+ // codex app-server の stderr はエラーログ用途 (プロトコル本体は stdout のみ)。
51
+ this.logger?.warn?.(
52
+ { stderr: chunk.toString("utf8").slice(0, 2000) },
53
+ "codex app-server stderr",
54
+ )
55
+ })
56
+ this.child.on("exit", (code, signal) => {
57
+ const err = new Error(
58
+ `codex app-server exited unexpectedly (code=${code}, signal=${signal})`,
59
+ )
60
+ this._settleAllPending(err)
61
+ this.closed = true
62
+ this.emit("exit", { code, signal })
63
+ })
64
+ this.child.on("error", (err) => {
65
+ this._settleAllPending(err)
66
+ this.closed = true
67
+ this.emit("error", err)
68
+ })
69
+ }
70
+
71
+ _onStdout(chunk) {
72
+ this._buf += chunk.toString("utf8")
73
+ let idx
74
+ while ((idx = this._buf.indexOf("\n")) >= 0) {
75
+ const line = this._buf.slice(0, idx)
76
+ this._buf = this._buf.slice(idx + 1)
77
+ if (!line.trim()) continue
78
+ let msg
79
+ try {
80
+ msg = JSON.parse(line)
81
+ } catch (err) {
82
+ this.logger?.warn?.(
83
+ { err: err.message, line: line.slice(0, 500) },
84
+ "codex app-server: unparsable line (skipped)",
85
+ )
86
+ continue
87
+ }
88
+ this._dispatch(msg)
89
+ }
90
+ }
91
+
92
+ _dispatch(msg) {
93
+ if (msg.method !== undefined) {
94
+ if (msg.id !== undefined && msg.id !== null) {
95
+ // サーバー発リクエスト (承認要求等)。呼び出し側が respond()/respondError() で
96
+ // 応答を返す責務を負う (codex-stream-bridge.mjs が id を保持して処理する)。
97
+ this.emit("request", msg)
98
+ } else {
99
+ this.emit("notification", msg)
100
+ }
101
+ return
102
+ }
103
+ if (msg.id !== undefined && msg.id !== null) {
104
+ const pending = this._pending.get(msg.id)
105
+ if (!pending) {
106
+ // 対応する pending が無い (タイムアウト後の遅延応答等)。実害は無いので警告のみ。
107
+ this.logger?.warn?.({ id: msg.id }, "codex app-server: response for unknown id")
108
+ return
109
+ }
110
+ this._pending.delete(msg.id)
111
+ if (msg.error) {
112
+ pending.reject(
113
+ Object.assign(new Error(msg.error.message || "codex app-server error"), {
114
+ code: msg.error.code,
115
+ data: msg.error.data,
116
+ }),
117
+ )
118
+ } else {
119
+ pending.resolve(msg.result)
120
+ }
121
+ return
122
+ }
123
+ this.logger?.warn?.({ msg }, "codex app-server: unrecognized message shape")
124
+ }
125
+
126
+ /** クライアント発のリクエストを送り、応答 (result) を Promise で返す。 */
127
+ request(method, params) {
128
+ if (this.closed || !this.child) {
129
+ return Promise.reject(new Error("codex app-server is not running"))
130
+ }
131
+ const id = ++this._reqId
132
+ this._write({ jsonrpc: "2.0", id, method, params })
133
+ return new Promise((resolve, reject) => {
134
+ this._pending.set(id, { resolve, reject })
135
+ })
136
+ }
137
+
138
+ /** クライアント発の通知 (応答不要) を送る。 */
139
+ notify(method, params) {
140
+ if (this.closed || !this.child) return
141
+ this._write({ jsonrpc: "2.0", method, params })
142
+ }
143
+
144
+ /** サーバー発リクエスト (承認要求等) への成功応答。 */
145
+ respond(id, result) {
146
+ if (this.closed || !this.child) return
147
+ this._write({ jsonrpc: "2.0", id, result: result === undefined ? null : result })
148
+ }
149
+
150
+ /** サーバー発リクエストへのエラー応答。 */
151
+ respondError(id, error) {
152
+ if (this.closed || !this.child) return
153
+ this._write({ jsonrpc: "2.0", id, error })
154
+ }
155
+
156
+ _write(obj) {
157
+ this.child.stdin.write(JSON.stringify(obj) + "\n")
158
+ }
159
+
160
+ _settleAllPending(err) {
161
+ for (const { reject } of this._pending.values()) reject(err)
162
+ this._pending.clear()
163
+ }
164
+
165
+ /** プロセスを停止する (stdin を閉じてから kill)。二重呼び出しは無害。 */
166
+ stop() {
167
+ if (!this.child || this.closed) return
168
+ this.closed = true
169
+ this._settleAllPending(new Error("codex app-server client stopped"))
170
+ try {
171
+ this.child.stdin.end()
172
+ } catch {
173
+ // stdin が既に閉じている等は無視して良い (stop の冪等性を優先)。
174
+ }
175
+ try {
176
+ this.child.kill()
177
+ } catch {
178
+ // プロセスが既に無い場合等は無視して良い。
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Codex 版チャットブリッジ (Cockpit Codex 両対応 item#3, Slice 2)。
3
+ *
4
+ * claude-stream-bridge.mjs (ClaudeStreamBridge/ClaudeStreamSession) と対称的な役割を
5
+ * codex-appserver-client.mjs (CodexAppServerClient) の上に実装する。1 stream_id = 1
6
+ * codex app-server プロセス (= 1 Thread) の per-session モデルを採る (Slice 0 の実機
7
+ * 検証で確認した per-session 方針、docs は codex-appserver-client.mjs 冒頭コメント参照)。
8
+ *
9
+ * Claude 側との対応 (詳細は main.mjs の codex.* dispatch = Slice 3 で配線):
10
+ * claude.attach → attach() (initialize → thread/start か thread/resume)
11
+ * claude.input → input() (turn/start、ターン中は turn/steer)
12
+ * claude.interrupt → interrupt() (turn/interrupt)
13
+ * claude.detach → detach() (TTL 付き graceful close)
14
+ * claude.permission.reply → permissionReply() (client.respond/respondError)
15
+ * claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
16
+ * claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
17
+ *
18
+ * Slice 2 のスコープ外 (意図的に見送り、後続スライスで追加する):
19
+ * - 複数ブラウザタブでの同一スレッド共有 (Claude の HUB_AGENT_CHAT_SHARED 相当)。
20
+ * resumeThreadId が「この hub-agent プロセス内で存命中の別 stream_id のセッション」
21
+ * を指す in-memory reattach は今回未対応。resumeThreadId は「未存命のスレッドを
22
+ * thread/resume で再ロードする」場合のみ扱う。
23
+ * - 画像/添付入力 (テキストのみ)。
24
+ * - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
25
+ * (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
26
+ */
27
+ import { EventEmitter } from "node:events"
28
+
29
+ import { CodexAppServerClient } from "./codex-appserver-client.mjs"
30
+
31
+ // detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
32
+ // Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
33
+ // 新プロセスが起動し、旧プロセスは detach で TTL 待ちに入る。7 日だと再接続チャーンの
34
+ // たびに codex app-server プロセスが積み上がる (リーク)。Codex は thread/resume が
35
+ // rollout 正本からモデル側コンテキストを完全復元できるため、プロセスを短命にしても
36
+ // 会話は失われない (再 attach 時のプロセス起動コストは 1〜2 秒)。ターン実行中は TTL に
37
+ // 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
38
+ const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
39
+
40
+ const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
41
+
42
+ function userInputFromMessage(message) {
43
+ const content = message?.content
44
+ if (typeof content === "string") {
45
+ return [{ type: "text", text: content }]
46
+ }
47
+ if (Array.isArray(content)) {
48
+ return content
49
+ .filter((block) => block && (block.type === "text" || typeof block.text === "string"))
50
+ .map((block) => ({ type: "text", text: block.text }))
51
+ }
52
+ return [{ type: "text", text: String(content ?? "") }]
53
+ }
54
+
55
+ export class CodexStreamBridge extends EventEmitter {
56
+ constructor({ codexCmd, logger, clientFactory, detachTtlMs } = {}) {
57
+ super()
58
+ this.codexCmd = codexCmd
59
+ this.logger = logger
60
+ this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
61
+ // テストから TTL を短縮できるよう注入可能にする (既定は 7 日)。
62
+ this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
63
+ /** @type {Map<string, object>} stream_id -> session record */
64
+ this.sessions = new Map()
65
+ }
66
+
67
+ /**
68
+ * @returns {Promise<{resuming: boolean, threadId: string}>}
69
+ */
70
+ async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
71
+ if (this.sessions.has(stream_id)) {
72
+ throw new Error(`stream_id already attached: ${stream_id}`)
73
+ }
74
+ const client = this._clientFactory({ cwd, codexCmd: this.codexCmd, logger: this.logger })
75
+ const session = {
76
+ stream_id,
77
+ cwd,
78
+ client,
79
+ threadId: null,
80
+ activeTurnId: null,
81
+ pendingApprovals: new Map(), // request_id -> { turnId }
82
+ detachTimer: null,
83
+ dead: false,
84
+ }
85
+ this.sessions.set(stream_id, session)
86
+ this._wireClient(session)
87
+ client.start()
88
+
89
+ try {
90
+ await client.request("initialize", { clientInfo: CLIENT_INFO })
91
+ const resuming = !!resumeThreadId
92
+ const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
93
+ if (effort) startParams.effort = effort
94
+ if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
95
+ const result = resuming
96
+ ? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
97
+ : await client.request("thread/start", startParams)
98
+ session.threadId = result?.thread?.id
99
+ return { resuming, threadId: session.threadId }
100
+ } catch (err) {
101
+ this.sessions.delete(stream_id)
102
+ client.stop()
103
+ throw err
104
+ }
105
+ }
106
+
107
+ _wireClient(session) {
108
+ const { client, stream_id } = session
109
+ client.on("notification", (msg) => this._onNotification(session, msg))
110
+ client.on("request", (msg) => this._onServerRequest(session, msg))
111
+ client.on("exit", (info) => this._onClientExit(session, info))
112
+ client.on("error", (err) => {
113
+ this.emit("error", { stream_id, thread_id: session.threadId, error: err.message || String(err) })
114
+ })
115
+ }
116
+
117
+ _onNotification(session, msg) {
118
+ const { stream_id } = session
119
+ switch (msg.method) {
120
+ case "turn/started":
121
+ session.activeTurnId = msg.params?.turn?.id || null
122
+ break
123
+ case "turn/completed":
124
+ session.activeTurnId = null
125
+ this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
126
+ this._maybeReapAfterIdle(session)
127
+ break
128
+ default:
129
+ break
130
+ }
131
+ this.emit("event", {
132
+ stream_id,
133
+ thread_id: session.threadId,
134
+ cwd: session.cwd,
135
+ notification: { method: msg.method, params: msg.params },
136
+ })
137
+ }
138
+
139
+ _onServerRequest(session, msg) {
140
+ const { stream_id } = session
141
+ session.pendingApprovals.set(msg.id, { turnId: session.activeTurnId })
142
+ this.emit("permission", {
143
+ stream_id,
144
+ thread_id: session.threadId,
145
+ cwd: session.cwd,
146
+ request_id: msg.id,
147
+ method: msg.method,
148
+ params: msg.params,
149
+ })
150
+ }
151
+
152
+ // ターン完了時、そのターンに紐づく未解決の承認要求は自動 decline する
153
+ // (browser が応答する前にターンが打ち切られた/クラッシュ相当のケース。
154
+ // claude-stream-bridge.mjs の「ターン終了時に未解決分は自動 deny」と同じ設計)。
155
+ _autoResolveStalePendingApprovals(session, completedTurnId) {
156
+ for (const [requestId, info] of session.pendingApprovals) {
157
+ if (info.turnId !== completedTurnId) continue
158
+ session.pendingApprovals.delete(requestId)
159
+ session.client.respond(requestId, { decision: "decline" })
160
+ }
161
+ }
162
+
163
+ _onClientExit(session, info) {
164
+ if (session.dead) return
165
+ session.dead = true
166
+ this._clearDetachTimer(session)
167
+ this.sessions.delete(session.stream_id)
168
+ this.emit("exit", {
169
+ stream_id: session.stream_id,
170
+ thread_id: session.threadId,
171
+ code: info.code,
172
+ reason: info.signal ? `signal:${info.signal}` : "process-exit",
173
+ })
174
+ }
175
+
176
+ /** @returns {Promise<void>} */
177
+ async input({ stream_id, message }) {
178
+ const session = this._require(stream_id)
179
+ const input = userInputFromMessage(message)
180
+ if (session.activeTurnId) {
181
+ await session.client.request("turn/steer", {
182
+ threadId: session.threadId,
183
+ expectedTurnId: session.activeTurnId,
184
+ input,
185
+ })
186
+ return
187
+ }
188
+ await session.client.request("turn/start", { threadId: session.threadId, input })
189
+ }
190
+
191
+ /** @returns {Promise<void>} */
192
+ async interrupt({ stream_id }) {
193
+ const session = this._require(stream_id)
194
+ if (!session.activeTurnId) return
195
+ await session.client.request("turn/interrupt", {
196
+ threadId: session.threadId,
197
+ turnId: session.activeTurnId,
198
+ })
199
+ }
200
+
201
+ /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
202
+ permissionReply({ stream_id, request_id, result }) {
203
+ const session = this._require(stream_id)
204
+ if (!session.pendingApprovals.has(request_id)) {
205
+ this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
206
+ return
207
+ }
208
+ session.pendingApprovals.delete(request_id)
209
+ session.client.respond(request_id, result)
210
+ }
211
+
212
+ /**
213
+ * グレースフル切断。ターン実行中は完走を待ち、非 busy ならアイドル TTL 後に
214
+ * プロセスを reap する (claude-stream-bridge.mjs の softDetach と同じ思想。
215
+ * Slice 2 は多端末共有非対応なので detach = そのまま TTL 予約でよい)。
216
+ */
217
+ detach({ stream_id }) {
218
+ const session = this.sessions.get(stream_id)
219
+ if (!session || session.dead) return
220
+ this._maybeReapAfterIdle(session)
221
+ }
222
+
223
+ _maybeReapAfterIdle(session) {
224
+ if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
225
+ this._clearDetachTimer(session)
226
+ session.detachTimer = setTimeout(() => {
227
+ if (session.dead) return
228
+ session.client.stop()
229
+ }, this._detachTtlMs)
230
+ session.detachTimer.unref?.()
231
+ }
232
+
233
+ _clearDetachTimer(session) {
234
+ if (session.detachTimer) {
235
+ clearTimeout(session.detachTimer)
236
+ session.detachTimer = null
237
+ }
238
+ }
239
+
240
+ _require(stream_id) {
241
+ const session = this.sessions.get(stream_id)
242
+ if (!session || session.dead) {
243
+ throw new Error(`no active codex session for stream_id: ${stream_id}`)
244
+ }
245
+ return session
246
+ }
247
+
248
+ /** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
249
+ shutdown() {
250
+ for (const session of this.sessions.values()) {
251
+ this._clearDetachTimer(session)
252
+ session.dead = true
253
+ session.client.stop()
254
+ }
255
+ this.sessions.clear()
256
+ }
257
+ }
package/src/main.mjs CHANGED
@@ -25,6 +25,7 @@ import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs
25
25
  import { WsClient } from "./ws-client.mjs"
26
26
  import { PtyBridge } from "./pty-bridge.mjs"
27
27
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
28
+ import { CodexStreamBridge } from "./codex-stream-bridge.mjs"
28
29
  import { UploadManager } from "./claude-upload.mjs"
29
30
  import { requestSelfUninstall } from "./service-install.mjs"
30
31
  import {
@@ -425,6 +426,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
425
426
  const resolvedPty = ptyModule || (await import("@lydell/node-pty"))
426
427
  const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
427
428
 
429
+ // ホストで起動可能な CLI (claude / codex) を 1 回検出する。hello で広告する用途に
430
+ // 加え、codex app-server ベースの chat ブリッジ (codexBridge) を構築するかどうかの
431
+ // gate にも使う (claudeBridge は npm optional dep の有無で判定するのに対し、codex は
432
+ // JS SDK が無く CLI バイナリの有無そのものが可否条件になる)。
433
+ const availableClis = await detectAvailableClis()
434
+ logger.info({ availableClis }, "available CLIs detected")
435
+
428
436
  // Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
429
437
  // テストでは引数で stub を差し込める。
430
438
  const resolvedSdk = claudeSdk !== undefined ? claudeSdk : await loadClaudeSdk(logger)
@@ -432,6 +440,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
432
440
  ? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
433
441
  : null
434
442
 
443
+ // codex app-server ベースの chat ブリッジ。PATH 上に codex バイナリが無ければ
444
+ // null のままにし、claudeBridge と同じ流儀で codex.attach が codex.error を返す
445
+ // 経路に分岐させる (main.mjs の codex.* dispatch 側で判定)。
446
+ const codexBridge = availableClis.includes("codex")
447
+ ? new CodexStreamBridge({ logger })
448
+ : null
449
+
435
450
  // Cockpit チャットモードの添付ファイル受信器 (browser → agent のチャンク送信を
436
451
  // ローカル FS に保存し、保存パスを返す)。SDK 有無に関わらず生成してよいが、添付は
437
452
  // チャットモード専用機能なので claudeBridge と同じく stream モード前提で使う。
@@ -442,10 +457,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
442
457
  logger.info({ bundleVersion }, "hub bundle version detected")
443
458
  }
444
459
 
445
- // ホストで起動可能な CLI (claude / codex) を 1 回検出して hello で広告する。
446
- const availableClis = await detectAvailableClis()
447
- logger.info({ availableClis }, "available CLIs detected")
448
-
449
460
  const client = new WsClient(config, {
450
461
  logger,
451
462
  version,
@@ -564,6 +575,53 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
564
575
  })
565
576
  }
566
577
 
578
+ // codex 版 (Slice 3): codex-stream-bridge.mjs の生 notification/permission/exit/error を
579
+ // browser にそのまま転送する。claudeBridge と同じ「素通し」方針 (codex-stream-bridge.mjs
580
+ // 冒頭コメント参照)。claude 版にある rate_limit/MCP control query 相当の処理は無い
581
+ // (codex 側にまだ存在しない/後続スライス)。ステータスドット用のチャット信号は
582
+ // turn/started・turn/completed のみ最小限で反映する (claude の assistant/result 相当)。
583
+ if (codexBridge) {
584
+ codexBridge.on("event", ({ stream_id, thread_id, cwd, notification }) => {
585
+ if (notification.method === "turn/started") {
586
+ try {
587
+ recordChatActivity(cwd, { status: "processing", inputPending: false })
588
+ } catch {
589
+ /* ignore */
590
+ }
591
+ } else if (notification.method === "turn/completed") {
592
+ try {
593
+ recordChatActivity(cwd, { status: "waiting", inputPending: false })
594
+ } catch {
595
+ /* ignore */
596
+ }
597
+ }
598
+ client.send({ type: "codex.event", stream_id, thread_id, event: notification })
599
+ })
600
+ codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
601
+ if (cwd) {
602
+ try {
603
+ recordChatActivity(cwd, { inputPending: true })
604
+ } catch {
605
+ /* ignore */
606
+ }
607
+ }
608
+ client.send({
609
+ type: "codex.permission.request",
610
+ stream_id,
611
+ thread_id,
612
+ request_id,
613
+ method,
614
+ params,
615
+ })
616
+ })
617
+ codexBridge.on("exit", ({ stream_id, thread_id, code, reason }) => {
618
+ client.send({ type: "codex.exit", stream_id, thread_id, code, reason })
619
+ })
620
+ codexBridge.on("error", ({ stream_id, thread_id, error }) => {
621
+ client.send({ type: "codex.error", stream_id, thread_id, error })
622
+ })
623
+ }
624
+
567
625
  // Hub からのメッセージ dispatch は **直列実行** する。
568
626
  //
569
627
  // `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
@@ -591,7 +649,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
591
649
  // permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
592
650
  if (isFastPathMessage(msg?.type)) {
593
651
  Promise.resolve(
594
- dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }),
652
+ dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, uploadManager }),
595
653
  ).catch((err) => {
596
654
  logger.error(
597
655
  { err: err.message, type: msg?.type },
@@ -601,7 +659,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
601
659
  return
602
660
  }
603
661
  dispatchChain = dispatchChain
604
- .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }))
662
+ .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, uploadManager }))
605
663
  .catch((err) => {
606
664
  logger.error(
607
665
  { err: err.message, type: msg?.type },
@@ -829,6 +887,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
829
887
  jsonlLiveWatchers.stop()
830
888
  ptyBridge.shutdown()
831
889
  claudeBridge?.shutdown?.()
890
+ codexBridge?.shutdown?.()
832
891
  client.stop()
833
892
  // 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
834
893
  clearInterval(keepaliveTimer)
@@ -856,7 +915,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
856
915
  )
857
916
  })
858
917
 
859
- return { client, plugins, ptyBridge, claudeBridge, uploadManager }
918
+ return { client, plugins, ptyBridge, claudeBridge, codexBridge, uploadManager }
860
919
  }
861
920
 
862
921
  const SESSION_EVENTS_DIR =
@@ -2934,6 +2993,76 @@ async function dispatch(msg, ctx) {
2934
2993
  if (!ctx.claudeBridge) return
2935
2994
  ctx.claudeBridge.detach({ stream_id: msg.stream_id })
2936
2995
  return
2996
+ // codex 版 dispatch (Slice 3)。claude.* と同じ流儀だが、codex-stream-bridge.mjs の
2997
+ // attach()/input()/interrupt() は codex app-server との JSON-RPC 往復を伴うため
2998
+ // async (claude 版は SDK 呼び出しが同期/fire-and-forget)。失敗時は codex.error で
2999
+ // browser に伝える。permission.reply の result は method ごとに shape が異なる
3000
+ // (accept/decline 等) ため、正規化せず browser が組み立てたものをそのまま渡す。
3001
+ case "codex.attach": {
3002
+ const stream_id = msg.stream_id
3003
+ if (!ctx.codexBridge) {
3004
+ ctx.client.send({
3005
+ type: "codex.error",
3006
+ stream_id,
3007
+ error: "codex_unavailable: codex CLI が agent の PATH に見つかりません",
3008
+ })
3009
+ return
3010
+ }
3011
+ try {
3012
+ const info = await ctx.codexBridge.attach({
3013
+ stream_id,
3014
+ cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
3015
+ model: msg.model || undefined,
3016
+ effort: msg.effort || undefined,
3017
+ approvalPolicy: msg.approval_policy || undefined,
3018
+ sandbox: msg.sandbox || undefined,
3019
+ resumeThreadId: msg.resume_thread_id || null,
3020
+ })
3021
+ ctx.client.send({
3022
+ type: "codex.ready",
3023
+ stream_id,
3024
+ resuming: info.resuming,
3025
+ thread_id: info.threadId,
3026
+ })
3027
+ } catch (err) {
3028
+ ctx.client.send({
3029
+ type: "codex.error",
3030
+ stream_id,
3031
+ error: err.message,
3032
+ })
3033
+ }
3034
+ return
3035
+ }
3036
+ case "codex.input":
3037
+ if (!ctx.codexBridge) return
3038
+ ctx.codexBridge
3039
+ .input({ stream_id: msg.stream_id, message: msg.message })
3040
+ .catch((err) => {
3041
+ ctx.client.send({
3042
+ type: "codex.error",
3043
+ stream_id: msg.stream_id,
3044
+ error: err.message,
3045
+ })
3046
+ })
3047
+ return
3048
+ case "codex.permission.reply":
3049
+ if (!ctx.codexBridge) return
3050
+ ctx.codexBridge.permissionReply({
3051
+ stream_id: msg.stream_id,
3052
+ request_id: msg.request_id,
3053
+ result: msg.result,
3054
+ })
3055
+ return
3056
+ case "codex.interrupt":
3057
+ if (!ctx.codexBridge) return
3058
+ ctx.codexBridge.interrupt({ stream_id: msg.stream_id }).catch((err) => {
3059
+ ctx.logger?.warn?.({ err: err.message }, "codex.interrupt failed")
3060
+ })
3061
+ return
3062
+ case "codex.detach":
3063
+ if (!ctx.codexBridge) return
3064
+ ctx.codexBridge.detach({ stream_id: msg.stream_id })
3065
+ return
2937
3066
  case "claude.history.request": {
2938
3067
  // Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
2939
3068
  // 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。
@@ -3263,7 +3392,7 @@ async function dispatch(msg, ctx) {
3263
3392
  return
3264
3393
  }
3265
3394
  case "worktree.create": {
3266
- // body: { request_id, dir, branch, claude_cmd? }
3395
+ // body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?, approval_policy?, effort?, initial_prompt? }
3267
3396
  const dir = (msg.dir || "").trim()
3268
3397
  const branch = (msg.branch || "").trim()
3269
3398
  if (!dir || !branch) {
@@ -3286,12 +3415,22 @@ async function dispatch(msg, ctx) {
3286
3415
  }
3287
3416
  try {
3288
3417
  const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
3289
- // worktree dir で tmux session を作成 (claude_cmd createSession の default)
3418
+ // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind
3419
+ // 解釈し、codex なら codex を pane 起動する(既定 claude)。worktree は別 cwd=
3420
+ // 別セッション名なので、メイン=claude / worktree=codex の並走が成立する。
3290
3421
  await createTmuxSession(wtName, wtPath, {
3422
+ cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3291
3423
  claudeCmd:
3292
3424
  typeof msg.claude_cmd === "string"
3293
3425
  ? msg.claude_cmd
3294
3426
  : claudeCmdFromAgentConfig(ctx.config),
3427
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3428
+ // codex 用設定 (claude 経路では無視される)。
3429
+ model: typeof msg.model === "string" ? msg.model : undefined,
3430
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3431
+ approvalPolicy:
3432
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3433
+ effort: typeof msg.effort === "string" ? msg.effort : undefined,
3295
3434
  initialPrompt:
3296
3435
  typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3297
3436
  logger: ctx.logger,