@cocorograph/hub-agent 0.8.74 → 0.8.76
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/README.md +1 -1
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +2 -0
- package/src/codex-stream-bridge.mjs +38 -6
- package/src/hooks.mjs +0 -15
- package/src/main.mjs +24 -69
- package/src/review-event-store.mjs +141 -0
- package/src/sibling-inject.mjs +50 -2
- package/src/tmux.mjs +0 -23
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ hub-agent/
|
|
|
105
105
|
│ ├── ws-client.mjs # outbound WSS + reconnect with jitter
|
|
106
106
|
│ ├── claude-stream-bridge.mjs # Claude Agent SDK チャット中継
|
|
107
107
|
│ ├── codex-stream-bridge.mjs # Codex app-server チャット中継
|
|
108
|
-
│ ├── tmux.mjs # tmux
|
|
108
|
+
│ ├── tmux.mjs # tmux list/create/kill
|
|
109
109
|
│ ├── state.mjs # session status / context_pct 検知
|
|
110
110
|
│ ├── skills.mjs # ~/.claude/skills 集計
|
|
111
111
|
│ ├── usage.mjs # Claude Code 使用量集計
|
package/package.json
CHANGED
|
@@ -49,6 +49,8 @@ function siblingEnvFor(cwd) {
|
|
|
49
49
|
...process.env,
|
|
50
50
|
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
51
51
|
COCKPIT_SIBLING_CWD: cwd,
|
|
52
|
+
// Spawn-time display capability; rejection by an older daemon does not stop the review.
|
|
53
|
+
COCKPIT_REVIEW_EVENTS: "1",
|
|
52
54
|
// hub-agent が spawn した Claude 配下 producer だけが delegate-event の受理 ACK を
|
|
53
55
|
// 要求するための capability。能力は spawn 時点で固定されるため、hub-agent を旧版へ
|
|
54
56
|
// ロールバックしても既存 producer は ACK を要求し続け、timeout し得る。
|
|
@@ -44,7 +44,7 @@ import { EventEmitter } from "node:events"
|
|
|
44
44
|
import { randomUUID } from "node:crypto"
|
|
45
45
|
import path from "node:path"
|
|
46
46
|
|
|
47
|
-
import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
47
|
+
import { lookupInjectSource, recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
48
48
|
|
|
49
49
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
50
50
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
@@ -203,6 +203,8 @@ function siblingEnvFor(cwd) {
|
|
|
203
203
|
...process.env,
|
|
204
204
|
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
205
205
|
COCKPIT_SIBLING_CWD: cwd,
|
|
206
|
+
// Spawn-time display capability; rejection by an older daemon does not stop the review.
|
|
207
|
+
COCKPIT_REVIEW_EVENTS: "1",
|
|
206
208
|
// ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
|
|
207
209
|
// 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
|
|
208
210
|
COCKPIT_SIBLING_SELF_KIND: "codex",
|
|
@@ -1206,13 +1208,13 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1206
1208
|
: { items: [], start: 0, truncated: false, total: 0 }
|
|
1207
1209
|
// hub-agent 側の履歴整形コスト (巨大スレッドで効く)。thread_op_ms とは別勘定にする。
|
|
1208
1210
|
timing.history_flatten_ms = lap()
|
|
1209
|
-
const history = flat.items
|
|
1211
|
+
const history = await this._restoreHistoryInjectSources(flat.items, cwd)
|
|
1210
1212
|
const historyTruncated = flat.truncated
|
|
1211
1213
|
// T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
|
|
1212
1214
|
// 完了ごとに追記し、cwd reattach 時にこれを返す(新プロセス thread/resume を再実行しない)。
|
|
1213
1215
|
// major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
|
|
1214
1216
|
// app-server reap 後の cold resume は rollout が正本になるため、未 rollout の空 fresh session や
|
|
1215
|
-
//
|
|
1217
|
+
// 未保存の sibling source タグは復元できない。空 session の滞留を避けるため fresh 例外は作らない。
|
|
1216
1218
|
session.historyItems = history.slice()
|
|
1217
1219
|
session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
|
|
1218
1220
|
session.historyTruncated = historyTruncated
|
|
@@ -1800,7 +1802,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1800
1802
|
})
|
|
1801
1803
|
return {
|
|
1802
1804
|
threadId: session.threadId,
|
|
1803
|
-
history: page.items,
|
|
1805
|
+
history: await this._restoreHistoryInjectSources(page.items, session.cwd),
|
|
1804
1806
|
historyStart: page.start,
|
|
1805
1807
|
historyTotal: page.total,
|
|
1806
1808
|
historyTruncated: page.truncated,
|
|
@@ -2398,6 +2400,21 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2398
2400
|
this._settleInjectDelivery(handle, { delivered: false, reason: "input_failed" })
|
|
2399
2401
|
}
|
|
2400
2402
|
|
|
2403
|
+
/** 履歴にも既存の cwd・本文一致ストアから注入元を戻す。元 item と既存 source は保持する。 */
|
|
2404
|
+
async _restoreHistoryInjectSources(items, cwd) {
|
|
2405
|
+
return Promise.all(items.map(async (item) => {
|
|
2406
|
+
if (item?.type !== "userMessage" || item.source != null) return item
|
|
2407
|
+
const content = item.content
|
|
2408
|
+
const text = typeof content === "string"
|
|
2409
|
+
? content
|
|
2410
|
+
: Array.isArray(content)
|
|
2411
|
+
? content.map((block) => typeof block?.text === "string" ? block.text : "").join("")
|
|
2412
|
+
: ""
|
|
2413
|
+
const source = await lookupInjectSource(cwd, text)
|
|
2414
|
+
return source ? { ...item, source } : item
|
|
2415
|
+
}))
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2401
2418
|
/** userMessage の echo (item/completed) が控えた注入本文と一致すれば、params を **クローン**して
|
|
2402
2419
|
* item.source を付けたものを返す (元の app-server オブジェクトは変更しない)。一致しなければ null。
|
|
2403
2420
|
* frontend の userMessageItemToEvent がこの item.source を読み専用カードへ分岐する。 */
|
|
@@ -3306,8 +3323,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3306
3323
|
// ライブ emit だけタグしていたため、cwd reattach で historyItems が返ると連携カードが通常の青い
|
|
3307
3324
|
// 自分バブルへ戻っていた。同一プロセス内 reattach で表示が維持されるよう、以降 effectiveParams
|
|
3308
3325
|
// (タグ済み or 素) を history と emit の両方で使う。一致しなければ null → 素の params のまま。
|
|
3309
|
-
//
|
|
3310
|
-
// jsonl at-rest と同じ許容範囲。source 永続マップは TTL/ID 再利用の複雑さに見合わないため不採用)。
|
|
3326
|
+
// cold resume と過去履歴 page は既存の永続ストアから source を復元する。
|
|
3311
3327
|
const eventThreadId = msg?.params?.threadId
|
|
3312
3328
|
if (
|
|
3313
3329
|
typeof eventThreadId === "string" &&
|
|
@@ -4136,6 +4152,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4136
4152
|
return out
|
|
4137
4153
|
}
|
|
4138
4154
|
|
|
4155
|
+
/** Display events never start a turn and only target an exact thread in this cwd. */
|
|
4156
|
+
emitToStream(cwd, sessionId, event) {
|
|
4157
|
+
if (!cwd || !sessionId) return { status: "not_found" }
|
|
4158
|
+
const matches = [...this._liveSessions()].filter((s) => s.cwd === cwd && s.threadId === sessionId)
|
|
4159
|
+
if (matches.length !== 1) return { status: matches.length ? "ambiguous" : "not_found" }
|
|
4160
|
+
const session = matches[0]
|
|
4161
|
+
this.emit("event", {
|
|
4162
|
+
stream_id: session.stream_id,
|
|
4163
|
+
producer_seq: session.seq,
|
|
4164
|
+
thread_id: session.threadId,
|
|
4165
|
+
cwd: session.cwd,
|
|
4166
|
+
notification: { method: "cockpit/review_event", params: event },
|
|
4167
|
+
})
|
|
4168
|
+
return { status: "delivered", session: this._describe(session) }
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4139
4171
|
/**
|
|
4140
4172
|
* 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
|
|
4141
4173
|
* ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
|
package/src/hooks.mjs
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
export const HOOK_NAMES = Object.freeze([
|
|
18
18
|
"onAgentStart",
|
|
19
19
|
"onAgentStop",
|
|
20
|
-
"interceptTmuxExec",
|
|
21
20
|
"interceptSessionCreate",
|
|
22
21
|
"interceptSessionKill",
|
|
23
22
|
"filterSessions",
|
|
@@ -25,26 +24,12 @@ export const HOOK_NAMES = Object.freeze([
|
|
|
25
24
|
"transformStatusDetection",
|
|
26
25
|
])
|
|
27
26
|
|
|
28
|
-
/**
|
|
29
|
-
* @typedef {Object} TmuxExecArgs
|
|
30
|
-
* @property {string[]} args
|
|
31
|
-
* @property {Record<string, string>} env
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @typedef {Object} TmuxExecDecision
|
|
36
|
-
* @property {string} command
|
|
37
|
-
* @property {string[]} args
|
|
38
|
-
* @property {Record<string, string>} [env]
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
27
|
/**
|
|
42
28
|
* @typedef {Object} Plugin
|
|
43
29
|
* @property {string} name
|
|
44
30
|
* @property {{
|
|
45
31
|
* onAgentStart?: (ctx: HookContext) => Promise<void>,
|
|
46
32
|
* onAgentStop?: (ctx: HookContext) => Promise<void>,
|
|
47
|
-
* interceptTmuxExec?: (ctx: HookContext & TmuxExecArgs) => Promise<TmuxExecDecision | null>,
|
|
48
33
|
* }} hooks
|
|
49
34
|
*/
|
|
50
35
|
|
package/src/main.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - plugin discovery + onAgentStart broadcast
|
|
6
6
|
* - WS メッセージのディスパッチ:
|
|
7
7
|
* - claude.* / codex.* → ClaudeStreamBridge / CodexStreamBridge (チャット)
|
|
8
|
-
* - tmux.* → tmux module (
|
|
8
|
+
* - tmux.* → tmux module (セッション一覧・作成・削除)
|
|
9
9
|
*
|
|
10
10
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
11
11
|
*/
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
resolveSyncTargets,
|
|
44
44
|
syncBundleTargets,
|
|
45
45
|
} from "./hub-bundle.mjs"
|
|
46
|
-
import { loadPlugins, runHookBroadcast
|
|
46
|
+
import { loadPlugins, runHookBroadcast } from "./plugin-loader.mjs"
|
|
47
47
|
import { WsClient } from "./ws-client.mjs"
|
|
48
48
|
import { DispatchScheduler } from "./dispatch-scheduler.mjs"
|
|
49
49
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
@@ -104,7 +104,6 @@ import {
|
|
|
104
104
|
buildWorktreeIndex,
|
|
105
105
|
createSession as createTmuxSession,
|
|
106
106
|
createWorktreeDir,
|
|
107
|
-
execTmux,
|
|
108
107
|
killManySessions,
|
|
109
108
|
killSession as killTmuxSession,
|
|
110
109
|
listSessions as listTmuxSessions,
|
|
@@ -387,10 +386,10 @@ export function withDeadline(promise, timeoutMs, onTimeout) {
|
|
|
387
386
|
/**
|
|
388
387
|
* B7: 直列 dispatchChain をバイパスして即時処理してよい低レイテンシ経路かを判定する。
|
|
389
388
|
* - claude.interrupt / codex.interrupt (2026-07-02): 停止指示。前段に重い dispatch
|
|
390
|
-
* (tmux
|
|
389
|
+
* (tmux セッション操作等) が滞留していると停止が遅延するため直列キューを
|
|
391
390
|
* バイパスする。中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い
|
|
392
391
|
* (むしろキュー内の後続入力より先に届くべき)。
|
|
393
|
-
* 入力系 (claude.input)・他の制御系 (permission / tmux
|
|
392
|
+
* 入力系 (claude.input)・他の制御系 (permission / tmux セッション操作等) は
|
|
394
393
|
* WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
|
|
395
394
|
*/
|
|
396
395
|
export function isFastPathMessage(type) {
|
|
@@ -2278,6 +2277,7 @@ export function buildClaudeReadyPayload(streamId, info) {
|
|
|
2278
2277
|
turn_active: info?.turn_active === true,
|
|
2279
2278
|
history_pushed: info?.resuming === true,
|
|
2280
2279
|
supports_queue_input_id: true,
|
|
2280
|
+
supports_review_events: true,
|
|
2281
2281
|
supports_queue_snapshot: true,
|
|
2282
2282
|
supports_session_options_cas: true,
|
|
2283
2283
|
}
|
|
@@ -2329,6 +2329,7 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
|
|
|
2329
2329
|
// Browser は旧 hub-agent との混在期間、この明示 capability が true のときだけ
|
|
2330
2330
|
// 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
|
|
2331
2331
|
supports_subagent_interrupt: true,
|
|
2332
|
+
supports_review_events: true,
|
|
2332
2333
|
// 旧 agent との混在時、取消結果を待てることを明示する。
|
|
2333
2334
|
supports_queue_cancel_response: true,
|
|
2334
2335
|
}
|
|
@@ -3330,65 +3331,6 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
3330
3331
|
return p
|
|
3331
3332
|
}
|
|
3332
3333
|
|
|
3333
|
-
/**
|
|
3334
|
-
* `tmux.exec` を処理し、`tmux.result` を返す。
|
|
3335
|
-
*
|
|
3336
|
-
* 公式 Hub の送信入口は撤去済みだが、公開 0.x package の root handler / deep import、
|
|
3337
|
-
* plugin hook、旧 Hub との互換入口として handler・dispatch・execTmux を維持する。
|
|
3338
|
-
*
|
|
3339
|
-
* plugin の `interceptTmuxExec` hook が `{ command, args, env }` を返したときは、その
|
|
3340
|
-
* command を代わりに spawn する (例: remote-ops プラグインで ssh 越しに実行)。hook が
|
|
3341
|
-
* 何も返さなければ既定のローカル `execTmux` へ落ちる。どちらの経路でも
|
|
3342
|
-
* `request_id` / `stdout` / `stderr` / `exit_code` / `plugin` の 5 フィールドを揃えて返す。
|
|
3343
|
-
*
|
|
3344
|
-
* dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している
|
|
3345
|
-
* (handleStreamsSyncResponse / handleClaudeChatInput と同じ扱い)。
|
|
3346
|
-
*
|
|
3347
|
-
* @param {{request_id?:string, args?:string[]}} msg
|
|
3348
|
-
* @param {{plugins:any[], logger:object, client:{send:Function}}} ctx
|
|
3349
|
-
* @param {{execTmux?:Function}} [deps] 既定実装の差し替え口 (テスト専用)。
|
|
3350
|
-
*/
|
|
3351
|
-
export async function handleTmuxExec(msg, ctx, deps = {}) {
|
|
3352
|
-
const execTmuxImpl = deps.execTmux || execTmux
|
|
3353
|
-
const args = Array.isArray(msg.args) ? msg.args : []
|
|
3354
|
-
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
|
3355
|
-
logger: ctx.logger,
|
|
3356
|
-
args,
|
|
3357
|
-
env: process.env,
|
|
3358
|
-
})
|
|
3359
|
-
let result
|
|
3360
|
-
if (hookResult?.result?.command) {
|
|
3361
|
-
// plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
|
|
3362
|
-
const { command, args: spawnArgs, env } = hookResult.result
|
|
3363
|
-
try {
|
|
3364
|
-
const { execFile } = await import("node:child_process")
|
|
3365
|
-
const { promisify } = await import("node:util")
|
|
3366
|
-
const execFileP = promisify(execFile)
|
|
3367
|
-
const { stdout, stderr } = await execFileP(command, spawnArgs, {
|
|
3368
|
-
env: env || process.env,
|
|
3369
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
3370
|
-
})
|
|
3371
|
-
result = { stdout, stderr, exit_code: 0 }
|
|
3372
|
-
} catch (err) {
|
|
3373
|
-
result = {
|
|
3374
|
-
stdout: err.stdout || "",
|
|
3375
|
-
stderr: err.stderr || err.message || "",
|
|
3376
|
-
exit_code: typeof err.code === "number" ? err.code : 1,
|
|
3377
|
-
}
|
|
3378
|
-
}
|
|
3379
|
-
} else {
|
|
3380
|
-
result = await execTmuxImpl(args)
|
|
3381
|
-
}
|
|
3382
|
-
ctx.client.send({
|
|
3383
|
-
type: "tmux.result",
|
|
3384
|
-
request_id: msg.request_id,
|
|
3385
|
-
stdout: result.stdout,
|
|
3386
|
-
stderr: result.stderr,
|
|
3387
|
-
exit_code: result.exit_code,
|
|
3388
|
-
plugin: hookResult?.plugin || null,
|
|
3389
|
-
})
|
|
3390
|
-
}
|
|
3391
|
-
|
|
3392
3334
|
/**
|
|
3393
3335
|
* claude.attach の ready 直後にpushする履歴を組み立てる。
|
|
3394
3336
|
* Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
|
|
@@ -4237,6 +4179,15 @@ export async function dispatch(msg, ctx) {
|
|
|
4237
4179
|
resumeSessionId,
|
|
4238
4180
|
})
|
|
4239
4181
|
ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
4182
|
+
try {
|
|
4183
|
+
if (resumeSessionId) {
|
|
4184
|
+
void ctx.siblingInjectBridge
|
|
4185
|
+
?.replayReviews?.("claude", cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
4186
|
+
?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
|
|
4187
|
+
}
|
|
4188
|
+
} catch (err) {
|
|
4189
|
+
ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
|
|
4190
|
+
}
|
|
4240
4191
|
if (resumeSessionId) {
|
|
4241
4192
|
void ctx.siblingInjectBridge
|
|
4242
4193
|
?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
@@ -4670,6 +4621,15 @@ export async function dispatch(msg, ctx) {
|
|
|
4670
4621
|
ctx.client.send(
|
|
4671
4622
|
buildCodexReadyPayload(stream_id, info, ctx.codexBridge.queueState(stream_id)),
|
|
4672
4623
|
)
|
|
4624
|
+
try {
|
|
4625
|
+
if (info.threadId) {
|
|
4626
|
+
void ctx.siblingInjectBridge
|
|
4627
|
+
?.replayReviews?.("codex", resolvedCwd || process.env.HOME || process.cwd(), info.threadId)
|
|
4628
|
+
?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
|
|
4629
|
+
}
|
|
4630
|
+
} catch (err) {
|
|
4631
|
+
ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
|
|
4632
|
+
}
|
|
4673
4633
|
// S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
|
|
4674
4634
|
// 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
|
|
4675
4635
|
// 既に分かっている threadId を sessionId として記録する (直後の turn/started が
|
|
@@ -5470,11 +5430,6 @@ export async function dispatch(msg, ctx) {
|
|
|
5470
5430
|
}
|
|
5471
5431
|
return
|
|
5472
5432
|
}
|
|
5473
|
-
case "tmux.exec": {
|
|
5474
|
-
// ロジックは handleTmuxExec へ抽出 (テストから直接呼べる)。
|
|
5475
|
-
await handleTmuxExec(msg, ctx)
|
|
5476
|
-
return
|
|
5477
|
-
}
|
|
5478
5433
|
case "tmux.list_sessions": {
|
|
5479
5434
|
try {
|
|
5480
5435
|
const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
4
|
+
|
|
5
|
+
// Maximum retention; the IPC temp directory can be removed earlier by the OS.
|
|
6
|
+
const RETENTION = 30 * 24 * 60 * 60 * 1000
|
|
7
|
+
export const REVIEW_SESSION_MAX_BYTES = 4 * 1024 * 1024
|
|
8
|
+
const FIELDS = new Set([
|
|
9
|
+
"type", "subtype", "schema_version", "review_id", "phase", "reviewer", "round",
|
|
10
|
+
"workdir", "timestamp", "report", "verdict", "summary", "pid", "pid_started_at",
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export function validReviewEvent(event) {
|
|
14
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return false
|
|
15
|
+
if (Object.keys(event).some((key) => !FIELDS.has(key))) return false
|
|
16
|
+
if (Buffer.byteLength(JSON.stringify(event)) > 512 * 1024) return false
|
|
17
|
+
if (event.type !== "system" || event.subtype !== "cross_review" || event.schema_version !== 1) return false
|
|
18
|
+
if (typeof event.review_id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(event.review_id)) return false
|
|
19
|
+
if (!["started", "completed", "failed", "stopped"].includes(event.phase)) return false
|
|
20
|
+
if (!["claude", "codex"].includes(event.reviewer)) return false
|
|
21
|
+
if (!Number.isSafeInteger(event.round) || event.round <= 0) return false
|
|
22
|
+
if (typeof event.workdir !== "string" || !path.isAbsolute(event.workdir)) return false
|
|
23
|
+
if (typeof event.timestamp !== "string" || !/^\d{4}-\d\d-\d\dT/.test(event.timestamp) || !Number.isFinite(Date.parse(event.timestamp))) return false
|
|
24
|
+
if (["report", "summary"].some((key) => event[key] !== undefined && typeof event[key] !== "string")) return false
|
|
25
|
+
if (event.pid !== undefined && (!Number.isSafeInteger(event.pid) || event.pid <= 0)) return false
|
|
26
|
+
if (event.pid_started_at !== undefined && typeof event.pid_started_at !== "string") return false
|
|
27
|
+
if (event.verdict !== undefined && !["pass", "changes_requested"].includes(event.verdict)) return false
|
|
28
|
+
return event.phase !== "completed" || (typeof event.report === "string" && event.verdict !== undefined)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Single daemon writer; atomic snapshots preserve start and first terminal independently.
|
|
32
|
+
* Synthetic stopped is also final. Replay is best-effort across daemon restarts, not OS temp cleanup.
|
|
33
|
+
*/
|
|
34
|
+
export class ReviewEventStore {
|
|
35
|
+
constructor(dir, now = Date.now) {
|
|
36
|
+
this.dir = dir
|
|
37
|
+
this.now = now
|
|
38
|
+
this.queue = Promise.resolve()
|
|
39
|
+
this.lastSweep = 0
|
|
40
|
+
}
|
|
41
|
+
_serial(operation) {
|
|
42
|
+
const pending = this.queue.then(operation)
|
|
43
|
+
this.queue = pending.catch(() => {})
|
|
44
|
+
return pending
|
|
45
|
+
}
|
|
46
|
+
_file(kind, cwd, sessionId) {
|
|
47
|
+
const hash = createHash("sha256").update(JSON.stringify([kind, cwd, sessionId])).digest("hex")
|
|
48
|
+
return path.join(this.dir, hash + ".json")
|
|
49
|
+
}
|
|
50
|
+
async _checkDirectory() {
|
|
51
|
+
const info = await fs.lstat(this.dir)
|
|
52
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o700) {
|
|
53
|
+
throw new Error("unsafe review directory")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async _read(file, target) {
|
|
57
|
+
try {
|
|
58
|
+
await this._checkDirectory()
|
|
59
|
+
const info = await fs.lstat(file)
|
|
60
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
|
|
61
|
+
throw new Error("unsafe review store")
|
|
62
|
+
}
|
|
63
|
+
if (info.size > REVIEW_SESSION_MAX_BYTES) throw new Error("review store exceeds size limit")
|
|
64
|
+
const snapshot = JSON.parse(await fs.readFile(file, "utf8"))
|
|
65
|
+
if (JSON.stringify(snapshot.target) !== JSON.stringify(target) || !Array.isArray(snapshot.rows) || snapshot.rows.length > 100) {
|
|
66
|
+
throw new Error("invalid review store target or rows")
|
|
67
|
+
}
|
|
68
|
+
const ids = new Set()
|
|
69
|
+
for (const row of snapshot.rows) {
|
|
70
|
+
if (!row || !Number.isFinite(row.at) || ids.has(row.id) || (!row.started && !row.terminal)) {
|
|
71
|
+
throw new Error("invalid review store row")
|
|
72
|
+
}
|
|
73
|
+
ids.add(row.id)
|
|
74
|
+
for (const key of ["started", "terminal"]) {
|
|
75
|
+
const event = row[key]
|
|
76
|
+
if (event && (!validReviewEvent(event) || event.review_id !== row.id || (key === "started") !== (event.phase === "started"))) {
|
|
77
|
+
throw new Error("invalid review store event")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return snapshot.rows.filter((row) => row.at >= this.now() - RETENTION)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code === "ENOENT") return []
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
events(kind, cwd, sessionId) {
|
|
88
|
+
return this._serial(async () => {
|
|
89
|
+
const rows = await this._read(this._file(kind, cwd, sessionId), [kind, cwd, sessionId])
|
|
90
|
+
return rows.flatMap((row) => [row.started, row.terminal].filter(Boolean))
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
record(kind, cwd, sessionId, event) {
|
|
94
|
+
return this._serial(() => this._record(kind, cwd, sessionId, event))
|
|
95
|
+
}
|
|
96
|
+
async _record(kind, cwd, sessionId, event) {
|
|
97
|
+
if (!validReviewEvent(event)) throw new Error("invalid review event")
|
|
98
|
+
await fs.mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
99
|
+
await this._checkDirectory()
|
|
100
|
+
if (this.now() - this.lastSweep >= 86400 * 1000) {
|
|
101
|
+
for (const name of await fs.readdir(this.dir)) {
|
|
102
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue
|
|
103
|
+
const file = path.join(this.dir, name)
|
|
104
|
+
if ((await fs.lstat(file)).mtimeMs < this.now() - RETENTION) await fs.unlink(file)
|
|
105
|
+
}
|
|
106
|
+
this.lastSweep = this.now()
|
|
107
|
+
}
|
|
108
|
+
const file = this._file(kind, cwd, sessionId)
|
|
109
|
+
const target = [kind, cwd, sessionId]
|
|
110
|
+
const rows = await this._read(file, target)
|
|
111
|
+
let row = rows.find((r) => r.id === event.review_id)
|
|
112
|
+
const key = event.phase === "started" ? "started" : "terminal"
|
|
113
|
+
if (row?.[key]) return { duplicate: true, events: [] }
|
|
114
|
+
if (!row) {
|
|
115
|
+
row = { id: event.review_id, at: this.now() }
|
|
116
|
+
rows.push(row)
|
|
117
|
+
}
|
|
118
|
+
row[key] = event
|
|
119
|
+
row.at = this.now()
|
|
120
|
+
let serialized = JSON.stringify({ target, rows })
|
|
121
|
+
while (rows.length > 100 || Buffer.byteLength(serialized) > REVIEW_SESSION_MAX_BYTES) {
|
|
122
|
+
// Prefer evicting old completed reviews, preserving the incoming review and active work.
|
|
123
|
+
let index = rows.findIndex((candidate) => candidate !== row && candidate.terminal)
|
|
124
|
+
if (index < 0) index = rows.findIndex((candidate) => candidate !== row)
|
|
125
|
+
rows.splice(index, 1)
|
|
126
|
+
serialized = JSON.stringify({ target, rows })
|
|
127
|
+
}
|
|
128
|
+
const tmp = file + "." + randomUUID() + ".tmp"
|
|
129
|
+
try {
|
|
130
|
+
await fs.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" })
|
|
131
|
+
await fs.rename(tmp, file)
|
|
132
|
+
} finally {
|
|
133
|
+
try {
|
|
134
|
+
await fs.unlink(tmp)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.code !== "ENOENT") throw err
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { duplicate: false, events: key === "started" && row.terminal ? [event, row.terminal] : [event] }
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
* 新 agent だけが処理する (probe を挟まず単一リクエストで TOCTOU を排除)。
|
|
33
33
|
* 宛先は session_id(claude) / stream_id・thread_id(codex) を cwd 一致下に確定解決。
|
|
34
34
|
* - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
|
|
35
|
+
* - レビュー通知: `{op:"review-event", cwd, target_cli_kind, token, target_session_id, event}`。
|
|
36
|
+
* 許可キーはこの6つのみ。要求ファイルは所有者の0600通常ファイルに限定する。
|
|
37
|
+
* 成功は `{ok:true, duplicate?}`、保存後の配送失敗は
|
|
38
|
+
* `{ok:false, error:"review_delivery_failed", persisted:true}`。
|
|
35
39
|
* - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
|
|
36
40
|
* 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
|
|
37
41
|
* - 安全な再起動: `{op:"restart"}` → `{ok:true, restart_pending:true}`。本文と token を持たず、
|
|
@@ -75,6 +79,7 @@ import path from "node:path"
|
|
|
75
79
|
|
|
76
80
|
import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
|
|
77
81
|
import { describeLiveSessionsUnion } from "./live-sessions.mjs"
|
|
82
|
+
import { ReviewEventStore, validReviewEvent } from "./review-event-store.mjs"
|
|
78
83
|
|
|
79
84
|
export const SIBLING_REQUESTS_DIR =
|
|
80
85
|
process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
|
|
@@ -437,6 +442,7 @@ export class SiblingInjectBridge {
|
|
|
437
442
|
this._watcher = null
|
|
438
443
|
this._sweepTimer = null
|
|
439
444
|
this._now = now
|
|
445
|
+
this.reviewEvents = new ReviewEventStore(path.join(dir, "review-events"), now)
|
|
440
446
|
this._processKill = processKill
|
|
441
447
|
this._pidStartedAt = pidStartedAt
|
|
442
448
|
this._onRestartRequested = onRestartRequested
|
|
@@ -660,6 +666,23 @@ export class SiblingInjectBridge {
|
|
|
660
666
|
return { claudeBridge: this.claudeBridge, codexBridge: this.codexBridge }
|
|
661
667
|
}
|
|
662
668
|
|
|
669
|
+
async replayReviews(kind, cwd, sessionId) {
|
|
670
|
+
const bridge = kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
671
|
+
const events = await this.reviewEvents.events(kind, cwd, sessionId)
|
|
672
|
+
const terminalIds = new Set(events.filter((event) => event.phase !== "started").map((event) => event.review_id))
|
|
673
|
+
for (const event of events) {
|
|
674
|
+
bridge?.emitToStream?.(cwd, sessionId, event)
|
|
675
|
+
if (event.phase === "started" && !terminalIds.has(event.review_id) && !(await this._delegateProcessAlive(event))) {
|
|
676
|
+
const saved = await this.reviewEvents.record(kind, cwd, sessionId, {
|
|
677
|
+
...event,
|
|
678
|
+
phase: "stopped",
|
|
679
|
+
timestamp: new Date(this._now()).toISOString(),
|
|
680
|
+
})
|
|
681
|
+
for (const stopped of saved.events) bridge?.emitToStream?.(cwd, sessionId, stopped)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
663
686
|
async _lstatDir() {
|
|
664
687
|
return lstat(this.dir)
|
|
665
688
|
}
|
|
@@ -841,6 +864,7 @@ export class SiblingInjectBridge {
|
|
|
841
864
|
const isList = op === "list"
|
|
842
865
|
const isExplicit = op === "inject"
|
|
843
866
|
const isDelegateEvent = op === "delegate-event"
|
|
867
|
+
const isReviewEvent = op === "review-event"
|
|
844
868
|
const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
|
|
845
869
|
const isRestart = op === "restart"
|
|
846
870
|
let result
|
|
@@ -861,7 +885,13 @@ export class SiblingInjectBridge {
|
|
|
861
885
|
(request_ack !== undefined && request_ack !== true) ||
|
|
862
886
|
(timestamp !== undefined &&
|
|
863
887
|
(typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
|
|
864
|
-
const badBody =
|
|
888
|
+
const badBody = isReviewEvent
|
|
889
|
+
? !validReviewEvent(body.event) || typeof target_session_id !== "string" || !target_session_id ||
|
|
890
|
+
typeof cwd !== "string" || !path.isAbsolute(cwd) ||
|
|
891
|
+
Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event"].includes(key)) ||
|
|
892
|
+
!requestInfo.isFile() || requestInfo.isSymbolicLink() || requestInfo.uid !== process.getuid?.() ||
|
|
893
|
+
(requestInfo.mode & 0o777) !== 0o600
|
|
894
|
+
: isDelegateEvent
|
|
865
895
|
? badDelegateBody
|
|
866
896
|
: isList
|
|
867
897
|
? false
|
|
@@ -927,7 +957,25 @@ export class SiblingInjectBridge {
|
|
|
927
957
|
}
|
|
928
958
|
}
|
|
929
959
|
const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
930
|
-
if (
|
|
960
|
+
if (isReviewEvent) {
|
|
961
|
+
const matches = describeLiveSessionsUnion(this._bridges(), { cwd, cliKind: target_cli_kind })
|
|
962
|
+
.filter((s) => (target_cli_kind === "codex" ? s.thread_id : s.session_id) === target_session_id)
|
|
963
|
+
if (matches.length !== 1) {
|
|
964
|
+
result = { ok: false, error: AMBIGUOUS_TARGET_ERROR }
|
|
965
|
+
} else {
|
|
966
|
+
try {
|
|
967
|
+
const saved = await this.reviewEvents.record(target_cli_kind, cwd, target_session_id, body.event)
|
|
968
|
+
result = { ok: true, duplicate: saved.duplicate }
|
|
969
|
+
for (const event of saved.events) {
|
|
970
|
+
const delivered = bridge?.emitToStream?.(cwd, target_session_id, event)
|
|
971
|
+
if (delivered?.status !== "delivered") result = { ok: false, error: "review_delivery_failed", persisted: true }
|
|
972
|
+
}
|
|
973
|
+
} catch (err) {
|
|
974
|
+
this.logger?.warn?.({ request_id, error: err?.message }, "review event display failed")
|
|
975
|
+
result = { ok: false, error: "review_display_failed" }
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
} else if (isDelegateEvent) {
|
|
931
979
|
// 委譲カードは別 Claude 会話へ誤配送すると誤解を生むため、cwd 内の 1 本へ推測配送しない。
|
|
932
980
|
// target_session_id が無い場合も、指定先が一意に見つからない場合も同じ fail-closed 契約にする。
|
|
933
981
|
if (!target_session_id) {
|
package/src/tmux.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tmux サブプロセス呼び出し抽象 (Sprint E)。
|
|
3
3
|
*
|
|
4
|
-
* - `execTmux(args)`: raw 実行 (stdout / stderr / exit_code を返す)
|
|
5
4
|
* - `listSessions()`: 構造化された TmuxSession[] を返す。各 session に
|
|
6
5
|
* status (state.mjs の detectSessionState) と cwd (active pane の current_path)
|
|
7
6
|
* を並列で付与
|
|
@@ -601,28 +600,6 @@ export async function setTmuxGlobalEnv(name, value, opts = {}) {
|
|
|
601
600
|
}
|
|
602
601
|
}
|
|
603
602
|
|
|
604
|
-
/**
|
|
605
|
-
* 汎用 tmux 実行 (raw)。任意の tmux サブコマンドを実行して結果を返す低レベル API。
|
|
606
|
-
*
|
|
607
|
-
* @returns {Promise<{stdout: string, stderr: string, exit_code: number}>}
|
|
608
|
-
*/
|
|
609
|
-
export async function execTmux(args, opts = {}) {
|
|
610
|
-
if (!Array.isArray(args)) throw new TypeError("execTmux requires args: string[]")
|
|
611
|
-
try {
|
|
612
|
-
const { stdout, stderr } = await execFileP(tmuxBin(opts), args, {
|
|
613
|
-
env: opts.env || process.env,
|
|
614
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
615
|
-
})
|
|
616
|
-
return { stdout, stderr, exit_code: 0 }
|
|
617
|
-
} catch (err) {
|
|
618
|
-
return {
|
|
619
|
-
stdout: err.stdout || "",
|
|
620
|
-
stderr: err.stderr || err.message || "",
|
|
621
|
-
exit_code: typeof err.code === "number" ? err.code : 1,
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
603
|
// P1 (重複排除): cwd 取得は state.mjs の getSessionCwd (キャッシュ付き) を共用する。
|
|
627
604
|
// 旧 tmux.mjs 私有実装は display-message を毎回 spawn しており、state loop の
|
|
628
605
|
// listSessionStates と list_sessions が別キャッシュ無しで二重 fork していた。
|