@cocorograph/hub-agent 0.8.42 → 0.8.44

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.42",
3
+ "version": "0.8.44",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -439,6 +439,7 @@ export class ClaudeStreamSession {
439
439
  onPermissionCancel,
440
440
  onExit,
441
441
  onError,
442
+ onOptions,
442
443
  onReap,
443
444
  onTurnSettled,
444
445
  }) {
@@ -481,6 +482,7 @@ export class ClaudeStreamSession {
481
482
  this.onPermissionCancel = onPermissionCancel
482
483
  this.onExit = onExit
483
484
  this.onError = onError
485
+ this.onOptions = onOptions
484
486
  /** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
485
487
  this.onReap = onReap
486
488
  /** RC-4: result を届けずにターンが終わった (abort/異常終了) ときに呼ぶコールバック。
@@ -551,6 +553,11 @@ export class ClaudeStreamSession {
551
553
  /** この session オブジェクトの世代 (T06603)。上記 nextQueueGeneration の説明を参照。
552
554
  * **同一プロセス内の順序のみ**を保証する (プロセス間は backend の接続フェンスが担う)。 */
553
555
  this._queueGeneration = nextQueueGeneration()
556
+ /** model / permissionMode の「次ターン desired」authority identity。
557
+ * UUID は同一 session object の一致確認だけに使い、大小・生成順は比較しない。 */
558
+ this._optionsInstanceId = randomUUID()
559
+ /** 上記instance内だけで比較するCAS revision。queue seq/generationとは独立。 */
560
+ this._optionsRevision = 0
554
561
  /** 配達確認の再表明用 (T06603): この session が受理 (キュー投入 or 直接配送) した
555
562
  * input_id の直近リング。`claude.input.ack` は一度きりのユニキャストで、WS 切断が
556
563
  * 30s を超えると ws-client の ctrl バッファから破棄され二度と再送されない。その結果
@@ -988,12 +995,9 @@ export class ClaudeStreamSession {
988
995
  * browser 接続へライブに流れる (= 通常の生成中表示と同じ)。再アタッチ前の確定分は
989
996
  * browser 側の jsonl hydrate (history.request) で復元するため、ここでは replay しない。
990
997
  *
991
- * 改修5 (2026-05-29): モデル/権限/拡張思考のターン切替。再アタッチ時に opts で
992
- * 新しい値が渡され、現在値と異なれば applyRuntimeOptions で反映する。これにより
993
- * 入力欄下バッジの変更が「同一セッション(常駐 query)を維持したまま次ターンから」
994
- * 効くようになる。従来は reattach が stream_id だけ差し替えていたため、起動済み
995
- * query の model/permission/thinking は初期値のまま変わらなかった (バッジが効かない
996
- * 不具合の原因)。 */
998
+ * 通常 attach は購読 + current snapshot 取得専用。model / permissionMode は CAS request
999
+ * (`claude.session.options.set`) だけが変更できる。maxThinkingTokens / effort は今回の
1000
+ * authority 対象外なので、既存 attach 反映を維持する。 */
997
1001
  reattach(stream_id, opts = undefined) {
998
1002
  this.stream_id = stream_id
999
1003
  // 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
@@ -1005,7 +1009,12 @@ export class ClaudeStreamSession {
1005
1009
  clearTimeout(this._idleTimer)
1006
1010
  this._idleTimer = null
1007
1011
  }
1008
- if (opts) this.applyRuntimeOptions(opts)
1012
+ if (opts) {
1013
+ this.applyRuntimeOptions({
1014
+ maxThinkingTokens: opts.maxThinkingTokens,
1015
+ effort: opts.effort,
1016
+ })
1017
+ }
1009
1018
  // キュー再表示バグ修正 (0.6.30): 再アタッチした端末は queue_state のライブ配信を
1010
1019
  // 取りこぼしている (jsonl hydrate にはキュー状態が含まれない)。現在の pending を
1011
1020
  // force で再 emit し、再表示端末・後から接続した端末の送信待ちチップを復元する。
@@ -1064,11 +1073,13 @@ export class ClaudeStreamSession {
1064
1073
  * maxThinkingTokens に 0/null が来たら次 spawn 時に budget オフ (thinking 未指定) となる。 */
1065
1074
  applyRuntimeOptions({ model, permissionMode, maxThinkingTokens, effort } = {}) {
1066
1075
  const q = this._residentQuery
1076
+ let desiredOptionsChanged = false
1067
1077
  // モデル
1068
1078
  if (model !== undefined) {
1069
1079
  const next = model || null
1070
1080
  if (next !== this.model) {
1071
1081
  this.model = next
1082
+ desiredOptionsChanged = true
1072
1083
  if (q && typeof q.setModel === "function") {
1073
1084
  q.setModel(next || undefined).catch((err) =>
1074
1085
  this.logger?.warn(
@@ -1084,6 +1095,7 @@ export class ClaudeStreamSession {
1084
1095
  const next = permissionMode || null
1085
1096
  if (next !== this.permissionMode) {
1086
1097
  this.permissionMode = next
1098
+ desiredOptionsChanged = true
1087
1099
  // setPermissionMode は有効な mode を要求する。null/空 (=未指定へ戻す) は
1088
1100
  // SDK 側に「解除」API が無いため、保持値の更新のみ (次回 spawn で既定に従う)。
1089
1101
  if (next && q && typeof q.setPermissionMode === "function") {
@@ -1116,6 +1128,84 @@ export class ClaudeStreamSession {
1116
1128
  if (effort !== undefined) {
1117
1129
  this.effort = effort || null
1118
1130
  }
1131
+ if (desiredOptionsChanged) {
1132
+ this._optionsRevision += 1
1133
+ try {
1134
+ this.onOptions?.(this.optionsSnapshot())
1135
+ } catch (err) {
1136
+ this.logger?.warn?.(
1137
+ { err: err?.message, stream_id: this.stream_id },
1138
+ "onOptions callback threw",
1139
+ )
1140
+ }
1141
+ }
1142
+ }
1143
+
1144
+ /** Exact-base CAS で model / permissionMode の次ターン desired を変更する。 */
1145
+ setSessionOptions({ baseOptionsInstanceId, baseOptionsRevision, patch }) {
1146
+ const current = () => this.optionsSnapshot()
1147
+ const hasModel = patch && Object.hasOwn(patch, "model")
1148
+ const hasPermissionMode =
1149
+ patch && Object.hasOwn(patch, "permission_mode")
1150
+ if (
1151
+ !patch ||
1152
+ typeof patch !== "object" ||
1153
+ Array.isArray(patch) ||
1154
+ Object.keys(patch).length === 0 ||
1155
+ Object.keys(patch).some(
1156
+ (key) => key !== "model" && key !== "permission_mode",
1157
+ ) ||
1158
+ !(hasModel || hasPermissionMode) ||
1159
+ (hasModel &&
1160
+ !(patch.model === null || typeof patch.model === "string")) ||
1161
+ (hasPermissionMode &&
1162
+ !(
1163
+ patch.permission_mode === null ||
1164
+ typeof patch.permission_mode === "string"
1165
+ ))
1166
+ ) {
1167
+ return { outcome: "invalid_patch", current: current() }
1168
+ }
1169
+ if (
1170
+ baseOptionsInstanceId !== this._optionsInstanceId ||
1171
+ !Number.isSafeInteger(baseOptionsRevision) ||
1172
+ baseOptionsRevision !== this._optionsRevision
1173
+ ) {
1174
+ return { outcome: "conflict", current: current() }
1175
+ }
1176
+
1177
+ // attach/configと同じく空文字はdefault(null)。比較前に正規化してnull状態のnoopを保つ。
1178
+ const normalizedPatch = {
1179
+ ...(hasModel ? { model: patch.model || null } : {}),
1180
+ ...(hasPermissionMode
1181
+ ? { permission_mode: patch.permission_mode || null }
1182
+ : {}),
1183
+ }
1184
+ const nextModel = hasModel ? normalizedPatch.model : this.model
1185
+ const nextPermissionMode =
1186
+ hasPermissionMode
1187
+ ? normalizedPatch.permission_mode
1188
+ : this.permissionMode
1189
+ if (nextModel === this.model && nextPermissionMode === this.permissionMode) {
1190
+ return { outcome: "noop", current: current() }
1191
+ }
1192
+ this.applyRuntimeOptions({
1193
+ ...(hasModel ? { model: normalizedPatch.model } : {}),
1194
+ ...(hasPermissionMode
1195
+ ? { permissionMode: normalizedPatch.permission_mode }
1196
+ : {}),
1197
+ })
1198
+ return { outcome: "applied", current: current() }
1199
+ }
1200
+
1201
+ /** 次ターンに使う desired options。SDK setter の成功実測値ではない。 */
1202
+ optionsSnapshot() {
1203
+ return {
1204
+ model: this.model,
1205
+ permission_mode: this.permissionMode,
1206
+ options_instance_id: this._optionsInstanceId,
1207
+ options_revision: this._optionsRevision,
1208
+ }
1119
1209
  }
1120
1210
 
1121
1211
  /** モデルが effort / adaptive thinking 対応かどうか。
@@ -2534,12 +2624,10 @@ export class ClaudeStreamBridge extends EventEmitter {
2534
2624
  // 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
2535
2625
  // 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
2536
2626
  // (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
2537
- // 改修5: 再アタッチ時に model/permission/maxThinkingTokens を引き継ぎ反映する。
2538
- // browser はバッジ変更時に新しい値を載せた claude.attach を同一 resume で送る
2539
- // ため、ここで適用すると常駐 query を維持したまま次ターンから切り替わる。
2627
+ // 通常 attach は購読 + current snapshot 取得だけ。旧 frontend は capability flag を
2628
+ // 送らず model/permission を載せるが、新 agent は live session では fail-closed で無視する。
2629
+ // maxThinkingTokens / effort は model/permission authority の対象外なので既存反映を維持。
2540
2630
  live.reattach(stream_id, {
2541
- model,
2542
- permissionMode,
2543
2631
  maxThinkingTokens:
2544
2632
  typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
2545
2633
  effort,
@@ -2562,6 +2650,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2562
2650
  resuming: true,
2563
2651
  reattached: true,
2564
2652
  turn_active: live._busy === true,
2653
+ ...live.optionsSnapshot(),
2565
2654
  }
2566
2655
  }
2567
2656
  }
@@ -2635,6 +2724,14 @@ export class ClaudeStreamBridge extends EventEmitter {
2635
2724
  error: err?.message || String(err),
2636
2725
  })
2637
2726
  },
2727
+ onOptions: (snapshot) => {
2728
+ // bridge 内部イベントは wire type を持たない。wire 契約は main builder に閉じる。
2729
+ this.emit("sessionoptions", {
2730
+ stream_id: session.stream_id,
2731
+ session_id: session.sessionId,
2732
+ ...snapshot,
2733
+ })
2734
+ },
2638
2735
  onReap: (info) => {
2639
2736
  // ターン完走後の遅延クローズ、または R1(a) 子プロセス死亡時の即 evict。Map / 索引から
2640
2737
  // 撤去し exit を emit する。多端末共有では複数端末キーが Map に載っているのでまとめて外す。
@@ -2680,9 +2777,39 @@ export class ClaudeStreamBridge extends EventEmitter {
2680
2777
  stream_id,
2681
2778
  resuming: !!resumeSessionId,
2682
2779
  turn_active: session._busy === true,
2780
+ ...session.optionsSnapshot(),
2683
2781
  }
2684
2782
  }
2685
2783
 
2784
+ /** browser の session options CAS request。常に current snapshot 付き outcome を返す。 */
2785
+ setSessionOptions({
2786
+ stream_id,
2787
+ session_id,
2788
+ base_options_instance_id,
2789
+ base_options_revision,
2790
+ patch,
2791
+ }) {
2792
+ const session = this.sessions.get(stream_id)
2793
+ if (!session) {
2794
+ return { outcome: "stream_missing", current: null }
2795
+ }
2796
+ // CAS操作もpermission reply/inputと同じユーザー活性。dead-terminal GCとwarm LRUを更新する。
2797
+ session.touch(stream_id)
2798
+ // fresh は SDK init 前だけ sessionId=null。同じ stream なら CAS 可能だが、session group の
2799
+ // identity がまだ無いため他端末はこの fresh session を購読できない。
2800
+ if (session.sessionId !== session_id) {
2801
+ return {
2802
+ outcome: "session_mismatch",
2803
+ current: session.optionsSnapshot(),
2804
+ }
2805
+ }
2806
+ return session.setSessionOptions({
2807
+ baseOptionsInstanceId: base_options_instance_id,
2808
+ baseOptionsRevision: base_options_revision,
2809
+ patch,
2810
+ })
2811
+ }
2812
+
2686
2813
  /**
2687
2814
  * browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。
2688
2815
  *
package/src/main.mjs CHANGED
@@ -978,6 +978,9 @@ export async function startDaemon({ version, claudeSdk } = {}) {
978
978
  claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
979
979
  client.send({ type: "claude.exit", stream_id, code, reason, session_id })
980
980
  })
981
+ claudeBridge.on("sessionoptions", (message) => {
982
+ client.send(buildClaudeSessionOptionsPayload(message))
983
+ })
981
984
  claudeBridge.on("turnsettled", ({ cwd }) => {
982
985
  // RC-4: result を届けずにターンが終わった (abort/異常終了)。チャット信号の
983
986
  // status=processing を waiting に落として三点リーダー/ステータスドットの固着を防ぐ
@@ -2052,6 +2055,58 @@ export function buildCodexChatPatch(status, contextPct, threadId, isAuthority) {
2052
2055
  return patch
2053
2056
  }
2054
2057
 
2058
+ /** 次ターンに使う desired options。現在ターンの setter 成功を観測した値ではない。 */
2059
+ function desiredOptionsWire(snapshot) {
2060
+ // stream_missing はcurrent不明。null(default)と偽装せずoptions fields自体を省く。
2061
+ if (!snapshot) return {}
2062
+ return {
2063
+ model: snapshot?.model ?? null,
2064
+ permission_mode: snapshot?.permission_mode ?? null,
2065
+ options_instance_id: snapshot?.options_instance_id,
2066
+ options_revision: snapshot?.options_revision,
2067
+ }
2068
+ }
2069
+
2070
+ /** claude.ready の wire payload。bridge attach info の options snapshot を透過する。 */
2071
+ export function buildClaudeReadyPayload(streamId, info) {
2072
+ return {
2073
+ type: "claude.ready",
2074
+ stream_id: streamId,
2075
+ resuming: info?.resuming === true,
2076
+ ...desiredOptionsWire(info),
2077
+ turn_active: info?.turn_active === true,
2078
+ history_pushed: info?.resuming === true,
2079
+ supports_queue_input_id: true,
2080
+ supports_queue_snapshot: true,
2081
+ supports_session_options_cas: true,
2082
+ }
2083
+ }
2084
+
2085
+ /** bridge internal options event → session fanout wire。 */
2086
+ export function buildClaudeSessionOptionsPayload(message) {
2087
+ return {
2088
+ type: "claude.session.options",
2089
+ stream_id: message.stream_id,
2090
+ session_id: message.session_id ?? null,
2091
+ ...desiredOptionsWire(message),
2092
+ }
2093
+ }
2094
+
2095
+ /** options CAS result は要求元 stream だけへ返す。 */
2096
+ export function buildClaudeSessionOptionsResultPayload(
2097
+ streamId,
2098
+ requestId,
2099
+ result,
2100
+ ) {
2101
+ return {
2102
+ type: "claude.session.options.result",
2103
+ stream_id: streamId,
2104
+ request_id: requestId,
2105
+ outcome: result.outcome,
2106
+ ...desiredOptionsWire(result.current),
2107
+ }
2108
+ }
2109
+
2055
2110
  /** codex.ready の wire payload を組み立てる。history は同じ配列参照を保持し、
2056
2111
  * started_history_index が指す位置を main 側の加工でずらさない。表示を保持する reconnect
2057
2112
  * (`info.subagents` 未定義) では subagents キー自体を省略し、空 snapshot で既存表示を消さない。 */
@@ -3768,34 +3823,14 @@ async function dispatch(msg, ctx) {
3768
3823
  effort: msg.effort || ctx.config?.claude_effort || null,
3769
3824
  resumeSessionId,
3770
3825
  })
3771
- ctx.client.send({
3772
- type: "claude.ready",
3773
- stream_id,
3774
- resuming: info.resuming,
3775
- // 再接続時の生成状態は resident session を保持する hub-agent が正本。
3776
- // jsonl 履歴末尾の推測だけでは「実際は busy なのに UI は idle」となり、
3777
- // 送信ボタンが楽観バブルを作る一方 agent はキューへ積む二重表示が起きる。
3778
- turn_active: info.turn_active === true,
3779
- history_pushed: resumeSessionId !== null,
3780
- // id 空間統一 (2026-07-08): この agent は browser の input_id をキュー id として
3781
- // 採用し、楽観送信待ちアイテムの確認前キャンセルに対応することを通知する capability。
3782
- // 旧 agent にはこのフラグが無く、browser は楽観アイテムの×を出さない (ゴースト
3783
- // 実行防止のフォールバック)。
3784
- supports_queue_input_id: true,
3785
- // pull 型再同期 capability (T06603, 2026-07-25): この agent は
3786
- // claude.queue.request に claude.queue.response で答え、queue_state に
3787
- // 版番号 (seq) と受理済み input_id (accepted) を載せることを通知する。
3788
- // 旧 agent はこのフラグが無く、browser は従来の「配信到達に依存する」
3789
- // 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
3790
- supports_queue_snapshot: true,
3791
- })
3826
+ ctx.client.send(buildClaudeReadyPayload(stream_id, info))
3792
3827
  if (resumeSessionId) {
3793
3828
  void ctx.siblingInjectBridge
3794
- ?.replayActiveDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
3829
+ ?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
3795
3830
  .catch((err) => {
3796
3831
  ctx.logger?.warn?.(
3797
3832
  { err: err?.message || String(err), stream_id, session_id: resumeSessionId },
3798
- "failed to replay active delegations after Claude attach",
3833
+ "failed to replay delegations after Claude attach",
3799
3834
  )
3800
3835
  })
3801
3836
  }
@@ -3874,6 +3909,23 @@ async function dispatch(msg, ctx) {
3874
3909
  }
3875
3910
  return
3876
3911
  }
3912
+ case "claude.session.options.set": {
3913
+ const result = ctx.claudeBridge?.setSessionOptions({
3914
+ stream_id: msg.stream_id,
3915
+ session_id: msg.session_id ?? null,
3916
+ base_options_instance_id: msg.base_options_instance_id,
3917
+ base_options_revision: msg.base_options_revision,
3918
+ patch: msg.patch,
3919
+ }) || { outcome: "stream_missing", current: null }
3920
+ ctx.client.send(
3921
+ buildClaudeSessionOptionsResultPayload(
3922
+ msg.stream_id,
3923
+ msg.request_id,
3924
+ result,
3925
+ ),
3926
+ )
3927
+ return
3928
+ }
3877
3929
  case "claude.input":
3878
3930
  // message: { role: 'user', content: string | Array } を期待
3879
3931
  handleClaudeChatInput(msg, ctx)
@@ -75,20 +75,68 @@ export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
75
75
  export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
76
76
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
77
77
  const DELEGATE_PROMPT_MAX_LENGTH = 64_000
78
+ const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
79
+ const TERMINAL_DELEGATE_AGENT_MAX_LENGTH = 200
80
+ const TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH = 2_000
81
+ const DELEGATION_ID_MAX_LENGTH = 200
78
82
 
79
83
  const execFile = promisify(execFileCallback)
80
84
 
81
- function truncateDelegatePrompt(prompt) {
82
- if (prompt.length <= DELEGATE_PROMPT_MAX_LENGTH) return prompt
83
- let end = DELEGATE_PROMPT_MAX_LENGTH
84
- const lastCodeUnit = prompt.charCodeAt(end - 1)
85
+ function truncateString(value, maxLength, truncationNote) {
86
+ if (value.length <= maxLength) return value
87
+ let end = maxLength
88
+ const lastCodeUnit = value.charCodeAt(end - 1)
85
89
  if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1
86
- return (
87
- prompt.slice(0, end) +
88
- `\n\n[依頼本文はここで打ち切られています(全 ${prompt.length} 文字)]`
90
+ return value.slice(0, end) + truncationNote(value.length)
91
+ }
92
+
93
+ function truncateDelegatePrompt(prompt) {
94
+ return truncateString(
95
+ prompt,
96
+ DELEGATE_PROMPT_MAX_LENGTH,
97
+ (length) => `\n\n[依頼本文はここで打ち切られています(全 ${length} 文字)]`,
89
98
  )
90
99
  }
91
100
 
101
+ function compactTerminalEvent(event, includePrompt = false) {
102
+ const storedEvent = {}
103
+ for (const field of [
104
+ "type",
105
+ "subtype",
106
+ "delegation_id",
107
+ "phase",
108
+ "background",
109
+ "timestamp",
110
+ ]) {
111
+ if (event[field] !== undefined) storedEvent[field] = event[field]
112
+ }
113
+
114
+ if (typeof event.agent === "string") {
115
+ storedEvent.agent = truncateString(
116
+ event.agent,
117
+ TERMINAL_DELEGATE_AGENT_MAX_LENGTH,
118
+ (length) => `\n\n[エージェント名はここで打ち切られています(全 ${length} 文字)]`,
119
+ )
120
+ }
121
+ if (typeof event.description === "string") {
122
+ storedEvent.description = truncateString(
123
+ event.description,
124
+ TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH,
125
+ (length) => `\n\n[説明はここで打ち切られています(全 ${length} 文字)]`,
126
+ )
127
+ }
128
+ if (includePrompt && typeof event.prompt === "string") {
129
+ storedEvent.prompt = truncateString(
130
+ event.prompt,
131
+ TERMINAL_DELEGATE_PROMPT_MAX_LENGTH,
132
+ (length) =>
133
+ `\n\n[終了済み委譲の依頼本文はここで打ち切られています(全 ${length} 文字)]`,
134
+ )
135
+ }
136
+
137
+ return storedEvent
138
+ }
139
+
92
140
  /**
93
141
  * `ps` の起動時刻を送受信双方で比較する契約形へ正規化する。
94
142
  * 先頭・末尾の Unicode 空白だけを除去し、日時本体の内部空白は変更しない。
@@ -312,7 +360,7 @@ export class SiblingInjectBridge {
312
360
  this._pidStartedAt = pidStartedAt
313
361
  /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
314
362
  this._activeDelegations = new Map()
315
- /** @type {Map<string, Map<string, number>>} 終端→開始の逆転で遅着 started を復活させない墓標。 */
363
+ /** @type {Map<string, Map<string, {startedEvent?: object, terminalEvent: object, startedAt?: number, recordedAt: number}>>} 終了済み委譲の再送情報を兼ねた墓標。 */
316
364
  this._terminalDelegations = new Map()
317
365
  }
318
366
 
@@ -329,8 +377,8 @@ export class SiblingInjectBridge {
329
377
  if (entries.size === 0) this._activeDelegations.delete(sessionKey)
330
378
  }
331
379
  for (const [sessionKey, entries] of this._terminalDelegations) {
332
- for (const [delegationId, recordedAt] of entries) {
333
- if (recordedAt < cutoff) entries.delete(delegationId)
380
+ for (const [delegationId, entry] of entries) {
381
+ if (entry.recordedAt < cutoff) entries.delete(delegationId)
334
382
  }
335
383
  if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
336
384
  }
@@ -340,6 +388,10 @@ export class SiblingInjectBridge {
340
388
  this._pruneDelegations()
341
389
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
342
390
  if (event.phase !== "started") {
391
+ const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
392
+ const existingTerminalEntry = this._terminalDelegations
393
+ .get(sessionKey)
394
+ ?.get(event.delegation_id)
343
395
  this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
344
396
  if (this._activeDelegations.get(sessionKey)?.size === 0) {
345
397
  this._activeDelegations.delete(sessionKey)
@@ -350,7 +402,21 @@ export class SiblingInjectBridge {
350
402
  this._terminalDelegations.set(sessionKey, terminalEntries)
351
403
  }
352
404
  terminalEntries.delete(event.delegation_id)
353
- terminalEntries.set(event.delegation_id, this._now())
405
+ terminalEntries.set(event.delegation_id, {
406
+ ...(activeEntry
407
+ ? {
408
+ startedEvent: compactTerminalEvent(activeEntry.event, true),
409
+ startedAt: activeEntry.recordedAt,
410
+ }
411
+ : existingTerminalEntry?.startedEvent && existingTerminalEntry.startedAt !== undefined
412
+ ? {
413
+ startedEvent: existingTerminalEntry.startedEvent,
414
+ startedAt: existingTerminalEntry.startedAt,
415
+ }
416
+ : {}),
417
+ terminalEvent: compactTerminalEvent(event),
418
+ recordedAt: this._now(),
419
+ })
354
420
  while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
355
421
  terminalEntries.delete(terminalEntries.keys().next().value)
356
422
  }
@@ -394,14 +460,35 @@ export class SiblingInjectBridge {
394
460
  }
395
461
  }
396
462
 
397
- /** attach/再接続時に同じ cwd+session の進行中委譲だけを再送する。 */
398
- async replayActiveDelegations(cwd, sessionId) {
463
+ /** attach/再接続時に同じ cwd+session の進行中・終了済み委譲を開始順で再送する。 */
464
+ async replayDelegations(cwd, sessionId) {
399
465
  if (!cwd || !sessionId) return
400
466
  this._pruneDelegations()
401
467
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
402
- const entries = this._activeDelegations.get(sessionKey)
403
- if (!entries) return
404
- for (const [delegationId, entry] of [...entries]) {
468
+ const replayEntries = [
469
+ ...[...(this._activeDelegations.get(sessionKey) || [])].map(([delegationId, entry]) => ({
470
+ delegationId,
471
+ entry,
472
+ kind: "active",
473
+ startedAt: entry.recordedAt,
474
+ })),
475
+ ...[...(this._terminalDelegations.get(sessionKey) || [])]
476
+ .filter(([, entry]) => entry.startedEvent && entry.startedAt !== undefined)
477
+ .map(([delegationId, entry]) => ({
478
+ delegationId,
479
+ entry,
480
+ kind: "terminal",
481
+ startedAt: entry.startedAt,
482
+ })),
483
+ ].sort((a, b) => a.startedAt - b.startedAt)
484
+
485
+ for (const { delegationId, entry, kind } of replayEntries) {
486
+ if (kind === "terminal") {
487
+ if (this._terminalDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
488
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.startedEvent)
489
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.terminalEvent)
490
+ continue
491
+ }
405
492
  const alive = await this._delegateProcessAlive(entry.event)
406
493
  // 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
407
494
  if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
@@ -571,6 +658,7 @@ export class SiblingInjectBridge {
571
658
  phase,
572
659
  agent,
573
660
  description,
661
+ background,
574
662
  prompt,
575
663
  timestamp,
576
664
  pid,
@@ -590,10 +678,12 @@ export class SiblingInjectBridge {
590
678
  const badDelegateBody =
591
679
  typeof delegation_id !== "string" ||
592
680
  !delegation_id ||
681
+ delegation_id.length > DELEGATION_ID_MAX_LENGTH ||
593
682
  !delegatePhases.has(phase) ||
594
683
  (phase === "started" && (typeof agent !== "string" || !agent)) ||
595
684
  (agent !== undefined && typeof agent !== "string") ||
596
685
  (description !== undefined && typeof description !== "string") ||
686
+ (background !== undefined && typeof background !== "boolean") ||
597
687
  (prompt !== undefined && typeof prompt !== "string") ||
598
688
  (pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
599
689
  (pid_started_at !== undefined && typeof pid_started_at !== "string") ||
@@ -656,6 +746,7 @@ export class SiblingInjectBridge {
656
746
  phase,
657
747
  ...(agent !== undefined ? { agent } : {}),
658
748
  ...(description !== undefined ? { description } : {}),
749
+ ...(background !== undefined ? { background } : {}),
659
750
  ...(phase === "started" && prompt ? { prompt: truncateDelegatePrompt(prompt) } : {}),
660
751
  ...(pid !== undefined ? { pid } : {}),
661
752
  ...(pid_started_at !== undefined ? { pid_started_at } : {}),
package/src/ws-client.mjs CHANGED
@@ -56,6 +56,9 @@ const MIN_BACKOFF_AFTER_5XX_MS = 5_000
56
56
  // 「送信待ち(未配信)」が消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
57
57
  // - claude/codex の event / ready / exit / error / permission:
58
58
  // 喪失すると新規セッションの楽観バブルが本物へ昇格できず、再 attach も固着する。
59
+ // - claude.session.options.result: options.set は再要求型RPCではなく明示intentの終端。
60
+ // agent→Hub切断中のapplied resultも保持し、request_idで同streamへ戻す。frontend側の
61
+ // stale-stream/request mismatch/10s timeoutが遅延結果を無害化する(LWW圧縮はしない)。
59
62
  // - claude.jsonl.event / session.event: TUI と一覧の確定状態を復元する。
60
63
  // 高頻度の delta 系はここに積まず (下の isEphemeral*DeltaEvent で除外)、低頻度な
61
64
  // ack/event がリング evict されるのを防ぐ (ack/event は 1 件ずつ意味を持つ)。
@@ -63,6 +66,8 @@ const BUFFERED_CTRL_TYPES = new Set([
63
66
  "claude.jsonl.event",
64
67
  "claude.event",
65
68
  "claude.ready",
69
+ "claude.session.options",
70
+ "claude.session.options.result",
66
71
  "claude.exit",
67
72
  "claude.error",
68
73
  "claude.permission.request",