@cocorograph/hub-agent 0.7.27 → 0.7.29
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 +1 -1
- package/src/claude-stream-bridge.mjs +34 -5
- package/src/codex-appserver-client.mjs +181 -0
- package/src/codex-stream-bridge.mjs +257 -0
- package/src/main.mjs +149 -11
package/package.json
CHANGED
|
@@ -568,18 +568,27 @@ class ClaudeStreamSession {
|
|
|
568
568
|
}
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
-
/** モデルが Opus 4.6+
|
|
571
|
+
/** モデルが effort / adaptive thinking 対応 (Opus 4.6+ / Fable 5) かどうか。
|
|
572
572
|
* budget 方式 (maxThinkingTokens) は Opus 4.7+ で廃止扱いのため、effort モデルでは
|
|
573
|
-
* effort + thinking:{type:'adaptive'} に切り替える。
|
|
573
|
+
* effort + thinking:{type:'adaptive'} に切り替える。
|
|
574
|
+
* ⚠️ frontend の isEffortModelByPattern (types/cockpit.ts) と同一パターンを維持する
|
|
575
|
+
* こと。旧実装は fable-5 を含まず、frontend が effort を送っても bridge が非 effort
|
|
576
|
+
* 分岐に入り「effort も budget も未適用」になる不整合があった (2026-07-02 修正)。 */
|
|
574
577
|
_isEffortModel() {
|
|
575
|
-
return
|
|
578
|
+
return (
|
|
579
|
+
typeof this.model === "string" && /claude-fable-5|claude-opus-4-[678]/.test(this.model)
|
|
580
|
+
)
|
|
576
581
|
}
|
|
577
582
|
|
|
578
583
|
/** 思考関連オプション (effort / adaptive thinking / 旧 budget) を options へ適用する。
|
|
579
584
|
* per-message / 常駐 query の両方から呼ぶ共通ロジック (分岐の二重定義を避ける)。 */
|
|
580
585
|
_applyThinkingOptions(options) {
|
|
581
|
-
|
|
582
|
-
|
|
586
|
+
// model 未指定 (= browser が agent/アカウント既定モデルへ委任) でも、browser が
|
|
587
|
+
// effort を明示送信してきた場合は effort 分岐に入れる。frontend は system/init の
|
|
588
|
+
// 実効モデル (runtimeModel) で capability 判定してから effort を送るため、ここでは
|
|
589
|
+
// その判断を信頼する (2026-07-02)。
|
|
590
|
+
if (this._isEffortModel() || (!this.model && this.effort)) {
|
|
591
|
+
// effort モデル: adaptive thinking を明示 ON にし、effort で深さを指定する。
|
|
583
592
|
// budget 方式 (maxThinkingTokens) は使わない (Opus 4.7+ で非対応)。
|
|
584
593
|
options.thinking = { type: "adaptive" }
|
|
585
594
|
if (this.effort) options.effort = this.effort
|
|
@@ -803,6 +812,26 @@ class ClaudeStreamSession {
|
|
|
803
812
|
} catch {
|
|
804
813
|
/* ignore */
|
|
805
814
|
}
|
|
815
|
+
// 停止即時化 (2026-07-02): onTurnSettled はチャット信号 (サイドバードット) 専用で、
|
|
816
|
+
// browser の SDK ストリーム UI (turnActive / interrupting) を解除するイベントでは
|
|
817
|
+
// ない。abort でターンが終わると result が届かず、UI は 120s 無音ウォッチドッグ
|
|
818
|
+
// まで「停止中…」のまま固着する。合成 result を通常のイベント経路 (claude.event)
|
|
819
|
+
// へ流し、reducer にターン終了を即時確定させる。subtype はユーザー中断 (abort) と
|
|
820
|
+
// 異常終了 (result 無しの自然終了) を区別する (frontend は前者を「中断しました」
|
|
821
|
+
// フッターで表示する)。uuid を持たせるのは重複排除 (isDuplicateEvent) が result
|
|
822
|
+
// 署名 (session_id + num_turns + duration_ms + cost) で判定するため — 合成 result
|
|
823
|
+
// はこれらが毎回同値になり、2 回目以降の中断で「重複」と誤判定され捨てられるのを防ぐ。
|
|
824
|
+
try {
|
|
825
|
+
this._emit({
|
|
826
|
+
type: "result",
|
|
827
|
+
subtype: aborted ? "aborted_by_user" : "turn_settled",
|
|
828
|
+
uuid: randomUUID(),
|
|
829
|
+
session_id: this.sessionId ?? undefined,
|
|
830
|
+
timestamp: new Date().toISOString(),
|
|
831
|
+
})
|
|
832
|
+
} catch {
|
|
833
|
+
/* ignore */
|
|
834
|
+
}
|
|
806
835
|
}
|
|
807
836
|
// graceful detach: browser が切れている間にターンが完走したら、ここで遅延
|
|
808
837
|
// クローズする。manager 側で sessions Map から撤去 + exit を emit する。
|
|
@@ -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 {
|
|
@@ -270,12 +271,21 @@ async function loadClaudeSdk(logger) {
|
|
|
270
271
|
|
|
271
272
|
/**
|
|
272
273
|
* B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
|
|
273
|
-
* pty 出力データ (pty.data) と resize (pty.resize)
|
|
274
|
-
*
|
|
275
|
-
*
|
|
274
|
+
* - pty 出力データ (pty.data) と resize (pty.resize): 高頻度・順序保証不要。
|
|
275
|
+
* - claude.interrupt / codex.interrupt (2026-07-02): 停止指示。前段に重い dispatch
|
|
276
|
+
* (tmux.exec 等) が滞留していると停止が遅延するため直列キューをバイパスする。
|
|
277
|
+
* 中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い (むしろ
|
|
278
|
+
* キュー内の後続入力より先に届くべき)。
|
|
279
|
+
* 入力系 (claude.input)・他の制御系 (tmux.exec / permission / cancel→paste 等) は
|
|
280
|
+
* WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
|
|
276
281
|
*/
|
|
277
282
|
export function isFastPathMessage(type) {
|
|
278
|
-
return
|
|
283
|
+
return (
|
|
284
|
+
type === "pty.data" ||
|
|
285
|
+
type === "pty.resize" ||
|
|
286
|
+
type === "claude.interrupt" ||
|
|
287
|
+
type === "codex.interrupt"
|
|
288
|
+
)
|
|
279
289
|
}
|
|
280
290
|
|
|
281
291
|
/**
|
|
@@ -425,6 +435,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
425
435
|
const resolvedPty = ptyModule || (await import("@lydell/node-pty"))
|
|
426
436
|
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
427
437
|
|
|
438
|
+
// ホストで起動可能な CLI (claude / codex) を 1 回検出する。hello で広告する用途に
|
|
439
|
+
// 加え、codex app-server ベースの chat ブリッジ (codexBridge) を構築するかどうかの
|
|
440
|
+
// gate にも使う (claudeBridge は npm optional dep の有無で判定するのに対し、codex は
|
|
441
|
+
// JS SDK が無く CLI バイナリの有無そのものが可否条件になる)。
|
|
442
|
+
const availableClis = await detectAvailableClis()
|
|
443
|
+
logger.info({ availableClis }, "available CLIs detected")
|
|
444
|
+
|
|
428
445
|
// Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
|
|
429
446
|
// テストでは引数で stub を差し込める。
|
|
430
447
|
const resolvedSdk = claudeSdk !== undefined ? claudeSdk : await loadClaudeSdk(logger)
|
|
@@ -432,6 +449,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
432
449
|
? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
|
|
433
450
|
: null
|
|
434
451
|
|
|
452
|
+
// codex app-server ベースの chat ブリッジ。PATH 上に codex バイナリが無ければ
|
|
453
|
+
// null のままにし、claudeBridge と同じ流儀で codex.attach が codex.error を返す
|
|
454
|
+
// 経路に分岐させる (main.mjs の codex.* dispatch 側で判定)。
|
|
455
|
+
const codexBridge = availableClis.includes("codex")
|
|
456
|
+
? new CodexStreamBridge({ logger })
|
|
457
|
+
: null
|
|
458
|
+
|
|
435
459
|
// Cockpit チャットモードの添付ファイル受信器 (browser → agent のチャンク送信を
|
|
436
460
|
// ローカル FS に保存し、保存パスを返す)。SDK 有無に関わらず生成してよいが、添付は
|
|
437
461
|
// チャットモード専用機能なので claudeBridge と同じく stream モード前提で使う。
|
|
@@ -442,10 +466,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
442
466
|
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
443
467
|
}
|
|
444
468
|
|
|
445
|
-
// ホストで起動可能な CLI (claude / codex) を 1 回検出して hello で広告する。
|
|
446
|
-
const availableClis = await detectAvailableClis()
|
|
447
|
-
logger.info({ availableClis }, "available CLIs detected")
|
|
448
|
-
|
|
449
469
|
const client = new WsClient(config, {
|
|
450
470
|
logger,
|
|
451
471
|
version,
|
|
@@ -564,6 +584,53 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
564
584
|
})
|
|
565
585
|
}
|
|
566
586
|
|
|
587
|
+
// codex 版 (Slice 3): codex-stream-bridge.mjs の生 notification/permission/exit/error を
|
|
588
|
+
// browser にそのまま転送する。claudeBridge と同じ「素通し」方針 (codex-stream-bridge.mjs
|
|
589
|
+
// 冒頭コメント参照)。claude 版にある rate_limit/MCP control query 相当の処理は無い
|
|
590
|
+
// (codex 側にまだ存在しない/後続スライス)。ステータスドット用のチャット信号は
|
|
591
|
+
// turn/started・turn/completed のみ最小限で反映する (claude の assistant/result 相当)。
|
|
592
|
+
if (codexBridge) {
|
|
593
|
+
codexBridge.on("event", ({ stream_id, thread_id, cwd, notification }) => {
|
|
594
|
+
if (notification.method === "turn/started") {
|
|
595
|
+
try {
|
|
596
|
+
recordChatActivity(cwd, { status: "processing", inputPending: false })
|
|
597
|
+
} catch {
|
|
598
|
+
/* ignore */
|
|
599
|
+
}
|
|
600
|
+
} else if (notification.method === "turn/completed") {
|
|
601
|
+
try {
|
|
602
|
+
recordChatActivity(cwd, { status: "waiting", inputPending: false })
|
|
603
|
+
} catch {
|
|
604
|
+
/* ignore */
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
client.send({ type: "codex.event", stream_id, thread_id, event: notification })
|
|
608
|
+
})
|
|
609
|
+
codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
|
|
610
|
+
if (cwd) {
|
|
611
|
+
try {
|
|
612
|
+
recordChatActivity(cwd, { inputPending: true })
|
|
613
|
+
} catch {
|
|
614
|
+
/* ignore */
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
client.send({
|
|
618
|
+
type: "codex.permission.request",
|
|
619
|
+
stream_id,
|
|
620
|
+
thread_id,
|
|
621
|
+
request_id,
|
|
622
|
+
method,
|
|
623
|
+
params,
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
codexBridge.on("exit", ({ stream_id, thread_id, code, reason }) => {
|
|
627
|
+
client.send({ type: "codex.exit", stream_id, thread_id, code, reason })
|
|
628
|
+
})
|
|
629
|
+
codexBridge.on("error", ({ stream_id, thread_id, error }) => {
|
|
630
|
+
client.send({ type: "codex.error", stream_id, thread_id, error })
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
567
634
|
// Hub からのメッセージ dispatch は **直列実行** する。
|
|
568
635
|
//
|
|
569
636
|
// `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
|
|
@@ -591,7 +658,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
591
658
|
// permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
|
|
592
659
|
if (isFastPathMessage(msg?.type)) {
|
|
593
660
|
Promise.resolve(
|
|
594
|
-
dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }),
|
|
661
|
+
dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, uploadManager }),
|
|
595
662
|
).catch((err) => {
|
|
596
663
|
logger.error(
|
|
597
664
|
{ err: err.message, type: msg?.type },
|
|
@@ -601,7 +668,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
601
668
|
return
|
|
602
669
|
}
|
|
603
670
|
dispatchChain = dispatchChain
|
|
604
|
-
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }))
|
|
671
|
+
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, uploadManager }))
|
|
605
672
|
.catch((err) => {
|
|
606
673
|
logger.error(
|
|
607
674
|
{ err: err.message, type: msg?.type },
|
|
@@ -829,6 +896,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
829
896
|
jsonlLiveWatchers.stop()
|
|
830
897
|
ptyBridge.shutdown()
|
|
831
898
|
claudeBridge?.shutdown?.()
|
|
899
|
+
codexBridge?.shutdown?.()
|
|
832
900
|
client.stop()
|
|
833
901
|
// 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
|
|
834
902
|
clearInterval(keepaliveTimer)
|
|
@@ -856,7 +924,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
856
924
|
)
|
|
857
925
|
})
|
|
858
926
|
|
|
859
|
-
return { client, plugins, ptyBridge, claudeBridge, uploadManager }
|
|
927
|
+
return { client, plugins, ptyBridge, claudeBridge, codexBridge, uploadManager }
|
|
860
928
|
}
|
|
861
929
|
|
|
862
930
|
const SESSION_EVENTS_DIR =
|
|
@@ -2934,6 +3002,76 @@ async function dispatch(msg, ctx) {
|
|
|
2934
3002
|
if (!ctx.claudeBridge) return
|
|
2935
3003
|
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|
|
2936
3004
|
return
|
|
3005
|
+
// codex 版 dispatch (Slice 3)。claude.* と同じ流儀だが、codex-stream-bridge.mjs の
|
|
3006
|
+
// attach()/input()/interrupt() は codex app-server との JSON-RPC 往復を伴うため
|
|
3007
|
+
// async (claude 版は SDK 呼び出しが同期/fire-and-forget)。失敗時は codex.error で
|
|
3008
|
+
// browser に伝える。permission.reply の result は method ごとに shape が異なる
|
|
3009
|
+
// (accept/decline 等) ため、正規化せず browser が組み立てたものをそのまま渡す。
|
|
3010
|
+
case "codex.attach": {
|
|
3011
|
+
const stream_id = msg.stream_id
|
|
3012
|
+
if (!ctx.codexBridge) {
|
|
3013
|
+
ctx.client.send({
|
|
3014
|
+
type: "codex.error",
|
|
3015
|
+
stream_id,
|
|
3016
|
+
error: "codex_unavailable: codex CLI が agent の PATH に見つかりません",
|
|
3017
|
+
})
|
|
3018
|
+
return
|
|
3019
|
+
}
|
|
3020
|
+
try {
|
|
3021
|
+
const info = await ctx.codexBridge.attach({
|
|
3022
|
+
stream_id,
|
|
3023
|
+
cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
|
|
3024
|
+
model: msg.model || undefined,
|
|
3025
|
+
effort: msg.effort || undefined,
|
|
3026
|
+
approvalPolicy: msg.approval_policy || undefined,
|
|
3027
|
+
sandbox: msg.sandbox || undefined,
|
|
3028
|
+
resumeThreadId: msg.resume_thread_id || null,
|
|
3029
|
+
})
|
|
3030
|
+
ctx.client.send({
|
|
3031
|
+
type: "codex.ready",
|
|
3032
|
+
stream_id,
|
|
3033
|
+
resuming: info.resuming,
|
|
3034
|
+
thread_id: info.threadId,
|
|
3035
|
+
})
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
ctx.client.send({
|
|
3038
|
+
type: "codex.error",
|
|
3039
|
+
stream_id,
|
|
3040
|
+
error: err.message,
|
|
3041
|
+
})
|
|
3042
|
+
}
|
|
3043
|
+
return
|
|
3044
|
+
}
|
|
3045
|
+
case "codex.input":
|
|
3046
|
+
if (!ctx.codexBridge) return
|
|
3047
|
+
ctx.codexBridge
|
|
3048
|
+
.input({ stream_id: msg.stream_id, message: msg.message })
|
|
3049
|
+
.catch((err) => {
|
|
3050
|
+
ctx.client.send({
|
|
3051
|
+
type: "codex.error",
|
|
3052
|
+
stream_id: msg.stream_id,
|
|
3053
|
+
error: err.message,
|
|
3054
|
+
})
|
|
3055
|
+
})
|
|
3056
|
+
return
|
|
3057
|
+
case "codex.permission.reply":
|
|
3058
|
+
if (!ctx.codexBridge) return
|
|
3059
|
+
ctx.codexBridge.permissionReply({
|
|
3060
|
+
stream_id: msg.stream_id,
|
|
3061
|
+
request_id: msg.request_id,
|
|
3062
|
+
result: msg.result,
|
|
3063
|
+
})
|
|
3064
|
+
return
|
|
3065
|
+
case "codex.interrupt":
|
|
3066
|
+
if (!ctx.codexBridge) return
|
|
3067
|
+
ctx.codexBridge.interrupt({ stream_id: msg.stream_id }).catch((err) => {
|
|
3068
|
+
ctx.logger?.warn?.({ err: err.message }, "codex.interrupt failed")
|
|
3069
|
+
})
|
|
3070
|
+
return
|
|
3071
|
+
case "codex.detach":
|
|
3072
|
+
if (!ctx.codexBridge) return
|
|
3073
|
+
ctx.codexBridge.detach({ stream_id: msg.stream_id })
|
|
3074
|
+
return
|
|
2937
3075
|
case "claude.history.request": {
|
|
2938
3076
|
// Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
|
|
2939
3077
|
// 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。
|