@cocorograph/hub-agent 0.8.83 → 0.8.85

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/src/main.mjs CHANGED
@@ -5,7 +5,6 @@
5
5
  * - plugin discovery + onAgentStart broadcast
6
6
  * - WS メッセージのディスパッチ:
7
7
  * - claude.* / codex.* → ClaudeStreamBridge / CodexStreamBridge (チャット)
8
- * - tmux.* → tmux module (セッション一覧・作成・削除)
9
8
  *
10
9
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
11
10
  */
@@ -69,11 +68,6 @@ import {
69
68
  import { listAgents } from "./agents.mjs"
70
69
  import { listSkills } from "./skills.mjs"
71
70
  import { readSidebarMessageSnapshot } from "./sidebar-message-snapshot.mjs"
72
- import {
73
- invalidateSessionCache,
74
- listSessionStates,
75
- StallTracker,
76
- } from "./state.mjs"
77
71
  import {
78
72
  DEFAULT_PROFILE_ID,
79
73
  defaultConfigDir,
@@ -98,49 +92,29 @@ import {
98
92
  profileHasKeychainCredentials,
99
93
  deleteKeychainCredentials,
100
94
  } from "./claude-credentials.mjs"
101
- import {
102
- buildClaudeCmd,
103
- buildCodexCmd,
104
- buildWorktreeIndex,
105
- createSession as createTmuxSession,
106
- createWorktreeDir,
107
- killManySessions,
108
- killSession as killTmuxSession,
109
- listSessions as listTmuxSessions,
110
- listWorktreeNameHistory,
111
- listWorktreeStubs,
112
- paneClaudeAliveOrUnknown,
113
- removeWorktree as removeWorktreeDir,
114
- setTmuxGlobalEnv,
115
- shouldLaunchPaneCli,
116
- validateInitialPrompt,
117
- } from "./tmux.mjs"
95
+ import { createWorktreeDir, listWorktreeNameHistory, removeWorktree as removeWorktreeDir } from "./tmux.mjs"
118
96
  import { expandTilde, prepareChatWorkspace, isCwdUnavailableError } from "./chat-workspace.mjs"
119
97
  import { buildChatSessionsSnapshot, refreshChatSessionsSignals } from "./chat-sessions-snapshot.mjs"
120
98
  import { chatTargetsCache } from "./chat-targets.mjs"
121
99
  import { ensureChatSession } from "./chat-session-starter.mjs"
122
100
  import { beginRemoval } from "./chat-removal-guard.mjs"
123
101
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
124
- import { describeLiveSessionsUnion, isSessionWarm } from "./live-sessions.mjs"
125
- import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
102
+ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
126
103
  import {
127
104
  boundSessionId,
128
105
  codexUsageFromAccountRateLimits,
129
106
  codexLiveContextForProducer,
130
107
  codexSessionUsages,
131
- codexTurnActiveForThread,
132
108
  codexUsageForThread,
133
109
  contextWindowSize,
134
110
  forgetCodexLiveTokenUsage,
135
111
  getSessionUsages,
136
112
  getUsage,
137
- isFreshUnboundBind,
138
113
  recordChatRateLimit,
139
114
  recordChatUsageSnapshot,
140
115
  readAccountUsageSnapshot,
141
116
  recordAccountUsageSnapshot,
142
117
  recordCodexLiveTokenUsage,
143
- turnActiveForCwd,
144
118
  } from "./usage.mjs"
145
119
  import {
146
120
  getPromptAcceptedAt,
@@ -153,12 +127,6 @@ import {
153
127
  hasRepresentativeRow,
154
128
  } from "./chat-signals.mjs"
155
129
  import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
156
- import {
157
- ReadinessTracker,
158
- busyChildCount,
159
- getPanePid,
160
- snapshotProcs,
161
- } from "./proc-introspect.mjs"
162
130
  import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
163
131
 
164
132
  export function resolveCodexLoginCommand(configDir, { isDefault = false, _resolveCodexBinary = resolveCodexBinary } = {}) {
@@ -203,127 +171,6 @@ export function resolveClaudeAttachOptions(config, msg = {}) {
203
171
  }
204
172
  }
205
173
 
206
- /**
207
- * `~/.hub/agent.json` の Claude CLI 設定から claude コマンド文字列を組み立てる。
208
- *
209
- * - enrollment 時に Hub から取得した `claude_model` / `claude_permission_mode` が
210
- * 入っていれば、それを使って `claude --continue --model ... || claude --model ...` を生成
211
- * - 両方空なら `undefined` を返す (createSession 内で DEFAULT_CLAUDE_CMD =
212
- * 環境変数 HUB_CLAUDE_CMD か最小デフォルト `claude --continue || claude` が使われる)
213
- *
214
- * cockpit 管理画面でユーザーが Hub 側で model / permission-mode を変更すると、
215
- * 次の enrollment / 再起動時に新しい値で組み立てられる。
216
- */
217
- function claudeCmdFromAgentConfig(config) {
218
- const cmd = buildClaudeCmd({
219
- model: config?.claude_model,
220
- permissionMode: config?.claude_permission_mode,
221
- })
222
- // buildClaudeCmd は config も env も空なら最小デフォルトを返す。
223
- // ここでは「明示的に Hub 側で何か指定されていた場合のみ上書き、なければ
224
- // createSession 側の DEFAULT_CLAUDE_CMD に委ねる」挙動にするため、
225
- // model も mode も空なら undefined を返す。
226
- const model = (config?.claude_model || "").trim()
227
- const mode = (config?.claude_permission_mode || "").trim()
228
- if (!model && !mode) return undefined
229
- return cmd
230
- }
231
-
232
- /**
233
- * `~/.hub/agent.json` の Codex CLI 設定 (codex_model / codex_effort) から
234
- * createTmuxSession の model/effort opts へのデフォルト値を取得する
235
- * (claudeCmdFromAgentConfig の Codex 版)。
236
- *
237
- * tmux.create_session / worktree.create の browser 側 msg.model / msg.effort が
238
- * 明示指定されていればそちらを優先し (呼び出し側で判定済み)、未指定のときだけ
239
- * このデフォルトにフォールバックする。空文字は「未指定」として undefined を返す
240
- * (buildCodexCmd 側は undefined を渡されても既定値へフォールバックするため安全)。
241
- */
242
- function codexDefaultsFromAgentConfig(config) {
243
- return {
244
- model: (config?.codex_model || "").trim() || undefined,
245
- effort: (config?.codex_effort || "").trim() || undefined,
246
- }
247
- }
248
-
249
- /**
250
- * プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
251
- *
252
- * 再作成された pane は **既定では素のシェル** (createSession が CLI を起動しない)。
253
- * アカウント切替は SDK チャット側の process.env と、tmux サーバーのグローバル環境
254
- * (syncTmuxProfileEnv の `set-environment -g`) で追従する。
255
- * `HUB_AGENT_TMUX_LAUNCH_CLI=1` で TUI を起動する運用の場合のみ、createSession 内の
256
- * injectConfigDirEnv が新しい CLAUDE_CONFIG_DIR をコマンドへ焼き込む。
257
- * initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
258
- * 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
259
- * cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
260
- * tmux サーバー未起動なら何もしない。
261
- */
262
- export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
263
- const result = { killed: [], recreated: [], failed: [] }
264
- const listSessionsForRestart = ctx._listTmuxSessions || listTmuxSessions
265
- const killSessionsForRestart = ctx._killManySessions || killManySessions
266
- const createSessionForRestart = ctx._createTmuxSession || createTmuxSession
267
- let sessions = []
268
- try {
269
- sessions = await listSessionsForRestart({ plugins: ctx.plugins, logger: ctx.logger })
270
- } catch (err) {
271
- ctx.logger?.debug?.(
272
- { err: err?.message || String(err) },
273
- "tmux list failed during profile switch (server not running?)",
274
- )
275
- return result
276
- }
277
- if (!sessions.length) return result
278
- const codexDefaults = codexDefaultsFromAgentConfig(ctx.config)
279
- let prebuiltCodexCmd
280
- const codexSessions = sessions.filter((s) => cliKindForSessionName(s.name) === "codex")
281
- let sessionsToRestart = sessions
282
- if (shouldLaunchPaneCli() && codexSessions.length) {
283
- try {
284
- prebuiltCodexCmd = buildCodexCmd({
285
- ...codexDefaults,
286
- _resolveCodexBinary: ctx._resolveCodexBinary,
287
- })
288
- } catch (err) {
289
- const reason = err?.message || String(err)
290
- result.failed.push(...codexSessions.map((s) => ({ name: s.name, reason })))
291
- const blocked = new Set(codexSessions.map((s) => s.name))
292
- sessionsToRestart = sessions.filter((s) => !blocked.has(s.name))
293
- }
294
- }
295
- if (!sessionsToRestart.length) return result
296
- const { killed } = await killSessionsForRestart(
297
- sessionsToRestart.map((s) => s.name),
298
- { logger: ctx.logger },
299
- )
300
- result.killed = killed
301
- for (const s of sessionsToRestart) {
302
- if (!killed.includes(s.name)) continue
303
- if (!s.cwd) {
304
- result.failed.push({ name: s.name, reason: "cwd unknown" })
305
- continue
306
- }
307
- try {
308
- const cliKind = cliKindForSessionName(s.name)
309
- await createSessionForRestart(s.name, s.cwd, {
310
- // cliKind はセッション名 (`<dir>__codex` か否か) だけで決まる。渡し忘れると
311
- // createSession が既定の claude として扱い、codex セッションの再生成でも
312
- // AGENTS.md ではなく CLAUDE.md を同期対象にしてしまう。
313
- cliKind,
314
- claudeCmd: claudeCmdFromAgentConfig(ctx.config),
315
- codexCmd: cliKind === "codex" ? prebuiltCodexCmd : undefined,
316
- ...codexDefaults,
317
- logger: ctx.logger,
318
- })
319
- result.recreated.push(s.name)
320
- } catch (err) {
321
- result.failed.push({ name: s.name, reason: err?.message || String(err) })
322
- }
323
- }
324
- return result
325
- }
326
-
327
174
  /**
328
175
  * `~/.claude/scripts/manifest.json` から Hub AI bundle の version を読む。
329
176
  * 未セットアップなら null を返す。
@@ -506,27 +353,6 @@ export function applyActiveProfileEnv(profile, log) {
506
353
  )
507
354
  }
508
355
 
509
- /**
510
- * active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
511
- * tmux pane のシェルは hub-agent の process.env を見ないため、set-environment -g で流し込む。
512
- * pane は既定で素のシェル (CLI を自動起動しない) なので、ユーザーが pane 内で直接
513
- * `claude` を打つケースにはこれが主経路になる
514
- * (`HUB_AGENT_TMUX_LAUNCH_CLI=1` で CLI を pane 起動するときだけ、createSession 内の
515
- * injectConfigDirEnv によるコマンド焼き込みが併用される)。
516
- * tmux サーバー未起動でも失敗しない。
517
- */
518
- export async function syncTmuxProfileEnv(profile, log) {
519
- const isDefault =
520
- !profile ||
521
- profile.id === DEFAULT_PROFILE_ID ||
522
- profile.configDir === defaultConfigDir()
523
- await setTmuxGlobalEnv(
524
- "CLAUDE_CONFIG_DIR",
525
- isDefault ? null : profile.configDir,
526
- { logger: log },
527
- )
528
- }
529
-
530
356
  /**
531
357
  * active Codex プロファイルの configDir を process.env.CODEX_HOME へ反映する。
532
358
  * 既定 (`~/.codex`) のときは変数を消して Codex 既定パスに戻す。codex app-server は
@@ -552,20 +378,6 @@ export function applyActiveCodexEnv(profile, log) {
552
378
  )
553
379
  }
554
380
 
555
- /**
556
- * active Codex プロファイルの CODEX_HOME を tmux サーバーのグローバル環境へ同期する。
557
- * tmux pane で直接 `codex` を打つケースや respawn の保険。tmux 未起動でも失敗しない。
558
- */
559
- export async function syncTmuxCodexEnv(profile, log) {
560
- const isDefault =
561
- !profile ||
562
- profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
563
- profile.configDir === codexProfiles.defaultCodexDir()
564
- await setTmuxGlobalEnv("CODEX_HOME", isDefault ? null : profile.configDir, {
565
- logger: log,
566
- })
567
- }
568
-
569
381
  /**
570
382
  * `setup_hub_ai.py --silent` を best-effort でキックし、全プロファイルの settings.json へ
571
383
  * bundle hooks (cockpit_permission_bridge 等) を反映させる。
@@ -930,7 +742,6 @@ export async function startDaemon({ version, claudeSdk } = {}) {
930
742
  try {
931
743
  const activeProfile = await getActiveProfile()
932
744
  applyActiveProfileEnv(activeProfile, logger)
933
- await syncTmuxProfileEnv(activeProfile, logger)
934
745
  } catch (err) {
935
746
  logger.warn(
936
747
  { err: err.message },
@@ -942,7 +753,6 @@ export async function startDaemon({ version, claudeSdk } = {}) {
942
753
  try {
943
754
  const activeCodex = await codexProfiles.getActiveProfile()
944
755
  applyActiveCodexEnv(activeCodex, logger)
945
- await syncTmuxCodexEnv(activeCodex, logger)
946
756
  } catch (err) {
947
757
  logger.warn(
948
758
  { err: err.message },
@@ -1213,58 +1023,15 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1213
1023
  // no-op. ref されていることだけが重要。
1214
1024
  }, 60_000)
1215
1025
 
1216
- // 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
1217
- // browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
1218
- // ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
1219
- // 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
1220
- // Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
1221
- // state loop に渡す boundSessions (tmux session_name → session_id) の器。
1222
- // ⚠️ 純 SDK チャット化 (2026-07) で唯一の writer だった claude.tui.bind が消えたため、
1223
- // この Map は **常に空**。それでも state loop 起動より前に「空 Map として」用意する:
1224
- // undefined を渡すと state loop がバックグラウンド時に cwd 最新 JSONL へフォールバックして
1225
- // 別会話の context% を出す。空 Map なら boundSessionId が常に null を返し、
1226
- // resolveStateLoopBoundSessionId の「SDK チャットは bind 台帳を持たないので claudeBridge の
1227
- // ライブセッションへフォールバック」経路 (下記) が正しく効く。
1228
- ctx.tuiReboundSessions = new Map()
1229
-
1230
- const readinessTracker = new ReadinessTracker()
1231
- ctx.readinessTracker = readinessTracker
1232
- const stateLoop = startStateLoop({
1233
- client,
1234
- plugins,
1235
- logger,
1236
- intervalMs: 5_000,
1237
- claudeBridge,
1238
- // 背景 Codex チャットセッションの状態を state loop へ渡す。
1239
- codexBridge,
1240
- readinessTracker,
1241
- // tmux session_name → bind 中の session_id。turn_active を「そのペインが実際に走らせている
1242
- // セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
1243
- boundSessions: ctx.tuiReboundSessions,
1244
- })
1245
- // T1: tmux.list_sessions poll fallback で turn_active/proc_busy/child_busy/stalled を
1246
- // 同梱できるよう、state loop の最新値スナップショット accessor を ctx に晒す。
1247
- // push 専用の 4 信号を poll でも回復可能にする (生産点は state loop で単一のまま、
1248
- // 取り出し口を 2 つに増やすだけ = §5 の用途別出し分けを侵さない)。
1249
- ctx.getSessionStateSnapshot = stateLoop.getSnapshot
1026
+ // 会話の回収と常駐数制限を定期実行する。
1027
+ const stateLoop = startStateLoop({ client, logger, intervalMs: 5_000, claudeBridge, codexBridge })
1250
1028
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
1251
1029
  // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
1252
1030
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
1253
- // prompt_submit / stop で readinessTracker を arm し、ready の settle 検出に繋ぐ。
1254
1031
  const sessionEventLoop = await startSessionEventWatcher({
1255
1032
  client,
1256
1033
  logger,
1257
- readinessTracker,
1258
- })
1259
- // arm 済みセッションの子プロセスを高速 poll し、baseline 復帰で event ファイルに 'ready'
1260
- // を書く (既存 watcher が session.event 'ready' を push)。アイドル時は ps を spawn しない。
1261
- const readinessLoop = startReadinessLoop({
1262
- logger,
1263
- tracker: readinessTracker,
1264
- intervalMs: 700,
1265
1034
  })
1266
-
1267
-
1268
1035
  // sibling-inject (ask-sibling スキル, 2026-07-04): 同一 cwd の claude⇄codex
1269
1036
  // 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
1270
1037
  // (src/sibling-token.mjs) で検証した要求のみ claudeBridge/codexBridge.sendToCwd()
@@ -1315,7 +1082,6 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1315
1082
  logger.info({ signal }, "agent stop hooks completed")
1316
1083
  stateLoop.stop()
1317
1084
  sessionEventLoop?.stop?.()
1318
- readinessLoop?.stop?.()
1319
1085
  restartCoordinator?.stop?.()
1320
1086
  claudeBridge?.shutdown?.()
1321
1087
  codexBridge?.shutdown?.()
@@ -1773,11 +1539,6 @@ async function readAllSessionEvents() {
1773
1539
  return out
1774
1540
  }
1775
1541
 
1776
- // 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
1777
- // tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
1778
- // 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
1779
- export const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1780
-
1781
1542
  /**
1782
1543
  * `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
1783
1544
  * UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
@@ -1880,7 +1641,6 @@ export async function publishSessionEvent(
1880
1641
  sort_affecting: sortAffecting,
1881
1642
  }, "session.event push")
1882
1643
  }
1883
- tmuxSessionsSnapshotCache.clear()
1884
1644
  client.send({
1885
1645
  type: "session.event",
1886
1646
  session_name: sessionName,
@@ -1891,125 +1651,11 @@ export async function publishSessionEvent(
1891
1651
  return { sortAffecting }
1892
1652
  }
1893
1653
 
1894
-
1895
- /**
1896
- * arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
1897
- * 高速 poll し、子プロセスが baseline へ復帰したエッジで event ファイルに 'ready' を書く。
1898
- * 既存の startSessionEventWatcher がそれを `session.event` 'ready' として push し、frontend は
1899
- * 「真に入力可能」になった瞬間として消灯 + キューフラッシュする (Stop フック中の誤フラッシュ
1900
- * 根治)。tracker.getBusy() は state loop が proc_busy レベルとして相乗せする。
1901
- * onReady(name): ready エッジで呼ぶコールバック (busy で defer した +新規 の実行に使う)。
1902
- *
1903
- * 負荷: アイドル (armed/busy なし) のときは ps を spawn しない。claude pid は TTL キャッシュ。
1904
- */
1905
- function startReadinessLoop({ tracker, logger, intervalMs = 700, onReady }) {
1906
- let stopped = false
1907
- const PANE_PID_TTL_MS = 30_000
1908
- const panePidByName = new Map() // name → {pid, at}
1909
- const unresolvedStreak = new Map() // name → 連続解決失敗回数 (SDK チャット等の掃除用)
1910
-
1911
- // busy 子数のカウント基点は claude 本体 PID ではなく tmux の **pane_pid** にする。
1912
- // 理由: (1) クロスプラットフォーム安全 — claude を comm 名で同定する方式は Linux/WSL で
1913
- // claude が `node .../cli.js` として起動し comm が 'node' になり同定に失敗する。pane_pid
1914
- // 基点なら子孫を数えるだけで claude の同定が不要。(2) /clear 耐性 — claude PID は /clear で
1915
- // 変わるが pane_pid (ペインの shell) は不変なので再解決が要らない。baseline (rolling-min) が
1916
- // 「claude 本体 + 常駐 MCP」を吸収するので、基点が pane_pid でも busy=「一過性の子増加」で正しい。
1917
- const resolvePanePid = async (name, procMap) => {
1918
- const hit = panePidByName.get(name)
1919
- if (hit && procMap.has(hit.pid) && Date.now() - hit.at < PANE_PID_TTL_MS) {
1920
- return hit.pid
1921
- }
1922
- const panePid = await getPanePid(name)
1923
- if (panePid && procMap.has(panePid)) {
1924
- panePidByName.set(name, { pid: panePid, at: Date.now() })
1925
- return panePid
1926
- }
1927
- panePidByName.delete(name)
1928
- return null
1929
- }
1930
-
1931
- const tick = async () => {
1932
- if (stopped) return
1933
- try {
1934
- const names = []
1935
- for (const n of tracker.byName.keys()) {
1936
- if (tracker.isActive(n)) names.push(n)
1937
- }
1938
- if (names.length === 0) return
1939
- const procMap = await snapshotProcs()
1940
- if (procMap.size === 0) return // ps 失敗時は据え置き (spurious ready 防止)
1941
- for (const name of names) {
1942
- const root = await resolvePanePid(name, procMap)
1943
- if (!root) {
1944
- // 解決失敗は observe しない (count=0 を注入すると誤って ready が出るため)。
1945
- // SDK チャット等で tmux セッションが無いものは連続失敗で tracker から忘れる。
1946
- const k = (unresolvedStreak.get(name) ?? 0) + 1
1947
- if (k >= 8) {
1948
- tracker.forget(name)
1949
- unresolvedStreak.delete(name)
1950
- panePidByName.delete(name)
1951
- } else {
1952
- unresolvedStreak.set(name, k)
1953
- }
1954
- continue
1955
- }
1956
- unresolvedStreak.delete(name)
1957
- const count = busyChildCount(root, procMap)
1958
- const { ready } = tracker.observe(name, count)
1959
- if (ready) {
1960
- await writeSessionEventFile(name, "ready", Date.now())
1961
- // busy で defer した +新規 をターン終了 (ready) のここで 1 回だけ実行する。
1962
- try {
1963
- await onReady?.(name)
1964
- } catch (err) {
1965
- logger?.warn({ err: err?.message }, "readiness onReady callback failed")
1966
- }
1967
- }
1968
- }
1969
- } catch (err) {
1970
- logger?.warn({ err: err?.message }, "readiness loop tick failed")
1971
- }
1972
- }
1973
-
1974
- const ti = setInterval(tick, intervalMs)
1975
- ti.unref?.()
1976
- return {
1977
- stop() {
1978
- stopped = true
1979
- clearInterval(ti)
1980
- },
1981
- }
1982
- }
1983
-
1984
- /**
1985
- * 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
1986
- *
1987
- * - 前回値との比較で status / context_pct のどちらかが変化したら送る
1988
- * - 新規 session は無条件 push、消えた session は無視 (browser 側は
1989
- * セッション一覧の更新で消滅を検知する)
1990
- * - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
1991
- */
1992
- // (i) 出力フロー検知の有効窓。ペイン出力領域の署名 (output_sig) が直近この時間内に変化して
1993
- // いれば「出力ストリーミング中」とみなし proc_busy に OR する。state loop は 5s 周期なので、
1994
- // 連続生成中は毎 tick 署名が変わり常時 true、出力が止まれば次 tick (≤5s) で署名不変になり
1995
- // 窓経過後に false へ落ちる (ターン終了後のスピナー固着=症状A を悪化させない短さ)。
1996
- // 出力フロー検知窓。state loop tick(5000ms)の整数倍にして、出力が止まった後の outputActive が
1997
- // 5s tick 境界で true↔false フラッピングするのを防ぐ(8000 は非整数倍で proc_busy 単独反転 push が
1998
- // 多発していた=perf監査。10000=2tick で安定側、影響は『出力停止とみなす猶予 8s→10s』のみ)。
1999
- const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 10000)
2000
-
2001
1654
  // 状態信号の計装ログ(session.state/event push)を出すか。既定 OFF。障害解析時に
2002
1655
  // HUB_AGENT_STATE_TRACE=1 で一時的に有効化する(常時 info 出力はログ肥大の主因=perf監査で
2003
1656
  // 直近1h ログの約半分を占めると実測。挙動には一切影響しない純ログ)。
2004
1657
  const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
2005
1658
 
2006
- // 権威的な「応答停止 (ハング)」判定の無進捗しきい値。ターン進行中 (armed) なのに、実際の
2007
- // 進捗 (子プロセス実行中 / 出力フロー / ペインのライブ processing) がこの時間継続して観測
2008
- // されなければ stalled とする。frontend 側の jsonl 無音タイマー (長時間ツールで誤検知し、
2009
- // 本当のハングでは turnActive クリアにより見逃す) を置き換える正本。frontend の
2010
- // TURN_STALL_WARN_MS と揃える。
2011
- const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
2012
-
2013
1659
  /**
2014
1660
  * tmux.list_sessions 応答に同梱する session オブジェクトを組み立てる純関数。
2015
1661
  *
@@ -2086,14 +1732,6 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
2086
1732
  return { status, contextPct }
2087
1733
  }
2088
1734
 
2089
- function codexRowSession(bridge, cwd) {
2090
- // 代表行(終了印を含む)は代表実体だけを見る。背景会話へ落ちると終了後も履歴 I/O が走る。
2091
- if (hasRepresentativeRow(cwd, "codex")) {
2092
- const rep = getRepresentativeConversation(cwd, "codex")
2093
- return rep && !rep.dead ? rep : null
2094
- }
2095
- return bridge?.liveSessionForCwd?.(cwd) ?? null
2096
- }
2097
1735
 
2098
1736
  /**
2099
1737
  * codex 行の使用率を、行の代表会話の live 値で上書きするかを決める。
@@ -2235,116 +1873,6 @@ export function filterCodexSessionsByAuthority(codexSessions, producerByCwd, seq
2235
1873
  return codexSessions.filter((e) => seqForCwd(e.cwd) === producerByCwd.get(e.cwd))
2236
1874
  }
2237
1875
 
2238
- /**
2239
- * S1 (2026-07): claude.attach / codex.attach の直後に、その cwd に対応する session.state を
2240
- * 1 回だけ即時 push する。startStateLoop の周期 (既定 5s) を待つと、エンジン切替や新規
2241
- * セッション選択の直後もサイドバーのドーナツ/ステータスドットが前セッションの値のまま
2242
- * 数秒間残ってしまう (症状③④)。claude.tui.bind が既に持つ「即時ステータススナップショット」
2243
- * (main.mjs 内 detectSessionState 直後の push) と同じ発想で、チャット (SDK/app-server) 側にも
2244
- * 対称の即時 push を用意する。
2245
- *
2246
- * cwd → session_name の解決: claude.attach / codex.attach は tmux の session_name を知らない
2247
- * (browser は cwd/stream_id しか送らない) ため、listSessionStates で全 tmux session を走査し
2248
- * cwd が一致するものへ push する。tmux 自体が無い / 一致が無ければ何もしない (安全側、次の
2249
- * poll 系フォールバックに委ねる)。
2250
- *
2251
- * ⚠️ T05945: 同一 cwd に claude 行 (`<dir>`) と codex 行 (`<dir>__codex`) が並ぶため、cwd 一致
2252
- * だけで push すると **attach した CLI のゼロ値/実値が反対側の CLI 行にも書き込まれる**
2253
- * (codex を新規 attach した瞬間に claude 行のドーナツが 0% に落ちる)。行のセッション名から
2254
- * 導いた cli_kind が attach 中の CLI と一致する行にだけ push する。
2255
- *
2256
- * `listSessionStatesFn` は単体テスト用の DI ポイント (既定は実 `listSessionStates`、tmux を
2257
- * 起動する)。実運用では省略してよい。
2258
- *
2259
- * @param {{ client: object, plugins: object, logger?: object, cwd: string, cliKind: 'claude'|'codex', sessionId: string|null, streamId?: string|null, contextPct?: number|null, listSessionStatesFn?: Function }} args
2260
- */
2261
- export async function pushImmediateChatState({
2262
- client,
2263
- plugins,
2264
- logger,
2265
- cwd,
2266
- cliKind,
2267
- sessionId,
2268
- streamId,
2269
- contextPct = 0,
2270
- listSessionStatesFn = listSessionStates,
2271
- }) {
2272
- if (!cwd || !client) return
2273
- try {
2274
- const states = await listSessionStatesFn({ plugins, logger })
2275
- for (const s of states) {
2276
- if (s.cwd !== cwd) continue
2277
- if (cliKindForSessionName(s.session_name) !== cliKind) continue
2278
- const status = "waiting"
2279
- // 代表行は直後の applyRowChatState が代表会話の実 ID で上書きする。
2280
- const contextSessionId = sessionId ?? null
2281
- tmuxSessionsSnapshotCache.clear()
2282
- client.send(applyRowChatState({
2283
- type: "session.state",
2284
- session_name: s.session_name,
2285
- status,
2286
- context_pct: typeof contextPct === "number" ? contextPct : null,
2287
- stable: status !== "processing",
2288
- proc_busy: false,
2289
- child_busy: false,
2290
- stalled: false,
2291
- turn_active: status == null ? null : status === "processing",
2292
- cli_kind: cliKind,
2293
- // context_pct がどの会話の値かを明示する。frontend は ID が変わったときだけ
2294
- // 0% を採用し、同一会話の一時的な 0/null は直近の確定値を保持する。
2295
- context_session_id: contextSessionId,
2296
- chat_session_id: contextSessionId,
2297
- }, cwd, cliKind))
2298
- }
2299
- } catch (err) {
2300
- logger?.warn?.({ err: err?.message, cwd }, "pushImmediateChatState failed")
2301
- }
2302
- }
2303
-
2304
- /**
2305
- * チャット入力の受付時刻を、対応する tmux 行の prompt_submit として **in-process で** publish する。
2306
- *
2307
- * state loop の 5 秒 tick だけでは、5 秒未満で完了するターンや、終了と次の開始の間に tick が
2308
- * 入らない連続ターンを取りこぼし、観測できても送信時刻ではなく assistant event の時刻になる。
2309
- * そこで handler が入力を受け付けた瞬間 (dispatch 前) に、cwd と CLI 種別が一致する tmux 行へ
2310
- * publishSessionEvent(prompt_submit, 受付時刻, fresh) を流す。
2311
- *
2312
- * ファイルは書かない。並び順の正本は ingestSessionEventForSort の sidecar (poll の
2313
- * sort_affecting_at で復元される) で、event ファイルは status 復元用に state loop が従来どおり
2314
- * 書く (その prompt_submit の at は chatSessionEventForTick が受付時刻へ揃える)。直接経路が
2315
- * ファイルを書かないので、fs.watch の読み落とし・並行 rename・kill 中の保留書き込みといった
2316
- * ファイル由来の競合そのものが無い。
2317
- *
2318
- * bridge が入力を拒否した場合 (empty / queue dead 等) も publish 済みのまま取り消さない:
2319
- * 並び順の定義は「最後に人間が送った順」で、送信操作自体は起きているため (仕様)。
2320
- */
2321
- export async function notifyChatPromptSubmit({
2322
- cwd,
2323
- cliKind,
2324
- at,
2325
- client,
2326
- readinessTracker,
2327
- plugins,
2328
- logger,
2329
- listSessionStatesFn = listSessionStates,
2330
- publishFn = publishSessionEvent,
2331
- }) {
2332
- if (!cwd || !client) return
2333
- try {
2334
- const states = await listSessionStatesFn({ plugins, logger })
2335
- for (const s of states) {
2336
- if (s.cwd !== cwd || cliKindForSessionName(s.session_name) !== cliKind) continue
2337
- try {
2338
- await publishFn({ client, readinessTracker, logger }, s.session_name, "prompt_submit", at, { fresh: true })
2339
- } catch (err) {
2340
- logger?.warn?.({ err: err?.message, cwd, cliKind, session_name: s.session_name }, "notifyChatPromptSubmit publish failed")
2341
- }
2342
- }
2343
- } catch (err) {
2344
- logger?.warn?.({ err: err?.message, cwd, cliKind }, "notifyChatPromptSubmit failed")
2345
- }
2346
- }
2347
-
2348
1876
  /**
2349
1877
  * chat signal から、この tick で書く session-event を導出する。
2350
1878
  * FIFO で前ターンの stop 後に次ターンの processing を観測しても、prompt_submit の at は
@@ -2432,17 +1960,7 @@ export function decideTurnActiveSource({
2432
1960
  return "none"
2433
1961
  }
2434
1962
 
2435
- export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, codexBridge, readinessTracker, boundSessions, listSessionStatesFn = listSessionStates, writeSessionEventFileFn = writeSessionEventFile }) {
2436
- const lastByName = new Map() // session_name → {status, context_pct}
2437
- const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
2438
- // session_name → 最後に書いた chat 由来 event 種別。エッジ駆動 (種別遷移時のみ書く) にして
2439
- // 生成中の prompt_submit 連打を防ぐ (並行生成でサイドバーが揺れる原因)。
2440
- const lastChatEventByName = new Map()
2441
- // (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
2442
- const outputFlowByName = new Map()
2443
- // 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
2444
- // 無進捗を判定する。旧 lastProgressAtByName + armed ゲートの「フック依存 + cap 140s で消える」死角を解消。
2445
- const stallTracker = new StallTracker({ stallWarnMs: STALL_WARN_MS })
1963
+ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codexBridge }) {
2446
1964
  // 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
2447
1965
  // 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
2448
1966
  // 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
@@ -2450,17 +1968,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2450
1968
  const warmBoundsLogState = { lastUnresolved: 0, lastLoggedAtMs: 0 }
2451
1969
  let stopped = false
2452
1970
 
2453
- // RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
2454
- // 再 push する。差分送信のままだと、切断中に起きた status 遷移 (processing→stop 等) の push が
2455
- // 失われ、再接続後も frontend が古い processing に固着する (zombie WS の検知が遅れると最大数十秒)。
2456
- // client は (再)接続ごとに "open" を emit する。フロントの tmux.list_sessions poll でも回復するが、
2457
- // これで再接続直後の次 tick (≤intervalMs) に短縮する。初回接続時は空 Map の clear で no-op。
2458
- const onReopen = () => {
2459
- lastByName.clear()
2460
- tmuxSessionsSnapshotCache.clear()
2461
- }
2462
- client.on("open", onReopen)
2463
-
2464
1971
  const tick = async () => {
2465
1972
  if (stopped) return
2466
1973
  try {
@@ -2505,232 +2012,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2505
2012
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
2506
2013
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
2507
2014
  await refreshContextWindow()
2508
- const states = await listSessionStatesFn({ plugins, logger })
2509
- for (const s of states) {
2510
- // T05945: この行がどの CLI のセッションかは **セッション名から構造的に決まる**
2511
- // (`<dir>` = claude / `<dir>__codex` = codex)。chat 信号の読み出し・turn_active の
2512
- // 判定源・session.state に載せる cli_kind はすべてこの 1 値から導く (正本一本化)。
2513
- const sessionCliKind = cliKindForSessionName(s.session_name)
2514
- // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
2515
- // session の cwd に一致する新鮮なチャット信号があれば status/context% を
2516
- // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
2517
- // 代表が設定済みなら、その会話の値だけを使い、欠けた値は null とする。
2518
- // 代表未設定の行は従来の applyChatOverlay / pane processing ガードを使う。
2519
- // (cwd, cliKind) キーで自 CLI のチャット信号だけを読む。同一 cwd の別 CLI 行が
2520
- // 立てた status/context_pct を拾わない (交差汚染の遮断)。
2521
- const representative = hasRepresentativeRow(s.cwd, sessionCliKind)
2522
- const chat = s.cwd ? readRowChatState(s.cwd, sessionCliKind) : null
2523
- const overlay = applyChatOverlay(s.status, s.context_pct, chat)
2524
- let status = representative ? chat?.status ?? null : overlay.status
2525
- let contextPct = representative ? chat?.context_pct ?? null : overlay.contextPct
2526
- // 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
2527
- // 即消灯するためのフラグ)。chat 信号で processing へ上書きされた場合は false。
2528
- let stable = status !== "processing" && s.stable === true
2529
- // プロセス内省由来の busy レベル (Stop フック/ツール実行中=true)。frontend は
2530
- // これでペイン由来の早期消灯を抑止し、tool/Stop フック中もスピナーを保つ。
2531
- // 高速 readiness loop が維持する値をそのまま相乗せする (この loop では計算しない)。
2532
- const childBusy = readinessTracker ? readinessTracker.getBusy(s.session_name) : false
2533
- // (i) 出力フロー検知: proc 内省は pane_pid 子孫の「プロセス数」で busy を見るため、
2534
- // ツールを起動しない純テキスト生成 (モデルが出力を流すだけ) を busy として検出できない。
2535
- // その間に capture-pane のスピナー検出が 1 フレーム取りこぼすと frontend がアイドルと
2536
- // 誤読してスピナーを消す (生成中なのに消える=症状B)。出力領域の署名が直近変化していれば
2537
- // 「出力中」とみなし proc_busy に OR する。childBusy=false の純テキスト生成中も
2538
- // proc_busy=true となり frontend のペイン由来消灯が抑止される。出力が止まれば署名が
2539
- // 不変になり窓経過後に false へ落ちるので、ターン終了後のスピナー固着は悪化させない。
2540
- let outputActive = false
2541
- if (typeof s.output_sig === "string") {
2542
- const prevFlow = outputFlowByName.get(s.session_name)
2543
- let changedAt = prevFlow ? prevFlow.changedAt : 0
2544
- if (!prevFlow || prevFlow.sig !== s.output_sig) changedAt = Date.now()
2545
- outputFlowByName.set(s.session_name, { sig: s.output_sig, changedAt })
2546
- outputActive = Date.now() - changedAt < OUTPUT_ACTIVE_MS
2547
- }
2548
- const procBusy = childBusy || outputActive
2549
- // 権威的 stall 判定 (StallTracker, arm 非依存・cap 非依存)。armed (バンドルフックが効く
2550
- // セッション) は OR で常に turn 開扱い (従来挙動の上位互換)。フック未発火/旧世代hookセッション
2551
- // でも観測した processing で turnOpen を立て、frozen スピナー (spinner_present) を維持して proc 子も
2552
- // 出力も止まったハングを拾う。proc 内省の cap(140s) disarm に依存しないので 140s 以後も消えない。
2553
- // 旧実装の「armed 依存 + cap で消える」二重死角を解消。frontend はこれを応答停止バナーの正本に使う。
2554
- const armed = readinessTracker
2555
- ? readinessTracker.isArmed(s.session_name)
2556
- : false
2557
- const nowMs = Date.now()
2558
- const { stalled } = stallTracker.observe(s.session_name, {
2559
- armed,
2560
- processing: status === "processing",
2561
- procBusy,
2562
- spinnerPresent: s.spinner_present === true,
2563
- stable,
2564
- now: nowMs,
2565
- })
2566
-
2567
- // 代表行は下の同期読み出しが turn_active も確定する。
2568
- // 代表未設定の行だけ、従来の履歴・pane probe を使う。
2569
- let turnActive = null
2570
- // codex 行のときだけ rollout ベース判定の材料を引く (claude 行では常に null)。
2571
- // 行の**代表会話の session** を 1 度だけ引き、threadId(turn_active 判定用・会話 ID) と
2572
- // seq(context% の live source 参照キー) の両方を同一 session から取る。⚠️ 別々に引くと
2573
- // 「背景会話の使用率に代表会話の ID が付く」不整合になる。usage.response と同じ
2574
- // codexRowSession を使い、代表が live でなければ null(別会話の値で埋めない)。
2575
- const codexAuthSession =
2576
- s.cwd && sessionCliKind === "codex"
2577
- ? codexRowSession(codexBridge, s.cwd)
2578
- : null
2579
- const codexThreadId = codexAuthSession?.threadId || null
2580
- // T06407 増分2 (axis3): codex 行の context%(ドーナツ) は TTL 非依存の live store
2581
- // (thread/tokenUsage/updated 由来) を権威 producer(seq) で引き、chat overlay より優先して
2582
- // 恒久収束させる。代表未設定の行だけ、従来の pane processing ガードを使う。
2583
- const paneIsProcessing = !representative && s.status === "processing"
2584
- if (sessionCliKind === "codex") {
2585
- const liveCtx = typeof codexAuthSession?.seq === "number" && !paneIsProcessing
2586
- ? codexLiveContextForProducer(codexAuthSession.seq)
2587
- : null
2588
- contextPct = resolveCodexRowContextPct({
2589
- contextPct, paneIsProcessing, rowSession: codexAuthSession, liveContextPct: liveCtx,
2590
- })
2591
- }
2592
- // fresh bind は実 session_id がまだ確定していない。tmux.list の statusLine / cwd 最新
2593
- // JSONL は直前会話の値を返し得るため、この間は必ず 0% を正本にする。これをしないと
2594
- // bind の即時 0% snapshot の直後に state loop が旧値を同じ fresh:<id> で再送して
2595
- // ドーナツを復活させる。
2596
- const freshUnbound =
2597
- sessionCliKind === "claude" && isFreshUnboundBind(boundSessions?.get(s.session_name))
2598
- if (freshUnbound && !representative) contextPct = 0
2599
- const turnActiveSource = representative ? "representative" : decideTurnActiveSource({
2600
- chatStatus: chat?.status,
2601
- cwd: s.cwd,
2602
- isFreshUnbound: freshUnbound && !representative,
2603
- codexThreadId,
2604
- sessionCliKind,
2605
- })
2606
- switch (turnActiveSource) {
2607
- case "chat-active":
2608
- turnActive = true
2609
- break
2610
- case "chat-inactive":
2611
- turnActive = false
2612
- break
2613
- case "fresh-unbound":
2614
- // 症状2b 根治: fresh bind プレースホルダの間は、別アクティビティ jsonl を mtime 最新で
2615
- // 取り違える汚染を避けて turn_active を出さない (null=不明)。実セッション id へ adopt
2616
- // されれば下の通常経路に乗る。青を出さない安全側にのみ倒すため固着青は生まない。
2617
- turnActive = null
2618
- break
2619
- case "codex":
2620
- turnActive = await codexTurnActiveForThread(codexThreadId)
2621
- break
2622
- case "claude": {
2623
- // P5 (T06570 症状3): チャットセッションは、そのセッションの SDK query ライフサイクル由来の
2624
- // 権威フラグ _busy を最優先する (Codex の rollout flag 優先と対称)。これにより browser を
2625
- // 離れて detach され this.sessions から外れた warm セッションでも、稼働中ターンの turn_active
2626
- // が維持され、非選択行のドットが赤へ誤落ちしない。加えて bare-shell pane を dead 判定して
2627
- // 赤へ倒す paneAliveProbe 経路 (チャットは実体が SDK 子プロセスで pane は素のシェル) も回避する。
2628
- // 代表があれば、その会話の busy だけを読む。不在なら null のままにする。
2629
- // 代表未設定の行だけ、従来の cwd 最新 / jsonl / pane probe を使う。
2630
- const chatActive = claudeBridge?.chatTurnActiveForCwd?.(s.cwd)
2631
- if (representative || chatActive === true || chatActive === false) {
2632
- turnActive = chatActive
2633
- } else {
2634
- turnActive = await turnActiveForCwd(
2635
- s.cwd,
2636
- resolveStateLoopBoundSessionId(boundSessions, s.session_name, s.cwd, claudeBridge),
2637
- s.session_name,
2638
- { paneAliveProbe: paneClaudeAliveOrUnknown },
2639
- )
2640
- }
2641
- break
2642
- }
2643
- default:
2644
- turnActive = null
2645
- }
2646
-
2647
- // cli_kind (T05945): **セッション名から導いた構造的な値**を常に載せる。旧実装は
2648
- // 「チャット信号があればその cliKind、無ければ null」だったため、TUI 専用行や
2649
- // チャット未接続行で null になり、frontend が行の CLI を知る術が無かった。
2650
- // (cwd, cliKind) キー化により「チャット信号の cliKind == 行の cliKind」が恒真に
2651
- // なったので、常に非 null の構造値を送る方が真に厳しい (= 情報量が多い) 上位互換。
2652
- //
2653
- // context_session_id は「この context_pct がどの会話の値か」。チャットの session/thread を
2654
- // 優先し、Claude TUI 専用行では bind 済みの session_id(fresh は fresh:<request_id>)を使う。
2655
- // frontend はこれを境界として、新規会話の 0% と同一会話の一時的な 0/null を区別する。
2656
- const chatCliKind = sessionCliKind
2657
- const contextSessionId =
2658
- chat?.sessionId ??
2659
- (sessionCliKind === "claude"
2660
- ? boundSessions?.get(s.session_name) ?? `unbound:${s.session_name}`
2661
- : codexThreadId)
2662
-
2663
- // 履歴・pane の非同期処理が終わった後に、現在の実体を同期で読む。
2664
- const payload = applyRowChatState({
2665
- status, context_pct: contextPct, stable, proc_busy: procBusy, child_busy: childBusy,
2666
- stalled, turn_active: turnActive, cli_kind: chatCliKind,
2667
- context_session_id: contextSessionId, chat_session_id: contextSessionId,
2668
- }, s.cwd, sessionCliKind)
2669
- const currentChat = readRowChatState(s.cwd, sessionCliKind)
2670
- if (currentChat) {
2671
- // チャットのターン境界を sort / 状態用 session-event に橋渡しする。
2672
- // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
2673
- //
2674
- // ⚠️ **エッジ駆動** (種別が変わったときだけ書く)。旧実装は turnAt が前進するたびに
2675
- // 書いており、turnAt は assistant イベント毎に前進するため **生成中ずっと
2676
- // prompt_submit を書き続けていた**。並行生成中のセッション同士が assistant の
2677
- // 到着順で入れ替わり、サイドバーが揺れる原因になる (2026-07-29)。
2678
- // 種別遷移でのみ書けば、prompt_submit は生成開始の 1 回だけになる。
2679
- //
2680
- // 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
2681
- // それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
2682
- const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
2683
- const prevEv = lastChatEventByName.get(s.session_name)
2684
- const eventForTick = chatSessionEventForTick({
2685
- chat: currentChat,
2686
- prevEv,
2687
- prevTurnAt,
2688
- directAt: s.cwd ? getPromptAcceptedAt(s.cwd, sessionCliKind) : undefined,
2689
- })
2690
- if (eventForTick) {
2691
- const { ev, at } = eventForTick
2692
- lastTurnAtByName.set(s.session_name, currentChat.turnAt)
2693
- lastChatEventByName.set(s.session_name, ev)
2694
- writeSessionEventFileFn(s.session_name, ev, at).catch(() => {})
2695
- }
2696
- }
2697
- const prev = lastByName.get(s.session_name)
2698
- if (!prev || Object.keys(payload).some((key) => prev[key] !== payload[key])) {
2699
- lastByName.set(s.session_name, payload)
2700
- if (STATE_TRACE) logger?.info({ session_name: s.session_name, ...payload }, "session.state push")
2701
- tmuxSessionsSnapshotCache.clear()
2702
- client.send({ type: "session.state", session_name: s.session_name, ...payload })
2703
- }
2704
- // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
2705
- // まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
2706
- // 各 per-session Map が線形増加し RSS が無入力時も成長していた。tmux が一時的に空応答
2707
- // (states=[]) のときは全消去しないよう states.length>0 をガードにする。
2708
- if (states.length > 0) {
2709
- const liveNames = new Set(states.map((s) => s.session_name))
2710
- for (const name of [...lastByName.keys()]) {
2711
- if (liveNames.has(name)) continue
2712
- // 終端を能動 broadcast (症状3 根治): tmux から消えた = crash/外部 kill/別経路
2713
- // 終了。explicit kill は kill_session ハンドラが session.gone を出すが、それ以外
2714
- // の消滅はここが唯一の検知点。frontend が states/lastEvent を忘れてドットを
2715
- // 'down' に倒せるよう、ローカル Map を forget する前に通知する。
2716
- try {
2717
- tmuxSessionsSnapshotCache.clear()
2718
- client.send({ type: "session.gone", session_name: name })
2719
- } catch {
2720
- /* ignore */
2721
- }
2722
- invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
2723
- stallTracker.forget(name) // StallTracker.byName
2724
- readinessTracker?.forget?.(name) // ReadinessTracker.byName
2725
- forgetSortAffectingState(name) // sawUserTurn / sort_affecting_at / lastEventAt (リーク防止)
2726
- lastByName.delete(name)
2727
- outputFlowByName.delete(name)
2728
- lastTurnAtByName.delete(name)
2729
- // 死んだセッションの bind 記録も掃除する (同名再作成時は claude.tui.bind が再登録)。
2730
- boundSessions?.delete(name)
2731
- }
2732
- }
2733
- }
2734
2015
  } catch (err) {
2735
2016
  logger?.warn({ err: err.message }, "state loop tick failed")
2736
2017
  }
@@ -2748,24 +2029,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2748
2029
  stopped = true
2749
2030
  clearInterval(ti)
2750
2031
  clearTimeout(t0)
2751
- client.off?.("open", onReopen)
2752
- },
2753
- /**
2754
- * tmux.list_sessions poll fallback 用の session.state snapshot を返す。
2755
- *
2756
- * 背景: session.state push は差分送信(prev 比較で変化時のみ送信)のため、WS 再接続
2757
- * 直後や push 取りこぼし時にドット権威信号(turn_active/proc_busy/child_busy/stalled)が
2758
- * 古値固着し得る(2026-06-28 ワークフローで検出した「事故的非対称」)。tmux.list_sessions
2759
- * poll の応答にこれら 4 信号を同梱することで、status/context_pct と同じ精度で poll 経路
2760
- * からも最新化できるようにする(T1)。
2761
- *
2762
- * shallow copy で返却して呼び出し側の mutate が lastByName を汚染するのを防ぐ
2763
- * (state loop の差分送信ロジックが prev 比較で誤判定するのを避ける = 症状A/B 再発防止)。
2764
- * 名前が未知 or まだ tick が走っていなければ null(frontend は legacy フォールバックへ)。
2765
- */
2766
- getSnapshot(name) {
2767
- const entry = lastByName.get(name)
2768
- return entry ? { ...entry } : null
2769
2032
  },
2770
2033
  }
2771
2034
  }
@@ -3030,25 +2293,10 @@ export function handleClaudeChatInput(msg, ctx) {
3030
2293
  }
3031
2294
  return undefined
3032
2295
  }
3033
- // cwd は dispatch 前に確定する (Promise 解決後に引くと、その間に stream が別 cwd へ再 attach
3034
- // された場合に別行へ記録する)。受付時刻の記録と並び順の publish も dispatch **前** に行う:
3035
- // Claude の resident reconcile / Codex の turn/start RPC が未解決の間に assistant / turn/started
3036
- // が先に届いて state loop が processing を観測しても、記録済みなら観測時刻ではなく受付時刻で
3037
- // 書かれる。bridge が拒否しても取り消さない (仕様。notifyChatPromptSubmit の JSDoc)。
2296
+ // 非同期の入力処理へ渡す前に cwd と受付時刻を確定する。
3038
2297
  const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
3039
2298
  if (cwdSnapshot) {
3040
2299
  recordPromptAccepted(cwdSnapshot, "claude", acceptedAt, ctx.claudeBridge.sessions?.get(stream_id), ctx.claudeBridge)
3041
- notifyChatPromptSubmit({
3042
- cwd: cwdSnapshot,
3043
- cliKind: "claude",
3044
- at: acceptedAt,
3045
- plugins: ctx.plugins,
3046
- logger: ctx.logger,
3047
- client: ctx.client,
3048
- readinessTracker: ctx.readinessTracker,
3049
- listSessionStatesFn: ctx._listSessionStates || listSessionStates,
3050
- publishFn: ctx._publishSessionEvent || publishSessionEvent,
3051
- }).catch(() => {})
3052
2300
  }
3053
2301
  const p = ctx.claudeBridge
3054
2302
  .input({
@@ -3264,17 +2512,6 @@ export function handleCodexChatInput(msg, ctx) {
3264
2512
  const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
3265
2513
  if (cwdSnapshot) {
3266
2514
  recordPromptAccepted(cwdSnapshot, "codex", acceptedAt, ctx.codexBridge.sessions?.get(stream_id), ctx.codexBridge)
3267
- notifyChatPromptSubmit({
3268
- cwd: cwdSnapshot,
3269
- cliKind: "codex",
3270
- at: acceptedAt,
3271
- plugins: ctx.plugins,
3272
- logger: ctx.logger,
3273
- client: ctx.client,
3274
- readinessTracker: ctx.readinessTracker,
3275
- listSessionStatesFn: ctx._listSessionStates || listSessionStates,
3276
- publishFn: ctx._publishSessionEvent || publishSessionEvent,
3277
- }).catch(() => {})
3278
2515
  }
3279
2516
  const p = ctx.codexBridge
3280
2517
  .input({
@@ -3384,72 +2621,6 @@ export async function handleCodexInterrupt(msg, ctx) {
3384
2621
  })
3385
2622
  }
3386
2623
 
3387
- /**
3388
- * tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
3389
- * tmux.kill_session.result を返したうえで、kill できた各 session について
3390
- * **session.gone を能動 broadcast** する。
3391
- *
3392
- * 不変条件: session の終端 (kill/crash/外部終了) は、同一ホストで実体を観測できる
3393
- * agent が正本として能動 push する。これが無いと frontend は kill 時点の turn_active=true
3394
- * を keep-last したままステータスドットが青で固着する。最後の 1 セッションを kill した
3395
- * 場合 state loop の GC は states.length>0 ガードで発火しないため、explicit kill 経路の
3396
- * ここから session.gone を出すのが確実 (GC は crash/外部 kill の補完)。
3397
- *
3398
- * テストから直接呼べるよう export。kill 実体 (killManySessions) は ctx で差し替え可能。
3399
- */
3400
- export async function handleKillSession(msg, ctx) {
3401
- const killFn = ctx.killManySessions || killManySessions
3402
- const names = Array.isArray(msg.session_names)
3403
- ? msg.session_names
3404
- : msg.session_name
3405
- ? [msg.session_name]
3406
- : []
3407
- if (names.length === 0) {
3408
- ctx.client.send({
3409
- type: "tmux.kill_session.result",
3410
- request_id: msg.request_id,
3411
- killed: [],
3412
- failed: [{ name: "", reason: "session_name(s) required" }],
3413
- })
3414
- return
3415
- }
3416
- // ⚠️ kill の受信・完了は必ずログに残す (2026-07-31 追加)。
3417
- // 「停止に 10 秒経っても応答が返らない」の調査で、agent 側に kill の痕跡が
3418
- // 1 行も無く、遅いのか・届いていないのか・返事が捨てられたのかを**判別できなかった**。
3419
- // kill は低頻度なのでログ量の懸念は無い。速かった場合も記録する
3420
- // (速いことの記録が無いと「agent は詰まっていない」を示せない)。
3421
- ctx.logger?.info?.({ names, request_id: msg.request_id }, "tmux kill_session received")
3422
- const killStartedAt = Date.now()
3423
- const r = await killFn(names)
3424
- const killMs = Date.now() - killStartedAt
3425
- const sent = ctx.client.send({
3426
- type: "tmux.kill_session.result",
3427
- request_id: msg.request_id,
3428
- killed: r.killed,
3429
- failed: r.failed,
3430
- })
3431
- // 送信できたか自体を残す。ws が閉じていると result は黙って捨てられるため、
3432
- // 「kill は成功したのに要求元へ返っていない」を後から特定できるようにする。
3433
- ctx.logger?.info?.(
3434
- {
3435
- request_id: msg.request_id,
3436
- killed: r.killed.length,
3437
- failed: r.failed.length,
3438
- kill_ms: killMs,
3439
- result_sent: sent === true,
3440
- },
3441
- "tmux kill_session done",
3442
- )
3443
- for (const name of r.killed) {
3444
- try {
3445
- ctx.client.send({ type: "session.gone", session_name: name })
3446
- } catch {
3447
- /* ignore */
3448
- }
3449
- forgetSortAffectingState(name) // sort_affecting 状態のリーク防止 (kill 経路)
3450
- }
3451
- }
3452
-
3453
2624
  /** usage.response の中身を共有する TTL (ms)。frontend の poll は既定 60 秒周期。 */
3454
2625
  export const USAGE_RESPONSE_TTL_MS = 4_000
3455
2626
 
@@ -3704,78 +2875,12 @@ export async function handleUsageRequest(msg, ctx, deps = {}) {
3704
2875
  }
3705
2876
 
3706
2877
  /**
3707
- * `tmux.create_session` ハンドラ。指定名・cwd で detached の tmux セッションを作る。
3708
- *
3709
- * ⚠️ pane に CLI (claude / codex) を起動するかは **createSession 内の
3710
- * shouldLaunchPaneCli() が一元判定**する (既定は起動しない)。ここで渡す cliKind /
3711
- * claudeCmd / codexCmd / initialPrompt 等は、`HUB_AGENT_TMUX_LAUNCH_CLI=1` で
3712
- * CLI を起動するときにだけ使われる。呼び出し元ごとに既定を変えないため、
3713
- * このハンドラ側で起動可否を判断しないこと。
3714
- *
3715
- * dispatch から切り出して export しているのは、実際に tmux へ渡る引数を
3716
- * テストから検査できるようにするため (test/tmux-pane-cli-launch.test.mjs)。
3717
- */
3718
- export async function handleTmuxCreateSession(msg, ctx) {
3719
- const name = (msg.session_name || "").trim()
3720
- const cwd = (msg.cwd || process.env.HOME || "/").trim()
3721
- if (!name) {
3722
- ctx.client.send({
3723
- type: "tmux.create_session.result",
3724
- request_id: msg.request_id,
3725
- ok: false,
3726
- error: "session_name is required",
3727
- })
3728
- return
3729
- }
3730
- try {
3731
- await createTmuxSession(name, cwd, {
3732
- // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
3733
- cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3734
- claudeCmd:
3735
- typeof msg.claude_cmd === "string"
3736
- ? msg.claude_cmd
3737
- : claudeCmdFromAgentConfig(ctx.config),
3738
- codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3739
- // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
3740
- model:
3741
- typeof msg.model === "string"
3742
- ? msg.model
3743
- : codexDefaultsFromAgentConfig(ctx.config).model,
3744
- sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3745
- approvalPolicy:
3746
- typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3747
- effort:
3748
- typeof msg.effort === "string"
3749
- ? msg.effort
3750
- : codexDefaultsFromAgentConfig(ctx.config).effort,
3751
- initialPrompt:
3752
- typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3753
- logger: ctx.logger,
3754
- })
3755
- tmuxSessionsSnapshotCache.clear()
3756
- ctx.client.send({
3757
- type: "tmux.create_session.result",
3758
- request_id: msg.request_id,
3759
- ok: true,
3760
- session_name: name,
3761
- })
3762
- } catch (err) {
3763
- ctx.client.send({
3764
- type: "tmux.create_session.result",
3765
- request_id: msg.request_id,
3766
- ok: false,
3767
- error: err.message,
3768
- })
3769
- }
3770
- }
3771
-
3772
- /**
3773
- * `worktree.create` ハンドラ。git worktree を作ってから、その dir で tmux セッションを作る。
2878
+ * `worktree.create` ハンドラ。git worktree とチャット行の識別子を作る。
3774
2879
  * body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?,
3775
2880
  * approval_policy?, effort?, initial_prompt? }
3776
2881
  *
3777
- * ⚠️ pane の CLI 起動可否は handleTmuxCreateSession と同じく createSession 側の
3778
- * 一元判定に従う (既定は起動しない)。
2882
+ * worktree 作成後にワークスペースの準備・同期を待ってから応答する。
2883
+ * CLI 起動用の旧フィールドは互換のため受け付けるが使用しない。
3779
2884
  */
3780
2885
  export async function handleWorktreeCreate(msg, ctx) {
3781
2886
  const dir = (msg.dir || "").trim()
@@ -3800,53 +2905,23 @@ export async function handleWorktreeCreate(msg, ctx) {
3800
2905
  }
3801
2906
  try {
3802
2907
  const worktreeCreate = ctx._createWorktreeDir || createWorktreeDir
3803
- const tmuxCreate = ctx._createTmuxSession || createTmuxSession
3804
2908
  const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
3805
- const launchPaneCli = shouldLaunchPaneCli()
3806
- const codexDefaults = codexDefaultsFromAgentConfig(ctx.config)
3807
- const tmuxOptions = {
3808
- cliKind: wtCliKind,
3809
- claudeCmd:
3810
- typeof msg.claude_cmd === "string"
3811
- ? msg.claude_cmd
3812
- : claudeCmdFromAgentConfig(ctx.config),
3813
- codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3814
- model: typeof msg.model === "string" ? msg.model : codexDefaults.model,
3815
- sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3816
- approvalPolicy:
3817
- typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3818
- effort: typeof msg.effort === "string" ? msg.effort : codexDefaults.effort,
3819
- initialPrompt:
3820
- typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3821
- logger: ctx.logger,
3822
- _resolveCodexBinary: ctx._resolveCodexBinary,
3823
- }
3824
- if (launchPaneCli) validateInitialPrompt(tmuxOptions.initialPrompt)
3825
- // 下流 createSession と同じ options・優先順位で command を1回だけ生成する。
3826
- // 失敗はこの try の catch へ流れ、worktree / tmux の副作用前に ok:false となる。
3827
- if (launchPaneCli && wtCliKind === "codex") {
3828
- tmuxOptions.codexCmd = tmuxOptions.codexCmd ?? buildCodexCmd(tmuxOptions)
3829
- }
3830
2909
  const { wtName, wtPath, createdBranch } = await worktreeCreate(dir, branch)
3831
- // ⚠️ 宛先一覧の無効化を tmux 作成の成否に依存させない。この一覧の正本はローカルの
3832
- // ディレクトリ実体なので、worktree ができた時点で既に古い。後続の tmux 作成が失敗しても
3833
- // worktree dir は残るため、ここで捨てないと残り TTL のあいだ実在する宛先が一覧から欠ける。
3834
2910
  chatTargetsCache.clear()
3835
- // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
3836
- // 解釈し、codex なら codex を pane 起動する(既定 claude)。
3837
- // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
3838
- // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
3839
- // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
3840
2911
  const wtSessionName = sessionNameFor(wtName, wtCliKind)
3841
- await tmuxCreate(wtSessionName, wtPath, tmuxOptions)
3842
- tmuxSessionsSnapshotCache.clear()
2912
+ await prepareChatWorkspace({
2913
+ cwd: wtPath,
2914
+ session: wtSessionName,
2915
+ cliKind: wtCliKind,
2916
+ logger: ctx.logger,
2917
+ })
3843
2918
  ctx.client.send({
3844
2919
  type: "worktree.create.result",
3845
2920
  request_id: msg.request_id,
3846
2921
  ok: true,
3847
2922
  // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
3848
2923
  wt_name: wtName,
3849
- // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
2924
+ // session_name は画面が選択するチャット行の名前 (codex なら __codex 付き)。
3850
2925
  // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
3851
2926
  session_name: wtSessionName,
3852
2927
  wt_path: wtPath,
@@ -4076,12 +3151,9 @@ export async function dispatch(msg, ctx) {
4076
3151
  })
4077
3152
  const session = ctx.claudeBridge.sessions?.get(stream_id)
4078
3153
  const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
4079
- const push = () => pushImmediateChatState({ client: ctx.client, plugins: ctx.plugins,
4080
- logger: ctx.logger, cwd: resolvedCwd, cliKind: "claude" })
4081
3154
  if (session && resumeSessionId) {
4082
- void loadResumeChatContext(session, "claude").then(push).catch(() => {})
3155
+ void loadResumeChatContext(session, "claude").catch(() => {})
4083
3156
  }
4084
- void push().catch(() => {})
4085
3157
 
4086
3158
  } catch (err) {
4087
3159
  ctx.client.send({
@@ -4468,12 +3540,9 @@ export async function dispatch(msg, ctx) {
4468
3540
  ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
4469
3541
  }
4470
3542
  const session = ctx.codexBridge.sessions?.get(stream_id)
4471
- const push = () => pushImmediateChatState({ client: ctx.client, plugins: ctx.plugins,
4472
- logger: ctx.logger, cwd: resolvedCwd, cliKind: "codex" })
4473
3543
  if (session && info.resuming) {
4474
- void loadResumeChatContext(session, "codex").then(push).catch(() => {})
3544
+ void loadResumeChatContext(session, "codex").catch(() => {})
4475
3545
  }
4476
- void push().catch(() => {})
4477
3546
 
4478
3547
  } catch (err) {
4479
3548
  ctx.client.send({
@@ -4907,8 +3976,8 @@ export async function dispatch(msg, ctx) {
4907
3976
  return
4908
3977
  }
4909
3978
  case "profile.switch": {
4910
- // アカウント切替。active 永続化 → env 反映 (process/tmux) → 全 Claude セッション
4911
- // 強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
3979
+ // アカウント切替。active 永続化 → env 反映 (process) → 全 Claude セッション
3980
+ // 強制再起動 (SDK チャット)。走行中ターンも中断する。
4912
3981
  //
4913
3982
  // WHY 認証スワップ廃止 (B案, 2026-07): Claude Code 2.1.x は OAuth トークンを
4914
3983
  // `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)>` に config ディレクトリ別
@@ -4920,13 +3989,9 @@ export async function dispatch(msg, ctx) {
4920
3989
  try {
4921
3990
  const target = await setActiveProfile(id)
4922
3991
  applyActiveProfileEnv(target, ctx.logger)
4923
- await syncTmuxProfileEnv(target, ctx.logger)
4924
3992
  // 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
4925
3993
  const before = ctx.claudeBridge ? ctx.claudeBridge.list().length : 0
4926
3994
  ctx.claudeBridge?.shutdown()
4927
- // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
4928
- const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
4929
- tmuxSessionsSnapshotCache.clear()
4930
3995
  // アカウントが変わったので、旧アカウントの利用量・認証在否を TTL の残り時間だけ
4931
3996
  // 見せ続けないよう明示的に捨てる (直後の needs_login 判定も実測値になる)。
4932
3997
  usageResponseCache.clear()
@@ -4940,9 +4005,6 @@ export async function dispatch(msg, ctx) {
4940
4005
  {
4941
4006
  profile_id: target.id,
4942
4007
  reaped: before,
4943
- tmux_killed: tmuxResult.killed.length,
4944
- tmux_recreated: tmuxResult.recreated.length,
4945
- tmux_failed: tmuxResult.failed.length,
4946
4008
  needs_login: needsLogin,
4947
4009
  },
4948
4010
  "claude profile switched; all sessions reaped",
@@ -4959,7 +4021,6 @@ export async function dispatch(msg, ctx) {
4959
4021
  config_dir: target.configDir,
4960
4022
  },
4961
4023
  reaped: before,
4962
- tmux_restarted: tmuxResult.recreated.length,
4963
4024
  needs_login: needsLogin,
4964
4025
  login_command: needsLogin
4965
4026
  ? isDefaultTarget
@@ -5083,7 +4144,7 @@ export async function dispatch(msg, ctx) {
5083
4144
  return
5084
4145
  }
5085
4146
  case "codex.profile.switch": {
5086
- // Codex アカウント切替。active 永続化 → CODEX_HOME を env/tmux に反映 → Codex
4147
+ // Codex アカウント切替。active 永続化 → CODEX_HOME を env に反映 → Codex
5087
4148
  // app-server セッションのみ再起動 (Claude 側は独立なので触らない)。認証はファイル
5088
4149
  // 分離のため CODEX_HOME 切替だけでアカウントが変わる (スワップ不要)。
5089
4150
  const id = msg.profile_id || msg.id || ""
@@ -5091,11 +4152,9 @@ export async function dispatch(msg, ctx) {
5091
4152
  const profileApi = ctx.codexProfiles || codexProfiles
5092
4153
  const target = await profileApi.setActiveProfile(id)
5093
4154
  ;(ctx.applyActiveCodexEnv || applyActiveCodexEnv)(target, ctx.logger)
5094
- await (ctx.syncTmuxCodexEnv || syncTmuxCodexEnv)(target, ctx.logger)
5095
4155
  // Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
5096
4156
  const before = ctx.codexBridge?.sessions?.size ?? 0
5097
4157
  ctx.codexBridge?.shutdown()
5098
- tmuxSessionsSnapshotCache.clear()
5099
4158
  // Codex アカウントが変わったので、旧アカウントの 5h/7d を TTL の残り時間だけ
5100
4159
  // 見せ続けない (rate limits 自体は profileId 別キャッシュなので混ざらないが、
5101
4160
  // usage.response の中身は両アカウントを束ねているため明示的に捨てる)。
@@ -5301,85 +4360,6 @@ export async function dispatch(msg, ctx) {
5301
4360
  }
5302
4361
  return
5303
4362
  }
5304
- case "tmux.list_sessions": {
5305
- try {
5306
- const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
5307
- // worktree index は listSessions の親判定と stub 列挙で共用する。従来は同じ
5308
- // 全 workspace + git branch 走査を 1 応答内で 2 回直列実行していた。
5309
- const worktreeIndex = await buildWorktreeIndex()
5310
- // boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
5311
- // (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
5312
- const sessions = await listTmuxSessions({
5313
- plugins: ctx.plugins,
5314
- logger: ctx.logger,
5315
- boundSessions: ctx.tuiReboundSessions,
5316
- claudeBridge: ctx.claudeBridge,
5317
- worktreeIndex,
5318
- })
5319
- const lastEventByName = await readAllSessionEvents()
5320
- const enriched = enrichTmuxSessionsForListResponse(
5321
- sessions,
5322
- lastEventByName,
5323
- (name) => {
5324
- const row = sessions.find((item) => item.name === name)
5325
- if (hasRepresentativeRow(row?.cwd, cliKindForSessionName(name))) return null
5326
- return ctx.getSessionStateSnapshot?.(name)
5327
- },
5328
- // warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
5329
- // 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
5330
- (row) =>
5331
- isSessionWarm(
5332
- { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5333
- row,
5334
- ),
5335
- )
5336
- const liveNames = new Set(enriched.map((s) => s.name))
5337
- let worktreeStubs = []
5338
- try {
5339
- worktreeStubs = await listWorktreeStubs(liveNames, { worktreeIndex })
5340
- } catch (err) {
5341
- ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
5342
- }
5343
- return { sessions: enriched, worktree_stubs: worktreeStubs }
5344
- })
5345
- // 行ごとの「次に開く会話」の context% (任意)。宛先 (context_targets) は要求ごとに
5346
- // 違うため、共有される snapshot キャッシュの**外**で解決し、触る行だけ複製して
5347
- // 載せる (他クライアントの応答を汚さない)。context_targets が無ければ従来どおりの
5348
- // 応答 (後方互換)。
5349
- let sessions = snapshot.sessions
5350
- if (Array.isArray(msg.context_targets) && msg.context_targets.length > 0) {
5351
- try {
5352
- const pointerByName = await resolvePointerContexts(
5353
- msg.context_targets,
5354
- sessions,
5355
- { logger: ctx.logger },
5356
- )
5357
- sessions = applyPointerContexts(sessions, pointerByName)
5358
- } catch (err) {
5359
- // pointer context はあくまで付加情報。失敗してもセッション一覧は返す。
5360
- ctx.logger?.warn?.(
5361
- { err: err?.message },
5362
- "resolvePointerContexts failed",
5363
- )
5364
- }
5365
- }
5366
- ctx.client.send({
5367
- type: "tmux.sessions",
5368
- request_id: msg.request_id,
5369
- ...snapshot,
5370
- sessions: sessions.map((row) => applyRowChatState(row, row.cwd, cliKindForSessionName(row.name))),
5371
- })
5372
- } catch (err) {
5373
- ctx.client.send({
5374
- type: "tmux.sessions",
5375
- request_id: msg.request_id,
5376
- sessions: [],
5377
- worktree_stubs: [],
5378
- error: err.message,
5379
- })
5380
- }
5381
- return
5382
- }
5383
4363
  case "chat.sidebar_snapshot.request": {
5384
4364
  try {
5385
4365
  const messages = await readSidebarMessageSnapshot(msg.targets, {
@@ -5403,10 +4383,6 @@ export async function dispatch(msg, ctx) {
5403
4383
  }
5404
4384
  return
5405
4385
  }
5406
- case "tmux.create_session": {
5407
- await handleTmuxCreateSession(msg, ctx)
5408
- return
5409
- }
5410
4386
  case "worktree.create": {
5411
4387
  await handleWorktreeCreate(msg, ctx)
5412
4388
  return
@@ -5485,7 +4461,6 @@ export async function dispatch(msg, ctx) {
5485
4461
  releaseRemoval?.()
5486
4462
  }
5487
4463
  if (result.dirty !== true) {
5488
- tmuxSessionsSnapshotCache.clear()
5489
4464
  chatTargetsCache.clear()
5490
4465
  }
5491
4466
  ctx.client.send({
@@ -5513,11 +4488,6 @@ export async function dispatch(msg, ctx) {
5513
4488
  }
5514
4489
  return
5515
4490
  }
5516
- case "tmux.kill_session": {
5517
- await handleKillSession(msg, ctx)
5518
- tmuxSessionsSnapshotCache.clear()
5519
- return
5520
- }
5521
4491
  case "skills.request": {
5522
4492
  try {
5523
4493
  // Cockpit Codex 両対応 (Stream A3, 2026-07-02): browser が cli_kind を