@cocorograph/hub-agent 0.8.75 → 0.8.77

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.
@@ -43,8 +43,9 @@
43
43
  import { EventEmitter } from "node:events"
44
44
  import { randomUUID } from "node:crypto"
45
45
  import path from "node:path"
46
+ import { isUnderRemoval } from "./chat-removal-guard.mjs"
46
47
 
47
- import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
48
+ import { lookupInjectSource, recordInjectSourcePersistent } from "./inject-source-store.mjs"
48
49
 
49
50
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
50
51
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
@@ -203,6 +204,8 @@ function siblingEnvFor(cwd) {
203
204
  ...process.env,
204
205
  COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
205
206
  COCKPIT_SIBLING_CWD: cwd,
207
+ // Spawn-time display capability; rejection by an older daemon does not stop the review.
208
+ COCKPIT_REVIEW_EVENTS: "1",
206
209
  // ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
207
210
  // 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
208
211
  COCKPIT_SIBLING_SELF_KIND: "codex",
@@ -798,6 +801,7 @@ export class CodexStreamBridge extends EventEmitter {
798
801
  if (this.sessions.has(stream_id)) {
799
802
  throw new Error(`stream_id already attached: ${stream_id}`)
800
803
  }
804
+ if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
801
805
  // 計測 (2026-07-31): attach 1 回 = 1 行の内訳ログ (`codex attach timing`)。
802
806
  // frontend の attach_sent→ready_received は 1 つの数字でしか見えず、その中身が
803
807
  // 「app-server の cold spawn + initialize」なのか「thread/resume の rollout 読み込み」なのか、
@@ -921,6 +925,11 @@ export class CodexStreamBridge extends EventEmitter {
921
925
  }
922
926
  }
923
927
 
928
+ // ⚠️ attach の入口でも同じ検査をしているが、そこから **_tryReattach の await** を挟んで
929
+ // ここへ来る(既存 session の thread 確定待ち)。その間に削除が始まっていることがあるので、
930
+ // プロセスを起こす直前にもう一度見る。ここを外すと「停止対象を列挙し終えたあとに新しい
931
+ // プロセスが生まれる」経路が残り、削除された cwd を指す会話ができる。
932
+ if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
924
933
  const client = this._clientFactory({
925
934
  cwd,
926
935
  codexCmd: this.codexCmd,
@@ -1206,13 +1215,13 @@ export class CodexStreamBridge extends EventEmitter {
1206
1215
  : { items: [], start: 0, truncated: false, total: 0 }
1207
1216
  // hub-agent 側の履歴整形コスト (巨大スレッドで効く)。thread_op_ms とは別勘定にする。
1208
1217
  timing.history_flatten_ms = lap()
1209
- const history = flat.items
1218
+ const history = await this._restoreHistoryInjectSources(flat.items, cwd)
1210
1219
  const historyTruncated = flat.truncated
1211
1220
  // T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
1212
1221
  // 完了ごとに追記し、cwd reattach 時にこれを返す(新プロセス thread/resume を再実行しない)。
1213
1222
  // major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
1214
1223
  // app-server reap 後の cold resume は rollout が正本になるため、未 rollout の空 fresh session や
1215
- // in-memory の sibling source タグは復元できない。空 session の滞留を避けるため fresh 例外は作らない。
1224
+ // 未保存の sibling source タグは復元できない。空 session の滞留を避けるため fresh 例外は作らない。
1216
1225
  session.historyItems = history.slice()
1217
1226
  session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
1218
1227
  session.historyTruncated = historyTruncated
@@ -1800,7 +1809,7 @@ export class CodexStreamBridge extends EventEmitter {
1800
1809
  })
1801
1810
  return {
1802
1811
  threadId: session.threadId,
1803
- history: page.items,
1812
+ history: await this._restoreHistoryInjectSources(page.items, session.cwd),
1804
1813
  historyStart: page.start,
1805
1814
  historyTotal: page.total,
1806
1815
  historyTruncated: page.truncated,
@@ -1972,6 +1981,14 @@ export class CodexStreamBridge extends EventEmitter {
1972
1981
  return addCodexSessionTitles(sessions, sessionsDir)
1973
1982
  }
1974
1983
 
1984
+ /** 指定 stream の live 会話を停止する。履歴ファイルは削除しない。 */
1985
+ stopSessionForStream({ stream_id, reason }) {
1986
+ const session = this.sessions.get(stream_id)
1987
+ if (!session || session.dead) return 0
1988
+ this._reapSession(session, Date.now(), reason || "explicit-stop")
1989
+ return 1
1990
+ }
1991
+
1975
1992
  async operateSession(options) {
1976
1993
  const invalid = validateCodexSessionOperation(options)
1977
1994
  if (invalid) return { ok: false, error: invalid }
@@ -2398,6 +2415,21 @@ export class CodexStreamBridge extends EventEmitter {
2398
2415
  this._settleInjectDelivery(handle, { delivered: false, reason: "input_failed" })
2399
2416
  }
2400
2417
 
2418
+ /** 履歴にも既存の cwd・本文一致ストアから注入元を戻す。元 item と既存 source は保持する。 */
2419
+ async _restoreHistoryInjectSources(items, cwd) {
2420
+ return Promise.all(items.map(async (item) => {
2421
+ if (item?.type !== "userMessage" || item.source != null) return item
2422
+ const content = item.content
2423
+ const text = typeof content === "string"
2424
+ ? content
2425
+ : Array.isArray(content)
2426
+ ? content.map((block) => typeof block?.text === "string" ? block.text : "").join("")
2427
+ : ""
2428
+ const source = await lookupInjectSource(cwd, text)
2429
+ return source ? { ...item, source } : item
2430
+ }))
2431
+ }
2432
+
2401
2433
  /** userMessage の echo (item/completed) が控えた注入本文と一致すれば、params を **クローン**して
2402
2434
  * item.source を付けたものを返す (元の app-server オブジェクトは変更しない)。一致しなければ null。
2403
2435
  * frontend の userMessageItemToEvent がこの item.source を読み専用カードへ分岐する。 */
@@ -3306,8 +3338,7 @@ export class CodexStreamBridge extends EventEmitter {
3306
3338
  // ライブ emit だけタグしていたため、cwd reattach で historyItems が返ると連携カードが通常の青い
3307
3339
  // 自分バブルへ戻っていた。同一プロセス内 reattach で表示が維持されるよう、以降 effectiveParams
3308
3340
  // (タグ済み or 素) を history と emit の両方で使う。一致しなければ null → 素の params のまま。
3309
- // ※ hub-agent 再起動 (cold resume) では historyItems が揮発するため青バブルへ戻る (claude の
3310
- // jsonl at-rest と同じ許容範囲。source 永続マップは TTL/ID 再利用の複雑さに見合わないため不採用)。
3341
+ // cold resume と過去履歴 page は既存の永続ストアから source を復元する。
3311
3342
  const eventThreadId = msg?.params?.threadId
3312
3343
  if (
3313
3344
  typeof eventThreadId === "string" &&
@@ -4136,6 +4167,22 @@ export class CodexStreamBridge extends EventEmitter {
4136
4167
  return out
4137
4168
  }
4138
4169
 
4170
+ /** Display events never start a turn and only target an exact thread in this cwd. */
4171
+ emitToStream(cwd, sessionId, event) {
4172
+ if (!cwd || !sessionId) return { status: "not_found" }
4173
+ const matches = [...this._liveSessions()].filter((s) => s.cwd === cwd && s.threadId === sessionId)
4174
+ if (matches.length !== 1) return { status: matches.length ? "ambiguous" : "not_found" }
4175
+ const session = matches[0]
4176
+ this.emit("event", {
4177
+ stream_id: session.stream_id,
4178
+ producer_seq: session.seq,
4179
+ thread_id: session.threadId,
4180
+ cwd: session.cwd,
4181
+ notification: { method: "cockpit/review_event", params: event },
4182
+ })
4183
+ return { status: "delivered", session: this._describe(session) }
4184
+ }
4185
+
4139
4186
  /**
4140
4187
  * 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
4141
4188
  * ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
@@ -4698,7 +4745,12 @@ export class CodexStreamBridge extends EventEmitter {
4698
4745
  producer_seq: session.seq,
4699
4746
  thread_id: session.threadId,
4700
4747
  code: 0,
4701
- reason: reason === "session-deleted" ? "session-deleted" : "idle-reaped",
4748
+ // ⚠️ ここは「明示的に止めた理由」の許可リスト。載っていない理由はすべて "idle-reaped"
4749
+ // へ丸められ、利用者には「放置されて片付いた」と通知される。明示停止を足したら必ず
4750
+ // ここへも足すこと(足し忘れても停止自体は動くので、通知だけが静かに嘘になる)。
4751
+ reason: ["session-deleted", "chat-stop", "chat-stop-all", "worktree-remove"].includes(reason)
4752
+ ? reason
4753
+ : "idle-reaped",
4702
4754
  })
4703
4755
  }
4704
4756
 
@@ -83,8 +83,14 @@ function globalMutationMode(type) {
83
83
  return null
84
84
  }
85
85
 
86
- function attachLaneKey(mode, msg) {
87
- const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
86
+ // ⚠️ cwd を省略した attach は、ハンドラ側で config.claude_cwd に補われ、さらに claude では
87
+ // bridge が HOME へ補完して「その cwd の会話」になる (main.mjs の claude.attach / codex.attach
88
+ // と claude-stream-bridge の attach)。レーン分類がその補完を知らないと、同じ会話への attach が
89
+ // stream レーンへ、個別停止 (cwd を持つ) が cwd レーンへ散り、直列化されないまま同時に走る。
90
+ // scheduler へ設定も HOME も読ませず、補完後の既定 cwd を呼び出し側から mode 別に渡してもらう
91
+ // (補完の規則は CLI 種別で違うため、mode を知らない scheduler 側では決められない)。
92
+ function attachLaneKey(mode, msg, defaultCwd = "") {
93
+ const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || defaultCwd
88
94
  if (cwd) return `${mode}:cwd:${cwd}`
89
95
  const resumeId =
90
96
  mode === "claude"
@@ -100,14 +106,18 @@ function attachLaneKey(mode, msg) {
100
106
  * run: (msg: object) => Promise<void>|void,
101
107
  * logger?: {debug?: Function, warn?: Function, error?: Function},
102
108
  * shouldBypass?: (type: string|undefined) => boolean,
109
+ * defaultCwd?: (mode: "claude"|"codex") => string,
103
110
  * }} options
104
111
  */
105
112
  export class DispatchScheduler {
106
- constructor({ run, logger, shouldBypass }) {
113
+ constructor({ run, logger, shouldBypass, defaultCwd }) {
107
114
  if (typeof run !== "function") throw new TypeError("DispatchScheduler run is required")
108
115
  this.run = run
109
116
  this.logger = logger
110
117
  this.shouldBypass = typeof shouldBypass === "function" ? shouldBypass : () => false
118
+ // cwd 省略時にハンドラ/bridge が使う既定 cwd を CLI 種別ごとに返す関数。展開済みの値を
119
+ // 返すこと (scheduler は `~` 展開も HOME 参照もしない)。
120
+ this.defaultCwd = typeof defaultCwd === "function" ? defaultCwd : () => ""
111
121
  this.generation = 0
112
122
  this.sequence = 0
113
123
  this.laneTails = new Map()
@@ -154,6 +164,21 @@ export class DispatchScheduler {
154
164
  }
155
165
  }
156
166
 
167
+ /**
168
+ * cwd 省略時にハンドラ/bridge が使う既定 cwd を、CLI 種別ごとに引く。
169
+ * ⚠️ レーン分類は全メッセージが通る経路なので、設定の読み取りが失敗しても dispatch を
170
+ * 止めない。取れなければ省略時の従来分類 (stream / control) へ戻す。
171
+ * @param {"claude"|"codex"} mode
172
+ */
173
+ _resolveDefaultCwd(mode) {
174
+ try {
175
+ const cwd = this.defaultCwd(mode)
176
+ return typeof cwd === "string" ? cwd.trim() : ""
177
+ } catch {
178
+ return ""
179
+ }
180
+ }
181
+
157
182
  _laneFor(msg) {
158
183
  const type = msg?.type
159
184
  const mutationMode = globalMutationMode(type)
@@ -163,20 +188,35 @@ export class DispatchScheduler {
163
188
  // frontend の 10 秒打ち切りで捨てられていたため分離する (2026-09-04 本番計測)。
164
189
  if (type === "usage.request") return "usage"
165
190
 
191
+ if (type === "chat.session.stop") {
192
+ if (msg.cli_kind === "claude") {
193
+ const cwd = typeof msg.cwd === "string" ? msg.cwd.trim() : ""
194
+ return cwd ? `claude:cwd:${cwd}` : "control"
195
+ }
196
+ if (msg.cli_kind === "codex" && msg.stream_id) {
197
+ return this.streamLaneKeys.get("codex:" + msg.stream_id) || `codex:stream:${msg.stream_id}`
198
+ }
199
+ return "control"
200
+ }
201
+ // chat.stop_all は全レーンに跨るため control に置く。その時点で live な会話だけを
202
+ // 止める契約であり、接続処理中のまだ live でない会話は対象外。sessions.request も control。
166
203
  const mode = chatMode(type)
167
204
  if (!mode) return "control"
168
205
 
169
206
  const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
170
207
  const streamKey = streamId ? `${mode}:${streamId}` : ""
208
+ const fallbackCwd = this._resolveDefaultCwd(mode)
171
209
  if (type === `${mode}.attach`) {
172
- const laneKey = attachLaneKey(mode, msg)
210
+ const laneKey = attachLaneKey(mode, msg, fallbackCwd)
173
211
  this._rememberStreamLane(streamId, laneKey)
174
212
  return laneKey
175
213
  }
176
214
  if (streamKey && this.streamLaneKeys.has(streamKey)) {
177
215
  return this.streamLaneKeys.get(streamKey)
178
216
  }
179
- const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
217
+ // attach と同じ補完を使う。片方だけ既定 cwd を見ると、同じ会話宛の upload / history 等が
218
+ // attach とは別レーンへ散り、attach で直したはずの取り違えが型ごと再発する。
219
+ const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || fallbackCwd
180
220
  if (cwd) return `${mode}:cwd:${cwd}`
181
221
  if (streamId) return `${mode}:stream:${streamId}`
182
222
  // codex.profile.list 等の stream 非依存 read は main 側の read-only bypass