@cocorograph/hub-agent 0.8.75 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.75",
3
+ "version": "0.8.76",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
- // in-memory の sibling source タグは復元できない。空 session の滞留を避けるため fresh 例外は作らない。
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
- // ※ hub-agent 再起動 (cold resume) では historyItems が揮発するため青バブルへ戻る (claude の
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/main.mjs CHANGED
@@ -2277,6 +2277,7 @@ export function buildClaudeReadyPayload(streamId, info) {
2277
2277
  turn_active: info?.turn_active === true,
2278
2278
  history_pushed: info?.resuming === true,
2279
2279
  supports_queue_input_id: true,
2280
+ supports_review_events: true,
2280
2281
  supports_queue_snapshot: true,
2281
2282
  supports_session_options_cas: true,
2282
2283
  }
@@ -2328,6 +2329,7 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
2328
2329
  // Browser は旧 hub-agent との混在期間、この明示 capability が true のときだけ
2329
2330
  // 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
2330
2331
  supports_subagent_interrupt: true,
2332
+ supports_review_events: true,
2331
2333
  // 旧 agent との混在時、取消結果を待てることを明示する。
2332
2334
  supports_queue_cancel_response: true,
2333
2335
  }
@@ -4177,6 +4179,15 @@ export async function dispatch(msg, ctx) {
4177
4179
  resumeSessionId,
4178
4180
  })
4179
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
+ }
4180
4191
  if (resumeSessionId) {
4181
4192
  void ctx.siblingInjectBridge
4182
4193
  ?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
@@ -4610,6 +4621,15 @@ export async function dispatch(msg, ctx) {
4610
4621
  ctx.client.send(
4611
4622
  buildCodexReadyPayload(stream_id, info, ctx.codexBridge.queueState(stream_id)),
4612
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
+ }
4613
4633
  // S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
4614
4634
  // 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
4615
4635
  // 既に分かっている threadId を sessionId として記録する (直後の turn/started が
@@ -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
+ }
@@ -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 = isDelegateEvent
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 (isDelegateEvent) {
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) {