@cocorograph/hub-agent 0.7.36 → 0.7.39
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 +83 -0
- package/src/codex-stream-bridge.mjs +72 -1
- package/src/main.mjs +135 -17
- package/src/sibling-inject.mjs +150 -0
- package/src/sibling-token.mjs +62 -0
- package/src/usage.mjs +120 -0
package/package.json
CHANGED
|
@@ -31,6 +31,25 @@ import {
|
|
|
31
31
|
createEventMasker,
|
|
32
32
|
maskJsonlFileAtRest,
|
|
33
33
|
} from "./tool-result-mask.mjs"
|
|
34
|
+
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
|
|
38
|
+
* この cwd の capability トークンを乗せる。ディスクには書かず、プロセス環境変数
|
|
39
|
+
* としてのみ渡す (src/sibling-token.mjs のコメント参照)。
|
|
40
|
+
* @param {string} cwd
|
|
41
|
+
* @returns {{COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string}}
|
|
42
|
+
*/
|
|
43
|
+
function siblingEnvFor(cwd) {
|
|
44
|
+
return {
|
|
45
|
+
...process.env,
|
|
46
|
+
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
47
|
+
COCKPIT_SIBLING_CWD: cwd,
|
|
48
|
+
// ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
|
|
49
|
+
// 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
|
|
50
|
+
COCKPIT_SIBLING_SELF_KIND: "claude",
|
|
51
|
+
}
|
|
52
|
+
}
|
|
34
53
|
|
|
35
54
|
/** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
|
|
36
55
|
* かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
|
|
@@ -709,6 +728,7 @@ class ClaudeStreamSession {
|
|
|
709
728
|
|
|
710
729
|
const options = {
|
|
711
730
|
cwd: this.cwd,
|
|
731
|
+
env: siblingEnvFor(this.cwd),
|
|
712
732
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
713
733
|
includePartialMessages: true,
|
|
714
734
|
abortController: this._abortController,
|
|
@@ -1017,6 +1037,7 @@ class ClaudeStreamSession {
|
|
|
1017
1037
|
async _runResidentQuery() {
|
|
1018
1038
|
const options = {
|
|
1019
1039
|
cwd: this.cwd,
|
|
1040
|
+
env: siblingEnvFor(this.cwd),
|
|
1020
1041
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
1021
1042
|
includePartialMessages: true,
|
|
1022
1043
|
}
|
|
@@ -1134,6 +1155,24 @@ class ClaudeStreamSession {
|
|
|
1134
1155
|
/* ignore */
|
|
1135
1156
|
}
|
|
1136
1157
|
this._busy = false
|
|
1158
|
+
// T05699 残タスク #2-1 (2026-07-04): interrupt() は現ターンの result を発火しない
|
|
1159
|
+
// (query は継続、次 input 受付継続のみ)。そのため abort 時点で _pendingMessages に
|
|
1160
|
+
// 溜まっている送信待ちメッセージは、result 経由の drain (_runResidentQuery 内の
|
|
1161
|
+
// result ハンドラ) を永久に迎えられず、_busy=false のまま取り残されていた。この
|
|
1162
|
+
// 状態で直後に次の sendMessage が届くと (_busy===false のため) pending をすっ飛ばして
|
|
1163
|
+
// _inputQueue へ直接 push してしまい、先に積んだメッセージより後着メッセージが
|
|
1164
|
+
// 先に処理される FIFO 順序崩れが起きる。ここで明示的に drain し、pending があれば
|
|
1165
|
+
// 同一 query 内で継続処理させる (無ければ _emitQueueState() のみで no-op)。
|
|
1166
|
+
// _drainResidentPending は async だが最初の await 前に _busy=true を同期的に
|
|
1167
|
+
// セットするため、この関数の呼び出し (await しない fire-and-forget) が完了した
|
|
1168
|
+
// 時点 (= abortTurn() の同期実行が終わるまでの同一 tick 内) で以降の sendMessage は
|
|
1169
|
+
// 正しく busy 状態を観測できる。
|
|
1170
|
+
this._drainResidentPending().catch((err) =>
|
|
1171
|
+
this.logger?.warn(
|
|
1172
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
1173
|
+
"drainResidentPending (abort) threw",
|
|
1174
|
+
),
|
|
1175
|
+
)
|
|
1137
1176
|
return
|
|
1138
1177
|
}
|
|
1139
1178
|
if (this._abortController) {
|
|
@@ -1660,6 +1699,50 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1660
1699
|
return best
|
|
1661
1700
|
}
|
|
1662
1701
|
|
|
1702
|
+
/**
|
|
1703
|
+
* 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
|
|
1704
|
+
* 2026-07-04)。同一マシン内の別プロセス (ask-sibling スキル経由の cockpit_orch.py)
|
|
1705
|
+
* が、Cockpit ブラウザの WS 接続を経由せず、hub-agent デーモンへローカル IPC
|
|
1706
|
+
* (src/sibling-inject.mjs) 経由でこの cwd の生きているチャットセッションへ新規
|
|
1707
|
+
* ターンを開始させる。liveSessionIdForCwd と同じ走査ロジックだが、返すのは
|
|
1708
|
+
* sessionId 文字列ではなく session オブジェクト自体 (sendMessage を直接呼べるように
|
|
1709
|
+
* するため)。同一 cwd に複数ライブセッションがある場合は最終アクティビティが最も
|
|
1710
|
+
* 新しいものを採用する。
|
|
1711
|
+
*
|
|
1712
|
+
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
1713
|
+
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
1714
|
+
*
|
|
1715
|
+
* @param {string} cwd
|
|
1716
|
+
* @param {string} text
|
|
1717
|
+
* @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
|
|
1718
|
+
*/
|
|
1719
|
+
sendToCwd(cwd, text) {
|
|
1720
|
+
if (!cwd || !text) return false
|
|
1721
|
+
let best = null
|
|
1722
|
+
let bestAt = -1
|
|
1723
|
+
const seen = new Set()
|
|
1724
|
+
for (const session of this.sessions.values()) {
|
|
1725
|
+
if (seen.has(session) || session.cwd !== cwd) continue
|
|
1726
|
+
seen.add(session)
|
|
1727
|
+
let at = 0
|
|
1728
|
+
for (const t of session.lastActivityByStream.values()) {
|
|
1729
|
+
if (t > at) at = t
|
|
1730
|
+
}
|
|
1731
|
+
if (at >= bestAt) {
|
|
1732
|
+
bestAt = at
|
|
1733
|
+
best = session
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
if (!best) return false
|
|
1737
|
+
best.sendMessage({ role: "user", content: text }).catch((err) => {
|
|
1738
|
+
this.logger?.error(
|
|
1739
|
+
{ cwd, err: err?.message },
|
|
1740
|
+
"sendToCwd: sendMessage threw unexpectedly",
|
|
1741
|
+
)
|
|
1742
|
+
})
|
|
1743
|
+
return true
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1663
1746
|
/**
|
|
1664
1747
|
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
1665
1748
|
* サーバーを `needs-auth` として補完する。
|
|
@@ -34,6 +34,26 @@
|
|
|
34
34
|
import { EventEmitter } from "node:events"
|
|
35
35
|
|
|
36
36
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
37
|
+
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
|
|
41
|
+
* 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
|
|
42
|
+
* プロセス環境変数としてのみ渡す (src/sibling-token.mjs のコメント参照、
|
|
43
|
+
* claude-stream-bridge.mjs の同名ヘルパーと対)。
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
* @returns {NodeJS.ProcessEnv}
|
|
46
|
+
*/
|
|
47
|
+
function siblingEnvFor(cwd) {
|
|
48
|
+
return {
|
|
49
|
+
...process.env,
|
|
50
|
+
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
51
|
+
COCKPIT_SIBLING_CWD: cwd,
|
|
52
|
+
// ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
|
|
53
|
+
// 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
|
|
54
|
+
COCKPIT_SIBLING_SELF_KIND: "codex",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
// detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
|
|
39
59
|
// Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
|
|
@@ -89,7 +109,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
89
109
|
if (this.sessions.has(stream_id)) {
|
|
90
110
|
throw new Error(`stream_id already attached: ${stream_id}`)
|
|
91
111
|
}
|
|
92
|
-
const client = this._clientFactory({
|
|
112
|
+
const client = this._clientFactory({
|
|
113
|
+
cwd,
|
|
114
|
+
codexCmd: this.codexCmd,
|
|
115
|
+
logger: this.logger,
|
|
116
|
+
env: siblingEnvFor(cwd),
|
|
117
|
+
})
|
|
93
118
|
const session = {
|
|
94
119
|
stream_id,
|
|
95
120
|
cwd,
|
|
@@ -300,6 +325,52 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
300
325
|
this._maybeReapAfterIdle(session)
|
|
301
326
|
}
|
|
302
327
|
|
|
328
|
+
/**
|
|
329
|
+
* 指定 cwd に一致するライブ (attach 済み) codex セッションの threadId を返す。
|
|
330
|
+
* Claude 版 `liveSessionIdForCwd` の対 (main.mjs の state loop / terminal mirror から
|
|
331
|
+
* cwd → threadId 解決に使う)。複数一致時は先に見つかったものを返す (5h/7d はアカウント
|
|
332
|
+
* 全体の値のためどのセッションから読んでも同じ契約の残量、代表 1 件で十分)。
|
|
333
|
+
*
|
|
334
|
+
* @param {string} cwd
|
|
335
|
+
* @returns {string|null}
|
|
336
|
+
*/
|
|
337
|
+
threadIdForCwd(cwd) {
|
|
338
|
+
if (!cwd) return null
|
|
339
|
+
for (const session of this.sessions.values()) {
|
|
340
|
+
if (session.cwd === cwd && session.threadId) return session.threadId
|
|
341
|
+
}
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
|
|
347
|
+
* 2026-07-04)。Claude 版 `sendToCwd` の対。同一 cwd に複数ライブセッションがある
|
|
348
|
+
* 場合は `threadIdForCwd` と同じく先に見つかったものを使う。
|
|
349
|
+
*
|
|
350
|
+
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
351
|
+
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
352
|
+
*
|
|
353
|
+
* @param {string} cwd
|
|
354
|
+
* @param {string} text
|
|
355
|
+
* @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
|
|
356
|
+
*/
|
|
357
|
+
sendToCwd(cwd, text) {
|
|
358
|
+
if (!cwd || !text) return false
|
|
359
|
+
for (const session of this.sessions.values()) {
|
|
360
|
+
if (session.cwd !== cwd) continue
|
|
361
|
+
this.input({ stream_id: session.stream_id, message: { role: "user", content: text } }).catch(
|
|
362
|
+
(err) => {
|
|
363
|
+
this.logger?.error(
|
|
364
|
+
{ cwd, err: err?.message },
|
|
365
|
+
"sendToCwd: input threw unexpectedly",
|
|
366
|
+
)
|
|
367
|
+
},
|
|
368
|
+
)
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
return false
|
|
372
|
+
}
|
|
373
|
+
|
|
303
374
|
_maybeReapAfterIdle(session) {
|
|
304
375
|
if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
|
|
305
376
|
this._clearDetachTimer(session)
|
package/src/main.mjs
CHANGED
|
@@ -93,9 +93,11 @@ import {
|
|
|
93
93
|
import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
|
|
94
94
|
import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
|
|
95
95
|
import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
|
|
96
|
+
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
96
97
|
import {
|
|
97
98
|
boundSessionId,
|
|
98
99
|
codexSessionUsages,
|
|
100
|
+
codexTurnActiveForThread,
|
|
99
101
|
codexUsageForThread,
|
|
100
102
|
contextWindowSize,
|
|
101
103
|
getSessionUsages,
|
|
@@ -766,6 +768,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
766
768
|
logger,
|
|
767
769
|
intervalMs: 5_000,
|
|
768
770
|
claudeBridge,
|
|
771
|
+
// T05699 残タスク #1 (2026-07-04): 背景 Codex チャットセッションの turn_active を
|
|
772
|
+
// rollout ベースで解決するために渡す (main.mjs startStateLoop 内の codexBridge.threadIdForCwd
|
|
773
|
+
// 参照)。
|
|
774
|
+
codexBridge,
|
|
769
775
|
readinessTracker,
|
|
770
776
|
// tmux session_name → bind 中の session_id。turn_active を「そのペインが実際に走らせている
|
|
771
777
|
// セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
|
|
@@ -863,6 +869,14 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
863
869
|
await tuiViewerRegistry.start()
|
|
864
870
|
ctx.tuiViewerRegistry = tuiViewerRegistry
|
|
865
871
|
|
|
872
|
+
// sibling-inject (ask-sibling スキル, 2026-07-04): 同一 cwd の claude⇄codex
|
|
873
|
+
// 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
|
|
874
|
+
// (src/sibling-token.mjs) で検証した要求のみ claudeBridge/codexBridge.sendToCwd()
|
|
875
|
+
// へ橋渡しする。
|
|
876
|
+
const siblingInjectBridge = new SiblingInjectBridge({ claudeBridge, codexBridge, logger })
|
|
877
|
+
await siblingInjectBridge.start()
|
|
878
|
+
ctx.siblingInjectBridge = siblingInjectBridge
|
|
879
|
+
|
|
866
880
|
// jsonl ライブ追従 (T04784 逐次表示): TUI チャットの閲覧ハートビートを起点に対象
|
|
867
881
|
// session の jsonl を tail し、追記行を claude.jsonl.event として逐次 push する。
|
|
868
882
|
// これで session.event 合図 → 全体再 hydrate + 2.5s ポーリングの「どさっと」を廃せる。
|
|
@@ -921,6 +935,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
921
935
|
tuiPermissionBridge.stop()
|
|
922
936
|
tuiViewerRegistry.stop()
|
|
923
937
|
jsonlLiveWatchers.stop()
|
|
938
|
+
siblingInjectBridge.stop()
|
|
924
939
|
ptyBridge.shutdown()
|
|
925
940
|
claudeBridge?.shutdown?.()
|
|
926
941
|
codexBridge?.shutdown?.()
|
|
@@ -1425,7 +1440,31 @@ export function resolveStateLoopBoundSessionId(boundSessions, sessionName, cwd,
|
|
|
1425
1440
|
return null
|
|
1426
1441
|
}
|
|
1427
1442
|
|
|
1428
|
-
|
|
1443
|
+
/**
|
|
1444
|
+
* turn_active をどの情報源で判定するかを決める純関数 (main.mjs startStateLoop から抽出、
|
|
1445
|
+
* T05699 残タスク #1)。実際の非同期解決 (turnActiveForCwd / codexTurnActiveForThread の
|
|
1446
|
+
* 呼び出し) は呼び出し側 (tick) が行う。優先順位:
|
|
1447
|
+
*
|
|
1448
|
+
* 1. chat 信号が processing → "chat-active" (true)
|
|
1449
|
+
* 2. chat 信号が waiting/idle → "chat-inactive" (false)
|
|
1450
|
+
* 3. fresh unbound bind (adopt 前の bind プレースホルダ) → "fresh-unbound" (null=不明、安全側)
|
|
1451
|
+
* 4. cwd に一致するライブ codexBridge セッションがある → "codex" (rollout ベース判定)
|
|
1452
|
+
* 5. cwd があれば → "claude" (jsonl ベース判定、TUI/bound session フォールバック)
|
|
1453
|
+
* 6. cwd も無ければ → "none" (null=不明)
|
|
1454
|
+
*
|
|
1455
|
+
* @param {{chatStatus?: string|null, cwd?: string|null, isFreshUnbound?: boolean, codexThreadId?: string|null}} params
|
|
1456
|
+
* @returns {'chat-active'|'chat-inactive'|'fresh-unbound'|'codex'|'claude'|'none'}
|
|
1457
|
+
*/
|
|
1458
|
+
export function decideTurnActiveSource({ chatStatus, cwd, isFreshUnbound, codexThreadId }) {
|
|
1459
|
+
if (chatStatus === "processing") return "chat-active"
|
|
1460
|
+
if (chatStatus === "waiting" || chatStatus === "idle") return "chat-inactive"
|
|
1461
|
+
if (cwd && isFreshUnbound) return "fresh-unbound"
|
|
1462
|
+
if (codexThreadId) return "codex"
|
|
1463
|
+
if (cwd) return "claude"
|
|
1464
|
+
return "none"
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, codexBridge, readinessTracker, boundSessions }) {
|
|
1429
1468
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
1430
1469
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
1431
1470
|
// (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
|
|
@@ -1553,23 +1592,50 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1553
1592
|
// null を返し mtime 最新 jsonl へフォールバックしていた (chat 版の cross-activity
|
|
1554
1593
|
// 汚染, 2026-07 発覚)。resolveStateLoopBoundSessionId が claudeBridge のライブ
|
|
1555
1594
|
// セッション id へフォールバックすることで TUI と同じ方式で根治する。
|
|
1595
|
+
// - Codex (T05699 残タスク #1, 2026-07-04): この cwd にライブな codexBridge セッション
|
|
1596
|
+
// (chat モード) があれば、chat 信号の 90s TTL に依存しない rollout ベースの判定
|
|
1597
|
+
// (codexTurnActiveForThread, task_started/task_complete イベント対) を使う。長時間
|
|
1598
|
+
// ターン中に chat 信号が stale 化してドットが消える欠落を根治する。Claude 版のような
|
|
1599
|
+
// paneAliveProbe は渡さない (chat モードのペインは素のシェルのまま = 実処理は別
|
|
1600
|
+
// プロセスのため、pane 生存判定を適用すると誤って dead 扱いになる)。cwd がライブな
|
|
1601
|
+
// codex セッションと一致しなければ従来通り Claude jsonl (turnActiveForCwd) にフォール
|
|
1602
|
+
// バックする (TUI/bare codex pane は現状 bound thread 解決が無いため、これまで通り
|
|
1603
|
+
// null=不明のまま、退行はしない)。
|
|
1604
|
+
// 分岐の優先順位は decideTurnActiveSource (純関数、単体テスト済み) が権威。
|
|
1556
1605
|
let turnActive = null
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1606
|
+
const codexThreadId = s.cwd ? codexBridge?.threadIdForCwd?.(s.cwd) || null : null
|
|
1607
|
+
const turnActiveSource = decideTurnActiveSource({
|
|
1608
|
+
chatStatus: chat?.status,
|
|
1609
|
+
cwd: s.cwd,
|
|
1610
|
+
isFreshUnbound: isFreshUnboundBind(boundSessions?.get(s.session_name)),
|
|
1611
|
+
codexThreadId,
|
|
1612
|
+
})
|
|
1613
|
+
switch (turnActiveSource) {
|
|
1614
|
+
case "chat-active":
|
|
1615
|
+
turnActive = true
|
|
1616
|
+
break
|
|
1617
|
+
case "chat-inactive":
|
|
1618
|
+
turnActive = false
|
|
1619
|
+
break
|
|
1620
|
+
case "fresh-unbound":
|
|
1621
|
+
// 症状2b 根治: fresh bind プレースホルダの間は、別アクティビティ jsonl を mtime 最新で
|
|
1622
|
+
// 取り違える汚染を避けて turn_active を出さない (null=不明)。実セッション id へ adopt
|
|
1623
|
+
// されれば下の通常経路に乗る。青を出さない安全側にのみ倒すため固着青は生まない。
|
|
1624
|
+
turnActive = null
|
|
1625
|
+
break
|
|
1626
|
+
case "codex":
|
|
1627
|
+
turnActive = await codexTurnActiveForThread(codexThreadId)
|
|
1628
|
+
break
|
|
1629
|
+
case "claude":
|
|
1630
|
+
turnActive = await turnActiveForCwd(
|
|
1631
|
+
s.cwd,
|
|
1632
|
+
resolveStateLoopBoundSessionId(boundSessions, s.session_name, s.cwd, claudeBridge),
|
|
1633
|
+
s.session_name,
|
|
1634
|
+
{ paneAliveProbe: paneClaudeAliveOrUnknown },
|
|
1635
|
+
)
|
|
1636
|
+
break
|
|
1637
|
+
default:
|
|
1638
|
+
turnActive = null
|
|
1573
1639
|
}
|
|
1574
1640
|
|
|
1575
1641
|
const prev = lastByName.get(s.session_name)
|
|
@@ -3243,6 +3309,58 @@ async function dispatch(msg, ctx) {
|
|
|
3243
3309
|
}
|
|
3244
3310
|
return
|
|
3245
3311
|
}
|
|
3312
|
+
case "codex.upload": {
|
|
3313
|
+
// B4 (T05699 残タスク, 2026-07-04): Cockpit Codexチャットの添付ファイル受信
|
|
3314
|
+
// (チャンク1件)。claude.upload と同じ UploadManager を再利用する (cwd ベースで
|
|
3315
|
+
// CLI 非依存の実装のため無改修で流用可能)。全チャンク到着で cwd 配下の
|
|
3316
|
+
// .cockpit-uploads/<日付>/ に保存し、保存パスを browser へ返す。browser は
|
|
3317
|
+
// そのパスを codex.input 本文に載せ、Codex がシェル/ファイルツールで読む
|
|
3318
|
+
// (Claude 版と同じ「パス参照方式」、Codex 側の model/effort 分離と同様に
|
|
3319
|
+
// アカウント/プロトコルが違うだけでファイル保存の仕組みは共有する)。
|
|
3320
|
+
if (!ctx.uploadManager) {
|
|
3321
|
+
ctx.client.send({
|
|
3322
|
+
type: "codex.upload.done",
|
|
3323
|
+
stream_id: msg.stream_id,
|
|
3324
|
+
upload_id: msg.upload_id,
|
|
3325
|
+
ok: false,
|
|
3326
|
+
error: "upload_unavailable",
|
|
3327
|
+
})
|
|
3328
|
+
return
|
|
3329
|
+
}
|
|
3330
|
+
try {
|
|
3331
|
+
const res = await ctx.uploadManager.addChunk({
|
|
3332
|
+
upload_id: msg.upload_id,
|
|
3333
|
+
filename: msg.filename,
|
|
3334
|
+
media_type: msg.media_type,
|
|
3335
|
+
total_size: msg.total_size,
|
|
3336
|
+
total_chunks: msg.total_chunks,
|
|
3337
|
+
chunk_index: msg.chunk_index,
|
|
3338
|
+
data: msg.data,
|
|
3339
|
+
cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
|
|
3340
|
+
})
|
|
3341
|
+
if (res.done) {
|
|
3342
|
+
ctx.client.send({
|
|
3343
|
+
type: "codex.upload.done",
|
|
3344
|
+
stream_id: msg.stream_id,
|
|
3345
|
+
upload_id: msg.upload_id,
|
|
3346
|
+
ok: true,
|
|
3347
|
+
path: res.path,
|
|
3348
|
+
filename: res.filename,
|
|
3349
|
+
media_type: res.media_type,
|
|
3350
|
+
size: res.size,
|
|
3351
|
+
})
|
|
3352
|
+
}
|
|
3353
|
+
} catch (err) {
|
|
3354
|
+
ctx.client.send({
|
|
3355
|
+
type: "codex.upload.done",
|
|
3356
|
+
stream_id: msg.stream_id,
|
|
3357
|
+
upload_id: msg.upload_id,
|
|
3358
|
+
ok: false,
|
|
3359
|
+
error: err?.message || String(err),
|
|
3360
|
+
})
|
|
3361
|
+
}
|
|
3362
|
+
return
|
|
3363
|
+
}
|
|
3246
3364
|
case "codex.input":
|
|
3247
3365
|
// model/effort: turn/start (非 busy 時) にのみ反映される override。turn/steer
|
|
3248
3366
|
// (busy 時) は公式スキーマ上 model/effort 非対応のため、その場合は
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sibling-inject (同一 cwd の claude⇄codex 相互依頼) の受信ブリッジ (2026-07-04)。
|
|
3
|
+
*
|
|
4
|
+
* ask-sibling スキル経由の `cockpit_orch.py` (Cockpit ブラウザの WS 接続を持たない、
|
|
5
|
+
* Bash tool 経由で動く別プロセス) が、同一マシン上で稼働中の hub-agent デーモンへ
|
|
6
|
+
* 「この cwd の生きているチャットセッション (claude/codex) へ新規ターンを注入して
|
|
7
|
+
* ほしい」と頼むための受信口。
|
|
8
|
+
*
|
|
9
|
+
* 設計 (WHY): hub-agent はローカル HTTP/Unix ソケットサーバを持たないため、既存の
|
|
10
|
+
* ファイル IPC 前例 (tui-permission-bridge.mjs 等、`/tmp/cockpit_*` + fs.watch +
|
|
11
|
+
* atomic write) と同じパターンに揃える。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ セキュリティ設計 (2026-07-04 セッションでユーザー承認済み): この機構は
|
|
14
|
+
* 「生きている自律エージェント (シェル/API/MCP ツールへのフルアクセス権を持つ)
|
|
15
|
+
* へ外部プロセスから新規指示を注入する」という強い書き込み権限を持つため、
|
|
16
|
+
* `src/sibling-token.mjs` の cwd スコープ capability トークンによる検証を必須とする。
|
|
17
|
+
* トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
|
|
18
|
+
*
|
|
19
|
+
* 契約: request file `<request_id>.json` = `{cwd, target_cli_kind, text, token}`。
|
|
20
|
+
* 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
|
|
21
|
+
* `<request_id>.result.json` = `{ok, error?}` を書く。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { watch } from "node:fs"
|
|
25
|
+
import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
26
|
+
import path from "node:path"
|
|
27
|
+
|
|
28
|
+
import { verifySiblingToken } from "./sibling-token.mjs"
|
|
29
|
+
|
|
30
|
+
export const SIBLING_REQUESTS_DIR =
|
|
31
|
+
process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
|
|
32
|
+
|
|
33
|
+
export class SiblingInjectBridge {
|
|
34
|
+
/**
|
|
35
|
+
* @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
|
|
36
|
+
*/
|
|
37
|
+
constructor({ dir = SIBLING_REQUESTS_DIR, claudeBridge, codexBridge, logger } = {}) {
|
|
38
|
+
this.dir = dir
|
|
39
|
+
this.claudeBridge = claudeBridge
|
|
40
|
+
this.codexBridge = codexBridge
|
|
41
|
+
this.logger = logger
|
|
42
|
+
/** @type {Set<string>} 二重処理防止 (request_id) */
|
|
43
|
+
this._seen = new Set()
|
|
44
|
+
this._watcher = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
|
|
48
|
+
async start() {
|
|
49
|
+
await mkdir(this.dir, { recursive: true })
|
|
50
|
+
await this._sweep()
|
|
51
|
+
try {
|
|
52
|
+
this._watcher = watch(this.dir, (_event, filename) => {
|
|
53
|
+
if (filename && filename.endsWith(".json") && !filename.endsWith(".result.json")) {
|
|
54
|
+
this._ingest(filename).catch((err) => {
|
|
55
|
+
this.logger?.warn({ err: err?.message, filename }, "sibling-inject ingest failed")
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
} catch (err) {
|
|
60
|
+
this.logger?.warn(
|
|
61
|
+
{ err: err?.message },
|
|
62
|
+
"sibling-inject watcher: fs.watch failed, watcher disabled",
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
|
|
68
|
+
async _sweep() {
|
|
69
|
+
let names = []
|
|
70
|
+
try {
|
|
71
|
+
names = await readdir(this.dir)
|
|
72
|
+
} catch {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
for (const n of names) {
|
|
76
|
+
if (n.endsWith(".json") && !n.endsWith(".result.json")) {
|
|
77
|
+
await this._ingest(n).catch((err) => {
|
|
78
|
+
this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
|
|
86
|
+
* 成否によらず要求ファイルは削除し、結果ファイルを書く。
|
|
87
|
+
* @param {string} filename `<request_id>.json`
|
|
88
|
+
*/
|
|
89
|
+
async _ingest(filename) {
|
|
90
|
+
const request_id = filename.slice(0, -".json".length)
|
|
91
|
+
if (this._seen.has(request_id)) return
|
|
92
|
+
this._seen.add(request_id)
|
|
93
|
+
const fp = path.join(this.dir, filename)
|
|
94
|
+
let body
|
|
95
|
+
try {
|
|
96
|
+
body = JSON.parse(await readFile(fp, "utf-8"))
|
|
97
|
+
} catch {
|
|
98
|
+
// partial write / 既に消えた → 次の watch イベントで拾い直す (seen から外す)。
|
|
99
|
+
this._seen.delete(request_id)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { cwd, target_cli_kind, text, token } = body || {}
|
|
104
|
+
let result
|
|
105
|
+
if (!cwd || !text || (target_cli_kind !== "claude" && target_cli_kind !== "codex")) {
|
|
106
|
+
result = { ok: false, error: "invalid_request" }
|
|
107
|
+
} else if (!verifySiblingToken(cwd, token)) {
|
|
108
|
+
this.logger?.warn({ cwd, request_id }, "sibling-inject: token mismatch, request denied")
|
|
109
|
+
result = { ok: false, error: "invalid_token" }
|
|
110
|
+
} else {
|
|
111
|
+
const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
112
|
+
const sent = bridge?.sendToCwd?.(cwd, text) ?? false
|
|
113
|
+
result = sent
|
|
114
|
+
? { ok: true }
|
|
115
|
+
: { ok: false, error: "no_live_session_for_cwd" }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await this._writeResult(request_id, result)
|
|
119
|
+
// 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
|
|
120
|
+
try {
|
|
121
|
+
await unlink(fp)
|
|
122
|
+
} catch {
|
|
123
|
+
/* 既に無ければ no-op */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
|
|
128
|
+
async _writeResult(request_id, result) {
|
|
129
|
+
const fp = path.join(this.dir, `${request_id}.result.json`)
|
|
130
|
+
const tmp = `${fp}.tmp`
|
|
131
|
+
try {
|
|
132
|
+
await writeFile(tmp, JSON.stringify(result))
|
|
133
|
+
await rename(tmp, fp)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
this.logger?.warn({ err: err?.message, request_id }, "sibling-inject result write failed")
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** watcher を閉じる。 */
|
|
140
|
+
stop() {
|
|
141
|
+
try {
|
|
142
|
+
this._watcher?.close()
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
this._watcher = null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default SiblingInjectBridge
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sibling-inject (同一 cwd の claude⇄codex 相互依頼) 用の cwd スコープ capability
|
|
3
|
+
* トークンストア (2026-07-04)。
|
|
4
|
+
*
|
|
5
|
+
* 設計の背景 (WHY):
|
|
6
|
+
* hub-agent はローカル HTTP/Unix ソケットサーバを持たず、既存のローカル IPC は
|
|
7
|
+
* すべて `/tmp/cockpit_*` 配下のファイルベース (tui-permission-bridge.mjs 等) で、
|
|
8
|
+
* 認証機構を持たない (同一 UID なら誰でも読み書きできる想定)。
|
|
9
|
+
*
|
|
10
|
+
* sibling-inject はこの上に「生きているチャットセッションへ新規ターンを注入する」
|
|
11
|
+
* という強い書き込み権限を追加するため、無認証のままだと「同一マシン上の無関係な
|
|
12
|
+
* 別プロセス (npm postinstall スクリプト等) が自律エージェント (シェル/API/MCP
|
|
13
|
+
* ツールへのフルアクセス権を持つ) へ任意の指示を注入できる」という新しい攻撃面に
|
|
14
|
+
* なる (2026-07-04 セッションで実装時に確認済みの懸念、ユーザー承認済み)。
|
|
15
|
+
*
|
|
16
|
+
* 対策: cwd ごとにランダムな capability トークンを発行し、hub-agent がそのセッション
|
|
17
|
+
* (claude/codex いずれも) を spawn する際に **そのプロセス自身の環境変数にのみ**
|
|
18
|
+
* 渡す (`COCKPIT_SIBLING_TOKEN`、ディスクには書かない)。sibling-inject の受信側は
|
|
19
|
+
* このトークンが一致した場合のみ注入を実行する。これにより「/tmp を書き換えられる
|
|
20
|
+
* 全プロセス」ではなく「そのセッション自身の環境変数を読めるプロセス (= 既に
|
|
21
|
+
* hub-agent が spawn した agent 自身、またはその子プロセス)」だけが注入できる設計に
|
|
22
|
+
* 絞り込める。Hub ユーザートークン (agent 全体を認証する、より広い権限を持つ鍵) を
|
|
23
|
+
* 転用しない理由: ディスク保存されるため同じ攻撃者から結局読める上、漏洩時の被害
|
|
24
|
+
* 範囲がこの cwd 限定トークンより大きくなるため。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomBytes } from "node:crypto"
|
|
28
|
+
|
|
29
|
+
/** @type {Map<string, string>} cwd -> token */
|
|
30
|
+
const tokensByCwd = new Map()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 指定 cwd の capability トークンを返す (無ければ新規発行)。
|
|
34
|
+
* @param {string} cwd
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function getOrCreateSiblingToken(cwd) {
|
|
38
|
+
if (!cwd) throw new Error("getOrCreateSiblingToken: cwd is required")
|
|
39
|
+
let token = tokensByCwd.get(cwd)
|
|
40
|
+
if (!token) {
|
|
41
|
+
token = randomBytes(32).toString("hex")
|
|
42
|
+
tokensByCwd.set(cwd, token)
|
|
43
|
+
}
|
|
44
|
+
return token
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 指定 cwd に対して提示されたトークンが正しいか検証する。
|
|
49
|
+
* @param {string} cwd
|
|
50
|
+
* @param {string} token
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function verifySiblingToken(cwd, token) {
|
|
54
|
+
if (!cwd || !token) return false
|
|
55
|
+
const expected = tokensByCwd.get(cwd)
|
|
56
|
+
return typeof expected === "string" && expected === token
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** テスト用: トークンストアを全クリアする。 */
|
|
60
|
+
export function _resetSiblingTokens() {
|
|
61
|
+
tokensByCwd.clear()
|
|
62
|
+
}
|
package/src/usage.mjs
CHANGED
|
@@ -1450,3 +1450,123 @@ export async function codexSessionUsages(liveSessions) {
|
|
|
1450
1450
|
)
|
|
1451
1451
|
return out
|
|
1452
1452
|
}
|
|
1453
|
+
|
|
1454
|
+
// ---------------------------------------------------------------------------
|
|
1455
|
+
// Codex 版 turn_active 判定 (Cockpit Codex 両対応 T05699 残タスク #1, 2026-07-04)。
|
|
1456
|
+
//
|
|
1457
|
+
// 背景: サイドバー背景セッションのステータスドット (turn_active) は state loop
|
|
1458
|
+
// (main.mjs startStateLoop) が権威。Codex はこれまで in-memory チャット信号
|
|
1459
|
+
// (chat-signals.mjs, TTL 90s) だけに依存しており、長時間ターン中に 90s 無通知の
|
|
1460
|
+
// 間隔が空くと stale 化しドットが消える欠落があった (main.mjs:1556-1573 の
|
|
1461
|
+
// else-if 分岐は Claude jsonl 専用 turnActiveForCwd しか知らない)。
|
|
1462
|
+
//
|
|
1463
|
+
// 実機の rollout jsonl (2026-07-04, 実データ 2444 ファイル走査) で `event_msg` の
|
|
1464
|
+
// `task_started`/`task_complete` が turn の開始/終了を示す明確なペアであることを
|
|
1465
|
+
// 確認済み (全ファイルで started==completed の対応が崩れた例は無し)。B3
|
|
1466
|
+
// (codexUsageForThread) と同じ「threadId → rollout ファイル特定 → 末尾 tail 走査」
|
|
1467
|
+
// パターンを踏襲し、チャット信号の TTL に依存しない独立した真実源にする。
|
|
1468
|
+
// ---------------------------------------------------------------------------
|
|
1469
|
+
|
|
1470
|
+
// task_started の記録から task_complete が届かないまま経過した場合の猶予。TUI (bare pane)
|
|
1471
|
+
// 経由の bound thread 解決は未対応 (2026-07-04 時点、`codex.tui.bind` 相当が無い) のため、
|
|
1472
|
+
// 現状 paneAliveProbe を渡すのは将来の TUI 対応時のみ想定。チャットモード (app-server 駆動)
|
|
1473
|
+
// はペインが素のシェルのまま (実処理は別プロセス) なので pane 生存判定の対象外。
|
|
1474
|
+
const CODEX_STALE_TASK_MS = 5_000
|
|
1475
|
+
|
|
1476
|
+
/** P5(perf): codex turn_active 判定結果を mtime+size でメモ化。 */
|
|
1477
|
+
const _codexTurnActiveMemo = new Map() // fp → { mtimeMs, size, result }
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* rollout jsonl テキストを走査して「最終 turn_active 状態 + 直近 task_started の時刻」を
|
|
1481
|
+
* 返す内部ヘルパー。Claude の `_scanTurnState` と対だが、Codex は `task_started`/
|
|
1482
|
+
* `task_complete` という明示イベントペアがあるため stop_reason 解釈のステートマシンは不要。
|
|
1483
|
+
*
|
|
1484
|
+
* @param {string} text
|
|
1485
|
+
* @returns {{active: boolean, activeSinceTs: number|null}}
|
|
1486
|
+
*/
|
|
1487
|
+
export function _scanCodexTurnState(text) {
|
|
1488
|
+
const empty = { active: false, activeSinceTs: null }
|
|
1489
|
+
if (!text) return empty
|
|
1490
|
+
let active = false
|
|
1491
|
+
let activeSinceTs = null
|
|
1492
|
+
const lines = text.split("\n")
|
|
1493
|
+
for (const line of lines) {
|
|
1494
|
+
if (!line) continue
|
|
1495
|
+
let d
|
|
1496
|
+
try {
|
|
1497
|
+
d = JSON.parse(line)
|
|
1498
|
+
} catch {
|
|
1499
|
+
continue
|
|
1500
|
+
}
|
|
1501
|
+
if (d.type !== "event_msg") continue
|
|
1502
|
+
const pt = d.payload?.type
|
|
1503
|
+
if (pt === "task_started") {
|
|
1504
|
+
active = true
|
|
1505
|
+
activeSinceTs = parseEntryTs(d.timestamp)
|
|
1506
|
+
} else if (pt === "task_complete") {
|
|
1507
|
+
active = false
|
|
1508
|
+
activeSinceTs = null
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return { active, activeSinceTs }
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* 指定 threadId の Codex セッションが現在ターン進行中かを rollout jsonl から判定する。
|
|
1516
|
+
* `turnActiveForCwd` (Claude) の対。チャット信号 (in-memory, TTL 90s) が stale なときの
|
|
1517
|
+
* フォールバックとして main.mjs の state loop から呼ばれる想定。
|
|
1518
|
+
*
|
|
1519
|
+
* @param {string} threadId
|
|
1520
|
+
* @param {{sessionName?: string, paneAliveProbe?: (name:string)=>Promise<'alive'|'dead'|'unknown'>, now?: number}} [opts]
|
|
1521
|
+
* sessionName/paneAliveProbe: 将来の TUI (bare pane) 対応用。チャットモードはペインが
|
|
1522
|
+
* 素のシェルのまま (実処理は別プロセス) のため渡さないこと (誤って dead 判定され得る)。
|
|
1523
|
+
* @returns {Promise<boolean|null>} true=生成中 / false=待機中 / null=rollout 不明
|
|
1524
|
+
*/
|
|
1525
|
+
export async function codexTurnActiveForThread(threadId, opts = {}) {
|
|
1526
|
+
if (!threadId) return null
|
|
1527
|
+
const fp = await findCodexRolloutFile(threadId)
|
|
1528
|
+
if (!fp) return null
|
|
1529
|
+
let st
|
|
1530
|
+
try {
|
|
1531
|
+
st = await fs.stat(fp)
|
|
1532
|
+
} catch {
|
|
1533
|
+
return null
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const memo = _codexTurnActiveMemo.get(fp)
|
|
1537
|
+
if (memo && memo.mtimeMs === st.mtimeMs && memo.size === st.size) {
|
|
1538
|
+
return memo.result
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const tail = await readTail(fp, 256 * 1024)
|
|
1542
|
+
let state = { active: false, activeSinceTs: null }
|
|
1543
|
+
if (tail != null) state = _scanCodexTurnState(tail)
|
|
1544
|
+
// tail で active=false の場合のみ、巨大 response_item で先頭の task_started が
|
|
1545
|
+
// tail 窓から落ちていないか全文で確認する (Claude 版 turnActiveForCwd と同じ方針)。
|
|
1546
|
+
if (state.active === false) {
|
|
1547
|
+
const full = await readOrNull(fp)
|
|
1548
|
+
if (full != null) state = _scanCodexTurnState(full)
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
let active = state.active
|
|
1552
|
+
const now = typeof opts.now === "number" ? opts.now : Date.now()
|
|
1553
|
+
if (
|
|
1554
|
+
active &&
|
|
1555
|
+
state.activeSinceTs !== null &&
|
|
1556
|
+
now - state.activeSinceTs > CODEX_STALE_TASK_MS &&
|
|
1557
|
+
opts.sessionName &&
|
|
1558
|
+
typeof opts.paneAliveProbe === "function"
|
|
1559
|
+
) {
|
|
1560
|
+
const verdict = await opts.paneAliveProbe(opts.sessionName)
|
|
1561
|
+
if (verdict === "dead") active = false
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const result = active
|
|
1565
|
+
_codexTurnActiveMemo.set(fp, { mtimeMs: st.mtimeMs, size: st.size, result })
|
|
1566
|
+
return result
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** テスト用: codex turn_active メモをクリアする。 */
|
|
1570
|
+
export function _resetCodexTurnActiveMemo() {
|
|
1571
|
+
_codexTurnActiveMemo.clear()
|
|
1572
|
+
}
|