@cocorograph/hub-agent 0.8.43 → 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.43",
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,27 +3823,7 @@ 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
3829
  ?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
@@ -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)
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",