@cocorograph/hub-agent 0.8.17 → 0.8.19

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/claude-md.mjs CHANGED
@@ -21,8 +21,7 @@ import os from "node:os"
21
21
  import path from "node:path"
22
22
  import {
23
23
  sha256,
24
- writeMeta,
25
- writeBaseBody,
24
+ writeSyncState,
26
25
  nowIso,
27
26
  } from "./claude-md-sync.mjs"
28
27
 
@@ -381,13 +380,18 @@ export async function ensureClaudeMd({
381
380
  const hubInitialized = !!hub?.claude_md_updated_at
382
381
  if (hubInitialized && hubBody) {
383
382
  await fs.writeFile(configMdPath, hubBody, "utf-8")
384
- await writeMeta(targetDir, {
385
- lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
386
- lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
387
- lastSyncedAt: nowIso(),
388
- }, configFilename)
389
383
  // 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
390
- await writeBaseBody(targetDir, hubBody, configFilename)
384
+ // ⚠️ base → meta の順で書くこと(writeSyncState に集約)。meta は「判定の
385
+ // commit marker」なので先に進めると、base の書込が失敗しても「同期済み」と
386
+ // 見なされ、以降の sync が通常 noop に入って base が壊れたまま固定される。
387
+ await writeSyncState(targetDir, {
388
+ body: hubBody,
389
+ meta: {
390
+ lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
391
+ lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
392
+ lastSyncedAt: nowIso(),
393
+ },
394
+ }, configFilename)
391
395
  logger?.info?.(
392
396
  { dirName, configMdPath },
393
397
  "wrote project instruction file from Hub project claude_md (initial sync)",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 「そのセッションに依頼を送れるか (warm)」判定の **正本 (SoT)** (2026-08-01)。
3
+ *
4
+ * ## 背景 (WHY)
5
+ * セッション間の依頼 (`cockpit_orch.py send`) は sibling-inject が宛先を解決するが、その
6
+ * 対象は **claudeBridge / codexBridge の `describeLiveSessions()` に載るセッションだけ**で
7
+ * ある (載らない宛先は `sendToStream` / `sendToCwd` が `not_found` を返す)。一方 Cockpit の
8
+ * サイドバーは「tmux のセッション行が存在するか」しか見ていないため、**依頼を送れない
9
+ * セッションが「起動中」と表示される**食い違いがあった。
10
+ *
11
+ * そこで `tmux.list_sessions` 応答に `warm` を載せて表示と配送の判定を揃えるが、その際に
12
+ * **同じ判定を別実装で書き直すと再び食い違う** (グレー表示なのに宛先一覧に出ない、等)。
13
+ * 判定はこのモジュール 1 箇所に集約し、sibling-inject の候補列挙 (op:list / op:list-all /
14
+ * 失敗時の診断) と `warm` の算出が**必ず同じ関数**を通るようにする。
15
+ *
16
+ * ## 不変条件
17
+ * - `isSessionWarm(bridges, row) === true`
18
+ * ⇔ `describeLiveSessionsUnion(bridges, {cwd: row.cwd, cliKind: 行の cli_kind}).length > 0`
19
+ * ⇔ sibling-inject の `op:"list"` が同じ cwd / kind で 1 本以上返す
20
+ * - 行の cli_kind は `session-names.mjs` の命名規約 (`<dir>` = claude /
21
+ * `<dir>__codex` = codex) からのみ導く。tmux / ブリッジの状態は参照しない。
22
+ */
23
+
24
+ import { cliKindForSessionName } from "./session-names.mjs"
25
+
26
+ /**
27
+ * claude / codex ブリッジの live セッションを union で返す (宛先解決の候補集合)。
28
+ *
29
+ * `cwd` を渡すとその cwd のみ、`cliKind` に 'claude' / 'codex' を渡すとその CLI のみへ
30
+ * 絞り込む。どちらも省略すると全 cwd・両 CLI (op:"list-all" 用)。`cliKind` が既知値以外
31
+ * なら絞り込まない (両方返す) — 呼び出し側で既に検証済みの前提。
32
+ *
33
+ * @param {{claudeBridge?: object, codexBridge?: object}} bridges
34
+ * @param {{cwd?: string, cliKind?: 'claude'|'codex'}} [filter]
35
+ * @returns {Array<object>} describeLiveSessions の記述子配列 (claude → codex の順)
36
+ */
37
+ export function describeLiveSessionsUnion(bridges, { cwd, cliKind } = {}) {
38
+ const { claudeBridge, codexBridge } = bridges || {}
39
+ const out = []
40
+ if (cliKind !== "codex") out.push(...(claudeBridge?.describeLiveSessions?.(cwd) ?? []))
41
+ if (cliKind !== "claude") out.push(...(codexBridge?.describeLiveSessions?.(cwd) ?? []))
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * tmux セッション行が「依頼を受け取れる (warm)」かを判定する。
47
+ *
48
+ * 判定は上記 union に一本化する。`cwd` が取れていない行 (tmux の cwd 解決失敗など) は
49
+ * 宛先解決のスコープを作れないため false (fail-closed。送れないものを送れると見せない)。
50
+ *
51
+ * @param {{claudeBridge?: object, codexBridge?: object}} bridges
52
+ * @param {{name?: string, cwd?: string|null}} row tmux.list_sessions の session 行
53
+ * @returns {boolean}
54
+ */
55
+ export function isSessionWarm(bridges, { name, cwd } = {}) {
56
+ if (!cwd) return false
57
+ const cliKind = cliKindForSessionName(name)
58
+ return describeLiveSessionsUnion(bridges, { cwd, cliKind }).length > 0
59
+ }
60
+
61
+ export default { describeLiveSessionsUnion, isSessionWarm }
package/src/main.mjs CHANGED
@@ -62,9 +62,6 @@ import { listAgents } from "./agents.mjs"
62
62
  import { listSkills } from "./skills.mjs"
63
63
  import { readSidebarMessageSnapshot } from "./sidebar-message-snapshot.mjs"
64
64
  import {
65
- capturePane,
66
- detectPermissionModeFromText,
67
- detectSessionState,
68
65
  invalidateSessionCache,
69
66
  listSessionStates,
70
67
  StallTracker,
@@ -110,6 +107,7 @@ import {
110
107
  stopTerminalMirror,
111
108
  } from "./tmux.mjs"
112
109
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
110
+ import { isSessionWarm } from "./live-sessions.mjs"
113
111
  import {
114
112
  boundSessionId,
115
113
  codexContextPercentFromTokenUsage,
@@ -199,14 +197,17 @@ function codexDefaultsFromAgentConfig(config) {
199
197
  /**
200
198
  * プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
201
199
  *
202
- * 再作成された pane の claude は createSession 内の injectConfigDirEnv 経由で
203
- * 新しい CLAUDE_CONFIG_DIR を拾うため、TUI セッションもアカウント切替に追従する。
200
+ * 再作成された pane は **既定では素のシェル** (createSession が CLI を起動しない)。
201
+ * アカウント切替は SDK チャット側の process.env と、tmux サーバーのグローバル環境
202
+ * (syncTmuxProfileEnv の `set-environment -g`) で追従する。
203
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` で TUI を起動する運用の場合のみ、createSession 内の
204
+ * injectConfigDirEnv が新しい CLAUDE_CONFIG_DIR をコマンドへ焼き込む。
204
205
  * initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
205
206
  * 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
206
207
  * cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
207
208
  * tmux サーバー未起動なら何もしない。
208
209
  */
209
- async function restartAllTmuxSessionsForProfileSwitch(ctx) {
210
+ export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
210
211
  const result = { killed: [], recreated: [], failed: [] }
211
212
  let sessions = []
212
213
  try {
@@ -232,6 +233,10 @@ async function restartAllTmuxSessionsForProfileSwitch(ctx) {
232
233
  }
233
234
  try {
234
235
  await createTmuxSession(s.name, s.cwd, {
236
+ // cliKind はセッション名 (`<dir>__codex` か否か) だけで決まる。渡し忘れると
237
+ // createSession が既定の claude として扱い、codex セッションの再生成でも
238
+ // AGENTS.md ではなく CLAUDE.md を同期対象にしてしまう。
239
+ cliKind: cliKindForSessionName(s.name),
235
240
  claudeCmd: claudeCmdFromAgentConfig(ctx.config),
236
241
  logger: ctx.logger,
237
242
  })
@@ -410,9 +415,11 @@ export function applyActiveProfileEnv(profile, log) {
410
415
 
411
416
  /**
412
417
  * active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
413
- * tmux pane のシェルは hub-agent の process.env を見ないため、手動 respawn や
414
- * ユーザーが pane 内で直接 `claude` を打つケースの保険として set-environment -g を併用する
415
- * (新規セッションの主経路は createSession 内の injectConfigDirEnv コマンド焼き込み)。
418
+ * tmux pane のシェルは hub-agent の process.env を見ないため、set-environment -g で流し込む。
419
+ * pane は既定で素のシェル (CLI を自動起動しない) なので、ユーザーが pane 内で直接
420
+ * `claude` を打つケースにはこれが主経路になる
421
+ * (`HUB_AGENT_TMUX_LAUNCH_CLI=1` で CLI を pane 起動するときだけ、createSession 内の
422
+ * injectConfigDirEnv によるコマンド焼き込みが併用される)。
416
423
  * tmux サーバー未起動でも失敗しない。
417
424
  */
418
425
  export async function syncTmuxProfileEnv(profile, log) {
@@ -2013,17 +2020,27 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
2013
2020
  * 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
2014
2021
  * 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
2015
2022
  *
2023
+ * `warm` (2026-08-01): 「このセッションに依頼 (cockpit_orch.py send) を送れるか」。frontend は
2024
+ * 従来「tmux のセッション行が存在するか」だけで起動中を判定していたため、**依頼を送れない
2025
+ * セッションが起動中と表示される**食い違いがあった。判定は live-sessions.mjs の
2026
+ * `isSessionWarm` (sibling-inject の宛先候補列挙と同一関数) に一本化する。accessor 未指定
2027
+ * (=旧呼び出し) では `warm` を付与しない(推測で false を配らない)。
2028
+ *
2016
2029
  * @param {Array} sessions tmux.list_sessions が返した素の session 行
2017
2030
  * @param {Map<string, object>} lastEventByName session_name → 最新 hook event
2018
2031
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
2019
2032
  * accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
2033
+ * @param {((row: {name: string, cwd?: string|null}) => boolean) | undefined} isWarm
2034
+ * warm 判定 accessor (既定は live-sessions.mjs の isSessionWarm を bind したもの)。
2035
+ * undefined のときは `warm` を付与しない
2020
2036
  */
2021
- export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
2037
+ export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap, isWarm) {
2022
2038
  return sessions.map((s) => {
2023
2039
  const base = {
2024
2040
  ...s,
2025
2041
  last_event: lastEventByName.get(s.name) || null,
2026
2042
  }
2043
+ if (isWarm) base.warm = !!isWarm(s)
2027
2044
  if (!getSnap) return base
2028
2045
  const snap = getSnap(s.name)
2029
2046
  if (!snap) return base
@@ -2151,7 +2168,6 @@ export async function pushImmediateChatState({
2151
2168
  session_name: s.session_name,
2152
2169
  status: "waiting",
2153
2170
  context_pct: typeof contextPct === "number" ? contextPct : 0,
2154
- permission_mode: s.permission_mode ?? null,
2155
2171
  stable: true,
2156
2172
  proc_busy: false,
2157
2173
  child_busy: false,
@@ -2295,9 +2311,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2295
2311
  // (`<dir>` = claude / `<dir>__codex` = codex)。chat 信号の読み出し・turn_active の
2296
2312
  // 判定源・session.state に載せる cli_kind はすべてこの 1 値から導く (正本一本化)。
2297
2313
  const sessionCliKind = cliKindForSessionName(s.session_name)
2298
- // 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
2299
- // tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
2300
- const permissionMode = s.permission_mode ?? null
2301
2314
  // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
2302
2315
  // session の cwd に一致する新鮮なチャット信号があれば status/context% を
2303
2316
  // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
@@ -2517,7 +2530,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2517
2530
  !prev ||
2518
2531
  prev.status !== status ||
2519
2532
  prev.context_pct !== contextPct ||
2520
- prev.permission_mode !== permissionMode ||
2521
2533
  prev.stable !== stable ||
2522
2534
  prev.proc_busy !== procBusy ||
2523
2535
  prev.child_busy !== childBusy ||
@@ -2529,7 +2541,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2529
2541
  lastByName.set(s.session_name, {
2530
2542
  status,
2531
2543
  context_pct: contextPct,
2532
- permission_mode: permissionMode,
2533
2544
  stable,
2534
2545
  proc_busy: procBusy,
2535
2546
  child_busy: childBusy,
@@ -2570,7 +2581,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2570
2581
  session_name: s.session_name,
2571
2582
  status,
2572
2583
  context_pct: contextPct,
2573
- permission_mode: permissionMode,
2574
2584
  stable,
2575
2585
  proc_busy: procBusy,
2576
2586
  // child_busy: outputActive を含まない真の子プロセス busy。frontend の shouldQueue が
@@ -3542,6 +3552,157 @@ export async function handleUsageRequest(msg, ctx, deps = {}) {
3542
3552
  }
3543
3553
  }
3544
3554
 
3555
+ /**
3556
+ * `tmux.create_session` ハンドラ。指定名・cwd で detached の tmux セッションを作る。
3557
+ *
3558
+ * ⚠️ pane に CLI (claude / codex) を起動するかは **createSession 内の
3559
+ * shouldLaunchPaneCli() が一元判定**する (既定は起動しない)。ここで渡す cliKind /
3560
+ * claudeCmd / codexCmd / initialPrompt 等は、`HUB_AGENT_TMUX_LAUNCH_CLI=1` で
3561
+ * CLI を起動するときにだけ使われる。呼び出し元ごとに既定を変えないため、
3562
+ * このハンドラ側で起動可否を判断しないこと。
3563
+ *
3564
+ * dispatch から切り出して export しているのは、実際に tmux へ渡る引数を
3565
+ * テストから検査できるようにするため (test/tmux-pane-cli-launch.test.mjs)。
3566
+ */
3567
+ export async function handleTmuxCreateSession(msg, ctx) {
3568
+ const name = (msg.session_name || "").trim()
3569
+ const cwd = (msg.cwd || process.env.HOME || "/").trim()
3570
+ if (!name) {
3571
+ ctx.client.send({
3572
+ type: "tmux.create_session.result",
3573
+ request_id: msg.request_id,
3574
+ ok: false,
3575
+ error: "session_name is required",
3576
+ })
3577
+ return
3578
+ }
3579
+ try {
3580
+ await createTmuxSession(name, cwd, {
3581
+ // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
3582
+ cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3583
+ claudeCmd:
3584
+ typeof msg.claude_cmd === "string"
3585
+ ? msg.claude_cmd
3586
+ : claudeCmdFromAgentConfig(ctx.config),
3587
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3588
+ // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
3589
+ model:
3590
+ typeof msg.model === "string"
3591
+ ? msg.model
3592
+ : codexDefaultsFromAgentConfig(ctx.config).model,
3593
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3594
+ approvalPolicy:
3595
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3596
+ effort:
3597
+ typeof msg.effort === "string"
3598
+ ? msg.effort
3599
+ : codexDefaultsFromAgentConfig(ctx.config).effort,
3600
+ initialPrompt:
3601
+ typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3602
+ logger: ctx.logger,
3603
+ })
3604
+ tmuxSessionsSnapshotCache.clear()
3605
+ ctx.client.send({
3606
+ type: "tmux.create_session.result",
3607
+ request_id: msg.request_id,
3608
+ ok: true,
3609
+ session_name: name,
3610
+ })
3611
+ } catch (err) {
3612
+ ctx.client.send({
3613
+ type: "tmux.create_session.result",
3614
+ request_id: msg.request_id,
3615
+ ok: false,
3616
+ error: err.message,
3617
+ })
3618
+ }
3619
+ }
3620
+
3621
+ /**
3622
+ * `worktree.create` ハンドラ。git worktree を作ってから、その dir で tmux セッションを作る。
3623
+ * body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?,
3624
+ * approval_policy?, effort?, initial_prompt? }
3625
+ *
3626
+ * ⚠️ pane の CLI 起動可否は handleTmuxCreateSession と同じく createSession 側の
3627
+ * 一元判定に従う (既定は起動しない)。
3628
+ */
3629
+ export async function handleWorktreeCreate(msg, ctx) {
3630
+ const dir = (msg.dir || "").trim()
3631
+ const branch = (msg.branch || "").trim()
3632
+ if (!dir || !branch) {
3633
+ ctx.client.send({
3634
+ type: "worktree.create.result",
3635
+ request_id: msg.request_id,
3636
+ ok: false,
3637
+ error: "dir and branch required",
3638
+ })
3639
+ return
3640
+ }
3641
+ if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
3642
+ ctx.client.send({
3643
+ type: "worktree.create.result",
3644
+ request_id: msg.request_id,
3645
+ ok: false,
3646
+ error: "invalid branch name",
3647
+ })
3648
+ return
3649
+ }
3650
+ try {
3651
+ const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
3652
+ // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
3653
+ // 解釈し、codex なら codex を pane 起動する(既定 claude)。
3654
+ // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
3655
+ // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
3656
+ // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
3657
+ const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
3658
+ const wtSessionName = sessionNameFor(wtName, wtCliKind)
3659
+ await createTmuxSession(wtSessionName, wtPath, {
3660
+ cliKind: wtCliKind,
3661
+ claudeCmd:
3662
+ typeof msg.claude_cmd === "string"
3663
+ ? msg.claude_cmd
3664
+ : claudeCmdFromAgentConfig(ctx.config),
3665
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3666
+ // codex 用設定 (claude 経路では無視される)。
3667
+ model:
3668
+ typeof msg.model === "string"
3669
+ ? msg.model
3670
+ : codexDefaultsFromAgentConfig(ctx.config).model,
3671
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3672
+ approvalPolicy:
3673
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3674
+ effort:
3675
+ typeof msg.effort === "string"
3676
+ ? msg.effort
3677
+ : codexDefaultsFromAgentConfig(ctx.config).effort,
3678
+ initialPrompt:
3679
+ typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3680
+ logger: ctx.logger,
3681
+ })
3682
+ tmuxSessionsSnapshotCache.clear()
3683
+ ctx.client.send({
3684
+ type: "worktree.create.result",
3685
+ request_id: msg.request_id,
3686
+ ok: true,
3687
+ // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
3688
+ wt_name: wtName,
3689
+ // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
3690
+ // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
3691
+ session_name: wtSessionName,
3692
+ wt_path: wtPath,
3693
+ branch,
3694
+ created_branch: createdBranch,
3695
+ })
3696
+ } catch (err) {
3697
+ ctx.client.send({
3698
+ type: "worktree.create.result",
3699
+ request_id: msg.request_id,
3700
+ ok: false,
3701
+ error: err.message,
3702
+ })
3703
+ }
3704
+ }
3705
+
3545
3706
  async function dispatch(msg, ctx) {
3546
3707
  const t = msg?.type || ""
3547
3708
  try {
@@ -4889,6 +5050,13 @@ async function dispatch(msg, ctx) {
4889
5050
  sessions,
4890
5051
  lastEventByName,
4891
5052
  ctx.getSessionStateSnapshot,
5053
+ // warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
5054
+ // 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
5055
+ (row) =>
5056
+ isSessionWarm(
5057
+ { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5058
+ row,
5059
+ ),
4892
5060
  )
4893
5061
  const liveNames = new Set(enriched.map((s) => s.name))
4894
5062
  let worktreeStubs = []
@@ -4939,135 +5107,11 @@ async function dispatch(msg, ctx) {
4939
5107
  return
4940
5108
  }
4941
5109
  case "tmux.create_session": {
4942
- const name = (msg.session_name || "").trim()
4943
- const cwd = (msg.cwd || process.env.HOME || "/").trim()
4944
- if (!name) {
4945
- ctx.client.send({
4946
- type: "tmux.create_session.result",
4947
- request_id: msg.request_id,
4948
- ok: false,
4949
- error: "session_name is required",
4950
- })
4951
- return
4952
- }
4953
- try {
4954
- await createTmuxSession(name, cwd, {
4955
- // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
4956
- cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
4957
- claudeCmd:
4958
- typeof msg.claude_cmd === "string"
4959
- ? msg.claude_cmd
4960
- : claudeCmdFromAgentConfig(ctx.config),
4961
- codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
4962
- // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
4963
- model:
4964
- typeof msg.model === "string"
4965
- ? msg.model
4966
- : codexDefaultsFromAgentConfig(ctx.config).model,
4967
- sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
4968
- approvalPolicy:
4969
- typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
4970
- effort:
4971
- typeof msg.effort === "string"
4972
- ? msg.effort
4973
- : codexDefaultsFromAgentConfig(ctx.config).effort,
4974
- initialPrompt:
4975
- typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
4976
- logger: ctx.logger,
4977
- })
4978
- tmuxSessionsSnapshotCache.clear()
4979
- ctx.client.send({
4980
- type: "tmux.create_session.result",
4981
- request_id: msg.request_id,
4982
- ok: true,
4983
- session_name: name,
4984
- })
4985
- } catch (err) {
4986
- ctx.client.send({
4987
- type: "tmux.create_session.result",
4988
- request_id: msg.request_id,
4989
- ok: false,
4990
- error: err.message,
4991
- })
4992
- }
5110
+ await handleTmuxCreateSession(msg, ctx)
4993
5111
  return
4994
5112
  }
4995
5113
  case "worktree.create": {
4996
- // body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?, approval_policy?, effort?, initial_prompt? }
4997
- const dir = (msg.dir || "").trim()
4998
- const branch = (msg.branch || "").trim()
4999
- if (!dir || !branch) {
5000
- ctx.client.send({
5001
- type: "worktree.create.result",
5002
- request_id: msg.request_id,
5003
- ok: false,
5004
- error: "dir and branch required",
5005
- })
5006
- return
5007
- }
5008
- if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
5009
- ctx.client.send({
5010
- type: "worktree.create.result",
5011
- request_id: msg.request_id,
5012
- ok: false,
5013
- error: "invalid branch name",
5014
- })
5015
- return
5016
- }
5017
- try {
5018
- const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
5019
- // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
5020
- // 解釈し、codex なら codex を pane 起動する(既定 claude)。
5021
- // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
5022
- // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
5023
- // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
5024
- const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
5025
- const wtSessionName = sessionNameFor(wtName, wtCliKind)
5026
- await createTmuxSession(wtSessionName, wtPath, {
5027
- cliKind: wtCliKind,
5028
- claudeCmd:
5029
- typeof msg.claude_cmd === "string"
5030
- ? msg.claude_cmd
5031
- : claudeCmdFromAgentConfig(ctx.config),
5032
- codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
5033
- // codex 用設定 (claude 経路では無視される)。
5034
- model:
5035
- typeof msg.model === "string"
5036
- ? msg.model
5037
- : codexDefaultsFromAgentConfig(ctx.config).model,
5038
- sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
5039
- approvalPolicy:
5040
- typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
5041
- effort:
5042
- typeof msg.effort === "string"
5043
- ? msg.effort
5044
- : codexDefaultsFromAgentConfig(ctx.config).effort,
5045
- initialPrompt:
5046
- typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
5047
- logger: ctx.logger,
5048
- })
5049
- tmuxSessionsSnapshotCache.clear()
5050
- ctx.client.send({
5051
- type: "worktree.create.result",
5052
- request_id: msg.request_id,
5053
- ok: true,
5054
- // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
5055
- wt_name: wtName,
5056
- // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
5057
- // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
5058
- session_name: wtSessionName,
5059
- wt_path: wtPath,
5060
- branch,
5061
- created_branch: createdBranch,
5062
- })
5063
- } catch (err) {
5064
- ctx.client.send({
5065
- type: "worktree.create.result",
5066
- request_id: msg.request_id,
5067
- ok: false,
5068
- error: err.message,
5069
- })
5070
- }
5114
+ await handleWorktreeCreate(msg, ctx)
5071
5115
  return
5072
5116
  }
5073
5117
  case "worktree.list_history": {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 「tmux pane に CLI (claude / codex) の TUI を起動しているか」の単一判定。
3
+ *
4
+ * この 1 フラグが tmux.mjs (pane で TUI を起動するか) と state.mjs (pane を
5
+ * capture-pane で読む意味があるか) の両方の分岐条件になるため、どちらからも
6
+ * import できる依存の無い最小モジュールに切り出してある。tmux.mjs 側に置いたまま
7
+ * state.mjs から import すると tmux.mjs → state.mjs → tmux.mjs の循環になる。
8
+ *
9
+ * 後方互換のため `tmux.mjs` からも re-export している (既存の import 先を壊さない)。
10
+ */
11
+
12
+ /**
13
+ * tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
14
+ *
15
+ * **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
16
+ * 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
17
+ * app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
18
+ * **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
19
+ * 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
20
+ *
21
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
22
+ * 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
23
+ * この関数 1 箇所に集約し、opts では上書きさせない。
24
+ *
25
+ * @param {NodeJS.ProcessEnv} [env]
26
+ * @returns {boolean}
27
+ */
28
+ export function shouldLaunchPaneCli(env = process.env) {
29
+ return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
30
+ }
@@ -41,6 +41,7 @@ import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/pro
41
41
  import path from "node:path"
42
42
 
43
43
  import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
44
+ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
44
45
 
45
46
  export const SIBLING_REQUESTS_DIR =
46
47
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
@@ -69,6 +70,14 @@ export class SiblingInjectBridge {
69
70
  this._watcher = null
70
71
  }
71
72
 
73
+ /**
74
+ * live-sessions.mjs (warm 判定の正本) へ渡すブリッジ束。宛先候補の列挙は必ずここを
75
+ * 経由し、`tmux.list_sessions` の `warm` と同じ判定を共有する。
76
+ */
77
+ _bridges() {
78
+ return { claudeBridge: this.claudeBridge, codexBridge: this.codexBridge }
79
+ }
80
+
72
81
  /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
73
82
  async start() {
74
83
  await mkdir(this.dir, { recursive: true })
@@ -159,13 +168,8 @@ export class SiblingInjectBridge {
159
168
  this.logger?.warn({ request_id }, "sibling-inject: invalid sibling token (list-all)")
160
169
  result = { ok: false, error: "invalid_token" }
161
170
  } else {
162
- result = {
163
- ok: true,
164
- live_sessions: [
165
- ...(this.claudeBridge?.describeLiveSessions?.() ?? []),
166
- ...(this.codexBridge?.describeLiveSessions?.() ?? []),
167
- ],
168
- }
171
+ // 判定は live-sessions.mjs に一本化する (tmux.list_sessions の warm と同じ関数)。
172
+ result = { ok: true, live_sessions: describeLiveSessionsUnion(this._bridges()) }
169
173
  }
170
174
  } else if (!cwd || badKind || badBody) {
171
175
  result = { ok: false, error: "invalid_request" }
@@ -192,7 +196,15 @@ export class SiblingInjectBridge {
192
196
  const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
193
197
  if (isList) {
194
198
  // 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
195
- result = { ok: true, live_sessions: bridge?.describeLiveSessions?.(cwd) ?? [] }
199
+ // ⚠️ tmux.list_sessions の `warm` と同じ関数を通す (二重定義にすると「グレー表示
200
+ // なのに宛先一覧に出ない」食い違いが再発する)。
201
+ result = {
202
+ ok: true,
203
+ live_sessions: describeLiveSessionsUnion(this._bridges(), {
204
+ cwd,
205
+ cliKind: target_cli_kind,
206
+ }),
207
+ }
196
208
  } else if (isExplicit) {
197
209
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
198
210
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
@@ -361,10 +373,7 @@ export class SiblingInjectBridge {
361
373
  * @param {string} targetKind
362
374
  */
363
375
  _diagnose(cwd, targetKind) {
364
- const sameCwd = [
365
- ...(this.claudeBridge?.describeLiveSessions?.(cwd) ?? []),
366
- ...(this.codexBridge?.describeLiveSessions?.(cwd) ?? []),
367
- ]
376
+ const sameCwd = describeLiveSessionsUnion(this._bridges(), { cwd })
368
377
  const hint = sameCwd.length
369
378
  ? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
370
379
  : `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +