@cocorograph/hub-agent 0.8.53 → 0.8.54

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.53",
3
+ "version": "0.8.54",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -40,13 +40,19 @@ import { nextSessionSeq } from "./session-seq.mjs"
40
40
  * この cwd の capability トークンを乗せる。ディスクには書かず、プロセス環境変数
41
41
  * としてのみ渡す (src/sibling-token.mjs のコメント参照)。
42
42
  * @param {string} cwd
43
- * @returns {{COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string}}
43
+ * @returns {NodeJS.ProcessEnv & {COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string,
44
+ * COCKPIT_DELEGATE_EVENT_ACK: string}}
44
45
  */
45
46
  function siblingEnvFor(cwd) {
46
47
  return {
47
48
  ...process.env,
48
49
  COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
49
50
  COCKPIT_SIBLING_CWD: cwd,
51
+ // hub-agent が spawn した Claude 配下 producer だけが delegate-event の受理 ACK を
52
+ // 要求するための capability。能力は spawn 時点で固定されるため、hub-agent を旧版へ
53
+ // ロールバックしても既存 producer は ACK を要求し続け、timeout し得る。
54
+ // 旧 bundle はこの値を無視し、従来どおり fire-and-forget のまま動く。
55
+ COCKPIT_DELEGATE_EVENT_ACK: "1",
50
56
  // ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
51
57
  // 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
52
58
  COCKPIT_SIBLING_SELF_KIND: "claude",
@@ -37,10 +37,13 @@
37
37
  * - 安全な再起動: `{op:"restart"}` → `{ok:true, restart_pending:true}`。本文と token を持たず、
38
38
  * 所有者限定の IPC directory/request file (0700/0600) だけを境界として扱う。
39
39
  * 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
40
- * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
40
+ * `<request_id>.result.json` = `{ok, error?, duplicate?, delivered_to?, candidates?, live_sessions?, hint?,
41
41
  * candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
42
42
  * 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
43
- * `delegate-event` だけは fire-and-forget のため応答ファイルを書かない。
43
+ * 同一 request_id の再投入は二重処理防止で無視するため、意味上の再送は新しい transport ID を使う。
44
+ * `delegate-event` の `request_ack` は省略または literal true だけが正規入力。true のとき受理結果を
45
+ * 書き、不正値でも property があれば明示エラーを書く。未指定の旧 producer は fire-and-forget の
46
+ * ままなので、新旧 bundle / hub-agent のローリング互換を保つ。
44
47
  *
45
48
  * ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
46
49
  * - `delivered` … userMessage echo まで確認済み (ok:true)
@@ -82,6 +85,7 @@ const SIBLING_SWEEP_DEBOUNCE_MS = 25
82
85
 
83
86
  export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
84
87
  export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
88
+ export const SIBLING_RESULT_RETENTION_MS = 24 * 60 * 60 * 1000
85
89
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
86
90
  const DELEGATE_PROMPT_MAX_LENGTH = 64_000
87
91
  const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
@@ -491,8 +495,8 @@ export class SiblingInjectBridge {
491
495
  }
492
496
  }
493
497
 
494
- _recordDelegateEvent(cwd, sessionId, event) {
495
- this._pruneDelegations()
498
+ _recordDelegateEvent(cwd, sessionId, event, { prune = true } = {}) {
499
+ if (prune) this._pruneDelegations()
496
500
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
497
501
  if (event.phase !== "started") {
498
502
  const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
@@ -542,11 +546,40 @@ export class SiblingInjectBridge {
542
546
  }
543
547
  }
544
548
 
545
- _acceptsDelegateEvent(cwd, sessionId, event) {
549
+ _delegateEventAcceptance(cwd, sessionId, event) {
546
550
  this._pruneDelegations()
547
- if (event.phase !== "started") return true
548
551
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
549
- return !this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)
552
+ const terminal = this._terminalDelegations.get(sessionKey)?.get(event.delegation_id)
553
+ if (event.phase === "started") {
554
+ if (terminal) return "stale"
555
+ return this._activeDelegations.get(sessionKey)?.has(event.delegation_id)
556
+ ? "duplicate"
557
+ : "accept"
558
+ }
559
+ if (!terminal) return "accept"
560
+ // terminal 同士は後から届いた実イベントを最新終端として上書きできる。
561
+ // 例: replay が PID 消失から stopped を合成した後に producer の completed が届く。
562
+ return terminal.terminalEvent?.phase === event.phase ? "duplicate" : "accept"
563
+ }
564
+
565
+ _liveClaudeSession(cwd, sessionId) {
566
+ const matches = describeLiveSessionsUnion(this._bridges(), {
567
+ cwd,
568
+ cliKind: "claude",
569
+ }).filter((session) => session.session_id === sessionId)
570
+ if (matches.length === 1) return { status: "delivered", session: matches[0] }
571
+ return {
572
+ status: matches.length > 1 ? "ambiguous" : "not_found",
573
+ ...(matches.length ? { candidates: matches } : {}),
574
+ }
575
+ }
576
+
577
+ _delegateTargetFailure(targetResult) {
578
+ return {
579
+ ok: false,
580
+ error: AMBIGUOUS_TARGET_ERROR,
581
+ ...(targetResult?.candidates ? { candidates: targetResult.candidates } : {}),
582
+ }
550
583
  }
551
584
 
552
585
  async _delegateProcessAlive(event) {
@@ -699,7 +732,26 @@ export class SiblingInjectBridge {
699
732
  return
700
733
  }
701
734
  for (const n of names) {
702
- if (n.endsWith(".json") && !n.endsWith(".result.json")) {
735
+ if (n.endsWith(".result.json")) {
736
+ const fp = path.join(this.dir, n)
737
+ try {
738
+ const info = await lstat(fp)
739
+ const uid = process.getuid?.()
740
+ if (
741
+ !info.isSymbolicLink() &&
742
+ info.isFile() &&
743
+ uid != null &&
744
+ info.uid === uid &&
745
+ // 壁時計が前へ step すると保持期間が短くなるが、result は再配送の正本ではなく
746
+ // 呼び出し側が回収する一時 ACK なので、24時間経過扱いでの掃除を許容する。
747
+ info.mtimeMs < this._now() - SIBLING_RESULT_RETENTION_MS
748
+ ) {
749
+ await unlink(fp)
750
+ }
751
+ } catch {
752
+ /* 同時に呼び出し側が回収済みなら no-op */
753
+ }
754
+ } else if (n.endsWith(".json")) {
703
755
  await this._ingest(n, { startup }).catch((err) => {
704
756
  this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
705
757
  })
@@ -709,7 +761,8 @@ export class SiblingInjectBridge {
709
761
 
710
762
  /**
711
763
  * 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
712
- * 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
764
+ * 成否によらず要求ファイルは削除する。delegate-event は request_ack:true または
765
+ * malformed な request_ack property がある場合だけ結果ファイルも書く。
713
766
  * @param {string} filename `<request_id>.json`
714
767
  */
715
768
  async _ingest(filename, { startup = false } = {}) {
@@ -729,8 +782,8 @@ export class SiblingInjectBridge {
729
782
  // ⚠️ **結果ファイルを必ず書く**。ここで書かずに戻ると、結果をポーリングしている
730
783
  // 呼び出し側 (cockpit_orch.py) には「タイムアウト」としか見えない。タイムアウトは
731
784
  // 「不達とは限らない」と扱う契約なので、拒否した事実が呼び出し側で不達に化ける
732
- // (=黙って捨てたのと同じになる)。delegate-event の送り手は結果を読まないため、
733
- // op を判別できないまま無条件に書いて構わない (余分な結果ファイルは stale 掃除の対象)。
785
+ // (=黙って捨てたのと同じになる)。ここでは本文を parse できず op / request_ack を
786
+ // 判別できないため無条件に書く。旧 producer 向けの余分な結果は stale 掃除の対象。
734
787
  await this._writeResult(request_id, {
735
788
  ok: false,
736
789
  error: "request_too_large",
@@ -778,6 +831,7 @@ export class SiblingInjectBridge {
778
831
  timestamp,
779
832
  pid,
780
833
  pid_started_at,
834
+ request_ack,
781
835
  } = body || {}
782
836
  // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
783
837
  // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
@@ -787,6 +841,7 @@ export class SiblingInjectBridge {
787
841
  const isList = op === "list"
788
842
  const isExplicit = op === "inject"
789
843
  const isDelegateEvent = op === "delegate-event"
844
+ const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
790
845
  const isRestart = op === "restart"
791
846
  let result
792
847
  const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
@@ -803,6 +858,7 @@ export class SiblingInjectBridge {
803
858
  (prompt !== undefined && typeof prompt !== "string") ||
804
859
  (pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
805
860
  (pid_started_at !== undefined && typeof pid_started_at !== "string") ||
861
+ (request_ack !== undefined && request_ack !== true) ||
806
862
  (timestamp !== undefined &&
807
863
  (typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
808
864
  const badBody = isDelegateEvent
@@ -892,21 +948,26 @@ export class SiblingInjectBridge {
892
948
  }
893
949
  // 墓標確認から配送成功時の台帳反映まで await を挟まない同期区間に閉じる。
894
950
  // 配送失敗した started は台帳へ残さず、終端後に遅着した started は配送前に拒否する。
895
- if (!this._acceptsDelegateEvent(cwd, target_session_id, event)) {
951
+ const acceptance = this._delegateEventAcceptance(cwd, target_session_id, event)
952
+ if (acceptance === "duplicate") {
953
+ // ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
954
+ // 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
955
+ const live = this._liveClaudeSession(cwd, target_session_id)
956
+ result = live.status === "delivered"
957
+ ? { ok: true, duplicate: true }
958
+ : this._delegateTargetFailure(live)
959
+ } else if (acceptance === "stale") {
896
960
  result = { ok: false, error: "stale_delegate_event" }
897
961
  } else {
898
962
  const r = this.claudeBridge?.emitToStream
899
963
  ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
900
964
  : { status: "not_found" }
901
965
  if (r.status === "delivered") {
902
- this._recordDelegateEvent(cwd, target_session_id, event)
966
+ // acceptance 直前に prune 済み。同じ同期区間で二重 prune しない。
967
+ this._recordDelegateEvent(cwd, target_session_id, event, { prune: false })
903
968
  result = { ok: true, delivered_to: r.session }
904
969
  } else {
905
- result = {
906
- ok: false,
907
- error: AMBIGUOUS_TARGET_ERROR,
908
- ...(r.candidates ? { candidates: r.candidates } : {}),
909
- }
970
+ result = this._delegateTargetFailure(r)
910
971
  }
911
972
  }
912
973
  }
@@ -1068,6 +1129,7 @@ export class SiblingInjectBridge {
1068
1129
  delivery_state: result.delivery_state,
1069
1130
  durable_state: result.durable_state,
1070
1131
  reason: result.reason,
1132
+ duplicate: result.duplicate,
1071
1133
  }
1072
1134
  : {
1073
1135
  error: result.error,
@@ -1077,7 +1139,9 @@ export class SiblingInjectBridge {
1077
1139
  }),
1078
1140
  },
1079
1141
  result.ok
1080
- ? result.delivery_state === "delivered"
1142
+ ? result.duplicate
1143
+ ? "sibling-inject duplicate acknowledged"
1144
+ : result.delivery_state === "delivered"
1081
1145
  ? "sibling-inject delivered"
1082
1146
  : result.delivery_state === "queued"
1083
1147
  ? "sibling-inject queued"
@@ -1085,7 +1149,11 @@ export class SiblingInjectBridge {
1085
1149
  : "sibling-inject failed",
1086
1150
  )
1087
1151
  // startup sweep 由来の restart には待受者がいない。孤児 result を残さない。
1088
- if (!isDelegateEvent && !(isRestart && startup)) await this._writeResult(request_id, result)
1152
+ if ((!isDelegateEvent || hasRequestAck) && !(isRestart && startup)) {
1153
+ // delegate ACK は「対象 Claude stream の内部 event 経路へ受理」までの保証。
1154
+ // ブラウザ側がイベントを登録・描画したことまでは保証しない。
1155
+ await this._writeResult(request_id, result)
1156
+ }
1089
1157
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
1090
1158
  try {
1091
1159
  await unlink(fp)