@cocorograph/hub-agent 0.8.29 → 0.8.31

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.29",
3
+ "version": "0.8.31",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -3727,6 +3727,16 @@ async function dispatch(msg, ctx) {
3727
3727
  // 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
3728
3728
  supports_queue_snapshot: true,
3729
3729
  })
3730
+ if (resumeSessionId) {
3731
+ void ctx.siblingInjectBridge
3732
+ ?.replayActiveDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
3733
+ .catch((err) => {
3734
+ ctx.logger?.warn?.(
3735
+ { err: err?.message || String(err), stream_id, session_id: resumeSessionId },
3736
+ "failed to replay active delegations after Claude attach",
3737
+ )
3738
+ })
3739
+ }
3730
3740
  // dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
3731
3741
  // 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
3732
3742
  void historyPromise
@@ -32,10 +32,11 @@
32
32
  * - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
33
33
  * - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
34
34
  * 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
35
- * 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
35
+ * 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
36
36
  * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
37
37
  * candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
38
38
  * 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
39
+ * `delegate-event` だけは fire-and-forget のため応答ファイルを書かない。
39
40
  *
40
41
  * ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
41
42
  * - `delivered` … userMessage echo まで確認済み (ok:true)
@@ -60,6 +61,8 @@
60
61
 
61
62
  import { watch } from "node:fs"
62
63
  import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
64
+ import { execFile as execFileCallback } from "node:child_process"
65
+ import { promisify } from "node:util"
63
66
  import path from "node:path"
64
67
 
65
68
  import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
@@ -68,6 +71,25 @@ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
68
71
  export const SIBLING_REQUESTS_DIR =
69
72
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
70
73
 
74
+ export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
75
+ export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
76
+
77
+ const execFile = promisify(execFileCallback)
78
+
79
+ /**
80
+ * `ps` の起動時刻を送受信双方で比較する契約形へ正規化する。
81
+ * 先頭・末尾の Unicode 空白だけを除去し、日時本体の内部空白は変更しない。
82
+ * 正本は Hub の `codex_agent.py:_normalize_process_started_at`。
83
+ */
84
+ export function normalizeProcessStartedAt(raw) {
85
+ return raw.trim()
86
+ }
87
+
88
+ async function defaultPidStartedAt(pid) {
89
+ const { stdout } = await execFile("ps", ["-o", "lstart=", "-p", String(pid)])
90
+ return normalizeProcessStartedAt(stdout)
91
+ }
92
+
71
93
  const DELIVERY_UNCONFIRMED_HINT =
72
94
  "turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
73
95
  "再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
@@ -248,9 +270,19 @@ function queuedResult(r) {
248
270
 
249
271
  export class SiblingInjectBridge {
250
272
  /**
251
- * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
273
+ * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object,
274
+ * now?: () => number, processKill?: (pid: number, signal: number) => void,
275
+ * pidStartedAt?: (pid: number) => Promise<string>}} [opts]
252
276
  */
253
- constructor({ dir = SIBLING_REQUESTS_DIR, claudeBridge, codexBridge, logger } = {}) {
277
+ constructor({
278
+ dir = SIBLING_REQUESTS_DIR,
279
+ claudeBridge,
280
+ codexBridge,
281
+ logger,
282
+ now = Date.now,
283
+ processKill = process.kill.bind(process),
284
+ pidStartedAt = defaultPidStartedAt,
285
+ } = {}) {
254
286
  this.dir = dir
255
287
  this.claudeBridge = claudeBridge
256
288
  this.codexBridge = codexBridge
@@ -258,6 +290,115 @@ export class SiblingInjectBridge {
258
290
  /** @type {Set<string>} 二重処理防止 (request_id) */
259
291
  this._seen = new Set()
260
292
  this._watcher = null
293
+ this._now = now
294
+ this._processKill = processKill
295
+ this._pidStartedAt = pidStartedAt
296
+ /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
297
+ this._activeDelegations = new Map()
298
+ /** @type {Map<string, Map<string, number>>} 終端→開始の逆転で遅着 started を復活させない墓標。 */
299
+ this._terminalDelegations = new Map()
300
+ }
301
+
302
+ _delegationSessionKey(cwd, sessionId) {
303
+ return `${cwd}\u0000${sessionId}`
304
+ }
305
+
306
+ _pruneDelegations() {
307
+ const cutoff = this._now() - DELEGATION_LEDGER_RETENTION_MS
308
+ for (const [sessionKey, entries] of this._activeDelegations) {
309
+ for (const [delegationId, entry] of entries) {
310
+ if (entry.recordedAt < cutoff) entries.delete(delegationId)
311
+ }
312
+ if (entries.size === 0) this._activeDelegations.delete(sessionKey)
313
+ }
314
+ for (const [sessionKey, entries] of this._terminalDelegations) {
315
+ for (const [delegationId, recordedAt] of entries) {
316
+ if (recordedAt < cutoff) entries.delete(delegationId)
317
+ }
318
+ if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
319
+ }
320
+ }
321
+
322
+ _recordDelegateEvent(cwd, sessionId, event) {
323
+ this._pruneDelegations()
324
+ const sessionKey = this._delegationSessionKey(cwd, sessionId)
325
+ if (event.phase !== "started") {
326
+ this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
327
+ if (this._activeDelegations.get(sessionKey)?.size === 0) {
328
+ this._activeDelegations.delete(sessionKey)
329
+ }
330
+ let terminalEntries = this._terminalDelegations.get(sessionKey)
331
+ if (!terminalEntries) {
332
+ terminalEntries = new Map()
333
+ this._terminalDelegations.set(sessionKey, terminalEntries)
334
+ }
335
+ terminalEntries.delete(event.delegation_id)
336
+ terminalEntries.set(event.delegation_id, this._now())
337
+ while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
338
+ terminalEntries.delete(terminalEntries.keys().next().value)
339
+ }
340
+ return
341
+ }
342
+ if (this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)) return
343
+ let entries = this._activeDelegations.get(sessionKey)
344
+ if (!entries) {
345
+ entries = new Map()
346
+ this._activeDelegations.set(sessionKey, entries)
347
+ }
348
+ entries.delete(event.delegation_id)
349
+ entries.set(event.delegation_id, { event, recordedAt: this._now() })
350
+ while (entries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
351
+ entries.delete(entries.keys().next().value)
352
+ }
353
+ }
354
+
355
+ async _delegateProcessAlive(event) {
356
+ if (!Number.isInteger(event.pid) || event.pid <= 0 || !event.pid_started_at) return true
357
+ try {
358
+ this._processKill(event.pid, 0)
359
+ } catch (err) {
360
+ if (err?.code === "ESRCH") return false
361
+ if (err?.code !== "EPERM") return true
362
+ }
363
+ try {
364
+ return (
365
+ normalizeProcessStartedAt(await this._pidStartedAt(event.pid)) ===
366
+ normalizeProcessStartedAt(event.pid_started_at)
367
+ )
368
+ } catch {
369
+ return true
370
+ }
371
+ }
372
+
373
+ /** attach/再接続時に同じ cwd+session の進行中委譲だけを再送する。 */
374
+ async replayActiveDelegations(cwd, sessionId) {
375
+ if (!cwd || !sessionId) return
376
+ this._pruneDelegations()
377
+ const sessionKey = this._delegationSessionKey(cwd, sessionId)
378
+ const entries = this._activeDelegations.get(sessionKey)
379
+ if (!entries) return
380
+ for (const [delegationId, entry] of [...entries]) {
381
+ const alive = await this._delegateProcessAlive(entry.event)
382
+ // 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
383
+ if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
384
+ if (alive) {
385
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.event)
386
+ continue
387
+ }
388
+ const stopped = {
389
+ type: "system",
390
+ subtype: "codex_delegate",
391
+ delegation_id: delegationId,
392
+ phase: "stopped",
393
+ ...(entry.event.agent !== undefined ? { agent: entry.event.agent } : {}),
394
+ ...(entry.event.description !== undefined
395
+ ? { description: entry.event.description }
396
+ : {}),
397
+ timestamp: new Date(this._now()).toISOString(),
398
+ }
399
+ this._recordDelegateEvent(cwd, sessionId, stopped)
400
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, stopped)
401
+ }
261
402
  }
262
403
 
263
404
  /**
@@ -344,7 +485,7 @@ export class SiblingInjectBridge {
344
485
 
345
486
  /**
346
487
  * 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
347
- * 成否によらず要求ファイルは削除し、結果ファイルを書く。
488
+ * 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
348
489
  * @param {string} filename `<request_id>.json`
349
490
  */
350
491
  async _ingest(filename) {
@@ -383,6 +524,8 @@ export class SiblingInjectBridge {
383
524
  agent,
384
525
  description,
385
526
  timestamp,
527
+ pid,
528
+ pid_started_at,
386
529
  } = body || {}
387
530
  // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
388
531
  // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
@@ -402,6 +545,8 @@ export class SiblingInjectBridge {
402
545
  (phase === "started" && (typeof agent !== "string" || !agent)) ||
403
546
  (agent !== undefined && typeof agent !== "string") ||
404
547
  (description !== undefined && typeof description !== "string") ||
548
+ (pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
549
+ (pid_started_at !== undefined && typeof pid_started_at !== "string") ||
405
550
  (timestamp !== undefined &&
406
551
  (typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
407
552
  const badBody = isDelegateEvent
@@ -461,19 +606,23 @@ export class SiblingInjectBridge {
461
606
  phase,
462
607
  ...(agent !== undefined ? { agent } : {}),
463
608
  ...(description !== undefined ? { description } : {}),
609
+ ...(pid !== undefined ? { pid } : {}),
610
+ ...(pid_started_at !== undefined ? { pid_started_at } : {}),
464
611
  timestamp: timestamp || new Date().toISOString(),
465
612
  }
466
613
  const r = this.claudeBridge?.emitToStream
467
614
  ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
468
615
  : { status: "not_found" }
469
- result =
470
- r.status === "delivered"
471
- ? { ok: true, delivered_to: r.session }
472
- : {
473
- ok: false,
474
- error: AMBIGUOUS_TARGET_ERROR,
475
- ...(r.candidates ? { candidates: r.candidates } : {}),
476
- }
616
+ if (r.status === "delivered") {
617
+ this._recordDelegateEvent(cwd, target_session_id, event)
618
+ result = { ok: true, delivered_to: r.session }
619
+ } else {
620
+ result = {
621
+ ok: false,
622
+ error: AMBIGUOUS_TARGET_ERROR,
623
+ ...(r.candidates ? { candidates: r.candidates } : {}),
624
+ }
625
+ }
477
626
  }
478
627
  } else if (isList) {
479
628
  // 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
@@ -636,7 +785,7 @@ export class SiblingInjectBridge {
636
785
  : "sibling-inject dispatched"
637
786
  : "sibling-inject failed",
638
787
  )
639
- await this._writeResult(request_id, result)
788
+ if (!isDelegateEvent) await this._writeResult(request_id, result)
640
789
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
641
790
  try {
642
791
  await unlink(fp)