@cocorograph/hub-agent 0.8.29 → 0.8.30

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.30",
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,16 @@ 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
+ async function defaultPidStartedAt(pid) {
80
+ const { stdout } = await execFile("ps", ["-o", "lstart=", "-p", String(pid)])
81
+ return stdout.replace(/\r?\n$/, "")
82
+ }
83
+
71
84
  const DELIVERY_UNCONFIRMED_HINT =
72
85
  "turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
73
86
  "再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
@@ -248,9 +261,19 @@ function queuedResult(r) {
248
261
 
249
262
  export class SiblingInjectBridge {
250
263
  /**
251
- * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
264
+ * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object,
265
+ * now?: () => number, processKill?: (pid: number, signal: number) => void,
266
+ * pidStartedAt?: (pid: number) => Promise<string>}} [opts]
252
267
  */
253
- constructor({ dir = SIBLING_REQUESTS_DIR, claudeBridge, codexBridge, logger } = {}) {
268
+ constructor({
269
+ dir = SIBLING_REQUESTS_DIR,
270
+ claudeBridge,
271
+ codexBridge,
272
+ logger,
273
+ now = Date.now,
274
+ processKill = process.kill.bind(process),
275
+ pidStartedAt = defaultPidStartedAt,
276
+ } = {}) {
254
277
  this.dir = dir
255
278
  this.claudeBridge = claudeBridge
256
279
  this.codexBridge = codexBridge
@@ -258,6 +281,112 @@ export class SiblingInjectBridge {
258
281
  /** @type {Set<string>} 二重処理防止 (request_id) */
259
282
  this._seen = new Set()
260
283
  this._watcher = null
284
+ this._now = now
285
+ this._processKill = processKill
286
+ this._pidStartedAt = pidStartedAt
287
+ /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
288
+ this._activeDelegations = new Map()
289
+ /** @type {Map<string, Map<string, number>>} 終端→開始の逆転で遅着 started を復活させない墓標。 */
290
+ this._terminalDelegations = new Map()
291
+ }
292
+
293
+ _delegationSessionKey(cwd, sessionId) {
294
+ return `${cwd}\u0000${sessionId}`
295
+ }
296
+
297
+ _pruneDelegations() {
298
+ const cutoff = this._now() - DELEGATION_LEDGER_RETENTION_MS
299
+ for (const [sessionKey, entries] of this._activeDelegations) {
300
+ for (const [delegationId, entry] of entries) {
301
+ if (entry.recordedAt < cutoff) entries.delete(delegationId)
302
+ }
303
+ if (entries.size === 0) this._activeDelegations.delete(sessionKey)
304
+ }
305
+ for (const [sessionKey, entries] of this._terminalDelegations) {
306
+ for (const [delegationId, recordedAt] of entries) {
307
+ if (recordedAt < cutoff) entries.delete(delegationId)
308
+ }
309
+ if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
310
+ }
311
+ }
312
+
313
+ _recordDelegateEvent(cwd, sessionId, event) {
314
+ this._pruneDelegations()
315
+ const sessionKey = this._delegationSessionKey(cwd, sessionId)
316
+ if (event.phase !== "started") {
317
+ this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
318
+ if (this._activeDelegations.get(sessionKey)?.size === 0) {
319
+ this._activeDelegations.delete(sessionKey)
320
+ }
321
+ let terminalEntries = this._terminalDelegations.get(sessionKey)
322
+ if (!terminalEntries) {
323
+ terminalEntries = new Map()
324
+ this._terminalDelegations.set(sessionKey, terminalEntries)
325
+ }
326
+ terminalEntries.delete(event.delegation_id)
327
+ terminalEntries.set(event.delegation_id, this._now())
328
+ while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
329
+ terminalEntries.delete(terminalEntries.keys().next().value)
330
+ }
331
+ return
332
+ }
333
+ if (this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)) return
334
+ let entries = this._activeDelegations.get(sessionKey)
335
+ if (!entries) {
336
+ entries = new Map()
337
+ this._activeDelegations.set(sessionKey, entries)
338
+ }
339
+ entries.delete(event.delegation_id)
340
+ entries.set(event.delegation_id, { event, recordedAt: this._now() })
341
+ while (entries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
342
+ entries.delete(entries.keys().next().value)
343
+ }
344
+ }
345
+
346
+ async _delegateProcessAlive(event) {
347
+ if (!Number.isInteger(event.pid) || event.pid <= 0 || !event.pid_started_at) return true
348
+ try {
349
+ this._processKill(event.pid, 0)
350
+ } catch (err) {
351
+ if (err?.code === "ESRCH") return false
352
+ if (err?.code !== "EPERM") return true
353
+ }
354
+ try {
355
+ return (await this._pidStartedAt(event.pid)) === event.pid_started_at
356
+ } catch {
357
+ return true
358
+ }
359
+ }
360
+
361
+ /** attach/再接続時に同じ cwd+session の進行中委譲だけを再送する。 */
362
+ async replayActiveDelegations(cwd, sessionId) {
363
+ if (!cwd || !sessionId) return
364
+ this._pruneDelegations()
365
+ const sessionKey = this._delegationSessionKey(cwd, sessionId)
366
+ const entries = this._activeDelegations.get(sessionKey)
367
+ if (!entries) return
368
+ for (const [delegationId, entry] of [...entries]) {
369
+ const alive = await this._delegateProcessAlive(entry.event)
370
+ // 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
371
+ if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
372
+ if (alive) {
373
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.event)
374
+ continue
375
+ }
376
+ const stopped = {
377
+ type: "system",
378
+ subtype: "codex_delegate",
379
+ delegation_id: delegationId,
380
+ phase: "stopped",
381
+ ...(entry.event.agent !== undefined ? { agent: entry.event.agent } : {}),
382
+ ...(entry.event.description !== undefined
383
+ ? { description: entry.event.description }
384
+ : {}),
385
+ timestamp: new Date(this._now()).toISOString(),
386
+ }
387
+ this._recordDelegateEvent(cwd, sessionId, stopped)
388
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, stopped)
389
+ }
261
390
  }
262
391
 
263
392
  /**
@@ -344,7 +473,7 @@ export class SiblingInjectBridge {
344
473
 
345
474
  /**
346
475
  * 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
347
- * 成否によらず要求ファイルは削除し、結果ファイルを書く。
476
+ * 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
348
477
  * @param {string} filename `<request_id>.json`
349
478
  */
350
479
  async _ingest(filename) {
@@ -383,6 +512,8 @@ export class SiblingInjectBridge {
383
512
  agent,
384
513
  description,
385
514
  timestamp,
515
+ pid,
516
+ pid_started_at,
386
517
  } = body || {}
387
518
  // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
388
519
  // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
@@ -402,6 +533,8 @@ export class SiblingInjectBridge {
402
533
  (phase === "started" && (typeof agent !== "string" || !agent)) ||
403
534
  (agent !== undefined && typeof agent !== "string") ||
404
535
  (description !== undefined && typeof description !== "string") ||
536
+ (pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
537
+ (pid_started_at !== undefined && typeof pid_started_at !== "string") ||
405
538
  (timestamp !== undefined &&
406
539
  (typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
407
540
  const badBody = isDelegateEvent
@@ -461,19 +594,23 @@ export class SiblingInjectBridge {
461
594
  phase,
462
595
  ...(agent !== undefined ? { agent } : {}),
463
596
  ...(description !== undefined ? { description } : {}),
597
+ ...(pid !== undefined ? { pid } : {}),
598
+ ...(pid_started_at !== undefined ? { pid_started_at } : {}),
464
599
  timestamp: timestamp || new Date().toISOString(),
465
600
  }
466
601
  const r = this.claudeBridge?.emitToStream
467
602
  ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
468
603
  : { 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
- }
604
+ if (r.status === "delivered") {
605
+ this._recordDelegateEvent(cwd, target_session_id, event)
606
+ result = { ok: true, delivered_to: r.session }
607
+ } else {
608
+ result = {
609
+ ok: false,
610
+ error: AMBIGUOUS_TARGET_ERROR,
611
+ ...(r.candidates ? { candidates: r.candidates } : {}),
612
+ }
613
+ }
477
614
  }
478
615
  } else if (isList) {
479
616
  // 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
@@ -636,7 +773,7 @@ export class SiblingInjectBridge {
636
773
  : "sibling-inject dispatched"
637
774
  : "sibling-inject failed",
638
775
  )
639
- await this._writeResult(request_id, result)
776
+ if (!isDelegateEvent) await this._writeResult(request_id, result)
640
777
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
641
778
  try {
642
779
  await unlink(fp)