@cocorograph/hub-agent 0.8.17 → 0.8.18

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.17",
3
+ "version": "0.8.18",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
@@ -110,6 +110,7 @@ import {
110
110
  stopTerminalMirror,
111
111
  } from "./tmux.mjs"
112
112
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
113
+ import { isSessionWarm } from "./live-sessions.mjs"
113
114
  import {
114
115
  boundSessionId,
115
116
  codexContextPercentFromTokenUsage,
@@ -199,14 +200,17 @@ function codexDefaultsFromAgentConfig(config) {
199
200
  /**
200
201
  * プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
201
202
  *
202
- * 再作成された pane の claude は createSession 内の injectConfigDirEnv 経由で
203
- * 新しい CLAUDE_CONFIG_DIR を拾うため、TUI セッションもアカウント切替に追従する。
203
+ * 再作成された pane は **既定では素のシェル** (createSession が CLI を起動しない)。
204
+ * アカウント切替は SDK チャット側の process.env と、tmux サーバーのグローバル環境
205
+ * (syncTmuxProfileEnv の `set-environment -g`) で追従する。
206
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` で TUI を起動する運用の場合のみ、createSession 内の
207
+ * injectConfigDirEnv が新しい CLAUDE_CONFIG_DIR をコマンドへ焼き込む。
204
208
  * initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
205
209
  * 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
206
210
  * cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
207
211
  * tmux サーバー未起動なら何もしない。
208
212
  */
209
- async function restartAllTmuxSessionsForProfileSwitch(ctx) {
213
+ export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
210
214
  const result = { killed: [], recreated: [], failed: [] }
211
215
  let sessions = []
212
216
  try {
@@ -410,9 +414,11 @@ export function applyActiveProfileEnv(profile, log) {
410
414
 
411
415
  /**
412
416
  * active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
413
- * tmux pane のシェルは hub-agent の process.env を見ないため、手動 respawn や
414
- * ユーザーが pane 内で直接 `claude` を打つケースの保険として set-environment -g を併用する
415
- * (新規セッションの主経路は createSession 内の injectConfigDirEnv コマンド焼き込み)。
417
+ * tmux pane のシェルは hub-agent の process.env を見ないため、set-environment -g で流し込む。
418
+ * pane は既定で素のシェル (CLI を自動起動しない) なので、ユーザーが pane 内で直接
419
+ * `claude` を打つケースにはこれが主経路になる
420
+ * (`HUB_AGENT_TMUX_LAUNCH_CLI=1` で CLI を pane 起動するときだけ、createSession 内の
421
+ * injectConfigDirEnv によるコマンド焼き込みが併用される)。
416
422
  * tmux サーバー未起動でも失敗しない。
417
423
  */
418
424
  export async function syncTmuxProfileEnv(profile, log) {
@@ -2013,17 +2019,27 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
2013
2019
  * 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
2014
2020
  * 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
2015
2021
  *
2022
+ * `warm` (2026-08-01): 「このセッションに依頼 (cockpit_orch.py send) を送れるか」。frontend は
2023
+ * 従来「tmux のセッション行が存在するか」だけで起動中を判定していたため、**依頼を送れない
2024
+ * セッションが起動中と表示される**食い違いがあった。判定は live-sessions.mjs の
2025
+ * `isSessionWarm` (sibling-inject の宛先候補列挙と同一関数) に一本化する。accessor 未指定
2026
+ * (=旧呼び出し) では `warm` を付与しない(推測で false を配らない)。
2027
+ *
2016
2028
  * @param {Array} sessions tmux.list_sessions が返した素の session 行
2017
2029
  * @param {Map<string, object>} lastEventByName session_name → 最新 hook event
2018
2030
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
2019
2031
  * accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
2032
+ * @param {((row: {name: string, cwd?: string|null}) => boolean) | undefined} isWarm
2033
+ * warm 判定 accessor (既定は live-sessions.mjs の isSessionWarm を bind したもの)。
2034
+ * undefined のときは `warm` を付与しない
2020
2035
  */
2021
- export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
2036
+ export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap, isWarm) {
2022
2037
  return sessions.map((s) => {
2023
2038
  const base = {
2024
2039
  ...s,
2025
2040
  last_event: lastEventByName.get(s.name) || null,
2026
2041
  }
2042
+ if (isWarm) base.warm = !!isWarm(s)
2027
2043
  if (!getSnap) return base
2028
2044
  const snap = getSnap(s.name)
2029
2045
  if (!snap) return base
@@ -3542,6 +3558,157 @@ export async function handleUsageRequest(msg, ctx, deps = {}) {
3542
3558
  }
3543
3559
  }
3544
3560
 
3561
+ /**
3562
+ * `tmux.create_session` ハンドラ。指定名・cwd で detached の tmux セッションを作る。
3563
+ *
3564
+ * ⚠️ pane に CLI (claude / codex) を起動するかは **createSession 内の
3565
+ * shouldLaunchPaneCli() が一元判定**する (既定は起動しない)。ここで渡す cliKind /
3566
+ * claudeCmd / codexCmd / initialPrompt 等は、`HUB_AGENT_TMUX_LAUNCH_CLI=1` で
3567
+ * CLI を起動するときにだけ使われる。呼び出し元ごとに既定を変えないため、
3568
+ * このハンドラ側で起動可否を判断しないこと。
3569
+ *
3570
+ * dispatch から切り出して export しているのは、実際に tmux へ渡る引数を
3571
+ * テストから検査できるようにするため (test/tmux-pane-cli-launch.test.mjs)。
3572
+ */
3573
+ export async function handleTmuxCreateSession(msg, ctx) {
3574
+ const name = (msg.session_name || "").trim()
3575
+ const cwd = (msg.cwd || process.env.HOME || "/").trim()
3576
+ if (!name) {
3577
+ ctx.client.send({
3578
+ type: "tmux.create_session.result",
3579
+ request_id: msg.request_id,
3580
+ ok: false,
3581
+ error: "session_name is required",
3582
+ })
3583
+ return
3584
+ }
3585
+ try {
3586
+ await createTmuxSession(name, cwd, {
3587
+ // cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
3588
+ cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3589
+ claudeCmd:
3590
+ typeof msg.claude_cmd === "string"
3591
+ ? msg.claude_cmd
3592
+ : claudeCmdFromAgentConfig(ctx.config),
3593
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3594
+ // codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
3595
+ model:
3596
+ typeof msg.model === "string"
3597
+ ? msg.model
3598
+ : codexDefaultsFromAgentConfig(ctx.config).model,
3599
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3600
+ approvalPolicy:
3601
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3602
+ effort:
3603
+ typeof msg.effort === "string"
3604
+ ? msg.effort
3605
+ : codexDefaultsFromAgentConfig(ctx.config).effort,
3606
+ initialPrompt:
3607
+ typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3608
+ logger: ctx.logger,
3609
+ })
3610
+ tmuxSessionsSnapshotCache.clear()
3611
+ ctx.client.send({
3612
+ type: "tmux.create_session.result",
3613
+ request_id: msg.request_id,
3614
+ ok: true,
3615
+ session_name: name,
3616
+ })
3617
+ } catch (err) {
3618
+ ctx.client.send({
3619
+ type: "tmux.create_session.result",
3620
+ request_id: msg.request_id,
3621
+ ok: false,
3622
+ error: err.message,
3623
+ })
3624
+ }
3625
+ }
3626
+
3627
+ /**
3628
+ * `worktree.create` ハンドラ。git worktree を作ってから、その dir で tmux セッションを作る。
3629
+ * body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?,
3630
+ * approval_policy?, effort?, initial_prompt? }
3631
+ *
3632
+ * ⚠️ pane の CLI 起動可否は handleTmuxCreateSession と同じく createSession 側の
3633
+ * 一元判定に従う (既定は起動しない)。
3634
+ */
3635
+ export async function handleWorktreeCreate(msg, ctx) {
3636
+ const dir = (msg.dir || "").trim()
3637
+ const branch = (msg.branch || "").trim()
3638
+ if (!dir || !branch) {
3639
+ ctx.client.send({
3640
+ type: "worktree.create.result",
3641
+ request_id: msg.request_id,
3642
+ ok: false,
3643
+ error: "dir and branch required",
3644
+ })
3645
+ return
3646
+ }
3647
+ if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
3648
+ ctx.client.send({
3649
+ type: "worktree.create.result",
3650
+ request_id: msg.request_id,
3651
+ ok: false,
3652
+ error: "invalid branch name",
3653
+ })
3654
+ return
3655
+ }
3656
+ try {
3657
+ const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
3658
+ // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
3659
+ // 解釈し、codex なら codex を pane 起動する(既定 claude)。
3660
+ // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
3661
+ // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
3662
+ // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
3663
+ const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
3664
+ const wtSessionName = sessionNameFor(wtName, wtCliKind)
3665
+ await createTmuxSession(wtSessionName, wtPath, {
3666
+ cliKind: wtCliKind,
3667
+ claudeCmd:
3668
+ typeof msg.claude_cmd === "string"
3669
+ ? msg.claude_cmd
3670
+ : claudeCmdFromAgentConfig(ctx.config),
3671
+ codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
3672
+ // codex 用設定 (claude 経路では無視される)。
3673
+ model:
3674
+ typeof msg.model === "string"
3675
+ ? msg.model
3676
+ : codexDefaultsFromAgentConfig(ctx.config).model,
3677
+ sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
3678
+ approvalPolicy:
3679
+ typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
3680
+ effort:
3681
+ typeof msg.effort === "string"
3682
+ ? msg.effort
3683
+ : codexDefaultsFromAgentConfig(ctx.config).effort,
3684
+ initialPrompt:
3685
+ typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
3686
+ logger: ctx.logger,
3687
+ })
3688
+ tmuxSessionsSnapshotCache.clear()
3689
+ ctx.client.send({
3690
+ type: "worktree.create.result",
3691
+ request_id: msg.request_id,
3692
+ ok: true,
3693
+ // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
3694
+ wt_name: wtName,
3695
+ // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
3696
+ // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
3697
+ session_name: wtSessionName,
3698
+ wt_path: wtPath,
3699
+ branch,
3700
+ created_branch: createdBranch,
3701
+ })
3702
+ } catch (err) {
3703
+ ctx.client.send({
3704
+ type: "worktree.create.result",
3705
+ request_id: msg.request_id,
3706
+ ok: false,
3707
+ error: err.message,
3708
+ })
3709
+ }
3710
+ }
3711
+
3545
3712
  async function dispatch(msg, ctx) {
3546
3713
  const t = msg?.type || ""
3547
3714
  try {
@@ -4889,6 +5056,13 @@ async function dispatch(msg, ctx) {
4889
5056
  sessions,
4890
5057
  lastEventByName,
4891
5058
  ctx.getSessionStateSnapshot,
5059
+ // warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
5060
+ // 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
5061
+ (row) =>
5062
+ isSessionWarm(
5063
+ { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5064
+ row,
5065
+ ),
4892
5066
  )
4893
5067
  const liveNames = new Set(enriched.map((s) => s.name))
4894
5068
  let worktreeStubs = []
@@ -4939,135 +5113,11 @@ async function dispatch(msg, ctx) {
4939
5113
  return
4940
5114
  }
4941
5115
  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
- }
5116
+ await handleTmuxCreateSession(msg, ctx)
4993
5117
  return
4994
5118
  }
4995
5119
  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
- }
5120
+ await handleWorktreeCreate(msg, ctx)
5071
5121
  return
5072
5122
  }
5073
5123
  case "worktree.list_history": {
@@ -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 本も無い。` +
package/src/tmux.mjs CHANGED
@@ -5,8 +5,9 @@
5
5
  * - `listSessions()`: 構造化された TmuxSession[] を返す。各 session に
6
6
  * status (state.mjs の detectSessionState) と cwd (active pane の current_path)
7
7
  * を並列で付与
8
- * - `createSession(name, cwd, opts)`: detached new-session + CLAUDE_CMD を
9
- * send-keys。既存ならエラー throw
8
+ * - `createSession(name, cwd, opts)`: detached new-session。既定では pane に CLI
9
+ * (claude / codex) を起動せず素のシェルのまま置く。`HUB_AGENT_TMUX_LAUNCH_CLI=1`
10
+ * のときだけ従来どおり CLAUDE_CMD / CODEX_CMD を send-keys する。既存ならエラー throw
10
11
  * - `killSession(name)`: 1 session を kill。存在しない場合は 'session not found' throw
11
12
  * - `killManySessions(names)`: 複数 kill、存在しないものは無視
12
13
  *
@@ -509,6 +510,26 @@ export function composeClaudeCmdFromOptions(opts = {}) {
509
510
 
510
511
  const DEFAULT_CLAUDE_CMD = buildClaudeCmd()
511
512
 
513
+ /**
514
+ * tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
515
+ *
516
+ * **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
517
+ * 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
518
+ * app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
519
+ * **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
520
+ * 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
521
+ *
522
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
523
+ * 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
524
+ * この 1 箇所 (createSession 内) だけで行い、opts では上書きさせない。
525
+ *
526
+ * @param {NodeJS.ProcessEnv} [env]
527
+ * @returns {boolean}
528
+ */
529
+ export function shouldLaunchPaneCli(env = process.env) {
530
+ return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
531
+ }
532
+
512
533
  function tmuxBin(opts = {}) {
513
534
  return opts.tmuxBin || DEFAULT_TMUX_BIN
514
535
  }
@@ -546,6 +567,10 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
546
567
  * しか効かないため、injectInitialPrompt と同様に `||` で split して両側に付ける。
547
568
  *
548
569
  * configDir が空 (= 既定プロファイル) のときは何もしない。
570
+ *
571
+ * ⚠️ この関数は **CLI を pane 起動するときだけ** 使われる
572
+ * (`HUB_AGENT_TMUX_LAUNCH_CLI=1`)。素のシェルの pane へは setTmuxGlobalEnv の
573
+ * `set-environment -g` 経由で CLAUDE_CONFIG_DIR が渡る。
549
574
  */
550
575
  export function injectConfigDirEnv(claudeCmd, configDir) {
551
576
  if (!configDir || typeof configDir !== "string") return claudeCmd
@@ -559,8 +584,10 @@ export function injectConfigDirEnv(claudeCmd, configDir) {
559
584
 
560
585
  /**
561
586
  * tmux サーバーのグローバル環境変数を設定/解除する。
562
- * プロファイル切替後に手動で開く pane や respawn にも CLAUDE_CONFIG_DIR が
563
- * 行き渡るようにするための補助線 (主経路は injectConfigDirEnv のコマンド焼き込み)。
587
+ * pane は既定で素のシェルなので、ユーザーが pane 内で手動起動する claude / codex に
588
+ * CLAUDE_CONFIG_DIR (CODEX_HOME) を届ける **主経路** がこれ。
589
+ * (CLI を pane 起動する `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ、
590
+ * injectConfigDirEnv のコマンド焼き込みが併用される)。
564
591
  * value が null/undefined なら unset (-gu)。tmux サーバー未起動などの失敗は握り潰す。
565
592
  */
566
593
  export async function setTmuxGlobalEnv(name, value, opts = {}) {
@@ -766,12 +793,21 @@ export async function killManySessions(names, opts = {}) {
766
793
  }
767
794
 
768
795
  /**
769
- * 新規 session を detached で作成して claude を起動する。
796
+ * 新規 session を detached で作成する。
797
+ *
798
+ * ⚠️ **既定では pane に CLI (claude / codex) を起動しない**(pane は素のシェルのまま)。
799
+ * Cockpit の通常運用はチャットモードで、pane の TUI は誰も見ないまま常駐して
800
+ * メモリを食うだけだったため (詳細は shouldLaunchPaneCli の JSDoc)。
801
+ * `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり CLI を send-keys する。
802
+ *
770
803
  * - 同名 session が既にあれば 'duplicate session' throw
771
- * - opts.claudeCmd で send-keys 内容を上書き可 (空文字なら claude 自動起動しない)
772
- * - opts.initialPrompt を渡すと `claude ... --permission-mode auto` の直後に
773
- * shell-safe 引数として埋め込まれ、claude TUI の初回プロンプトとして実行される
774
- * (`/orchestrate` 等のスキル発火に利用)
804
+ * - CLAUDE.md / AGENTS.md の生成・Hub 同期、cwd 自動作成、mouse mode 設定は
805
+ * **CLI を起動するかどうかに関わらず**常に行う(素のシェルでも必要なため)
806
+ * - 以下は CLI を起動するときだけ意味を持つので、起動時のみ評価される:
807
+ * opts.claudeCmd / opts.codexCmd / opts.cliKind / opts.claudeConfigDir /
808
+ * opts.codexHome / opts.initialPrompt / model・sandbox 等の codex フラグ
809
+ * - opts.initialPrompt は CLI 起動時に位置引数として末尾へ shell-safe に埋め込まれ、
810
+ * TUI の初回プロンプトとして実行される (`/orchestrate` 等のスキル発火に利用)
775
811
  */
776
812
  export async function createSession(name, cwd, opts = {}) {
777
813
  // tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
@@ -918,6 +954,21 @@ export async function createSession(name, cwd, opts = {}) {
918
954
  // display-message 失敗は致命的でないので飲み込む
919
955
  }
920
956
  }
957
+ // 既定では pane を素のシェルのまま残す (CLI は起動しない)。ここから下は
958
+ // HUB_AGENT_TMUX_LAUNCH_CLI=1 のときだけ走る。
959
+ //
960
+ // 素のシェルでも必要な処理 (cwd 解決・CLAUDE.md 生成/同期・mouse mode) は上で
961
+ // 済ませてある。CLAUDE_CONFIG_DIR / CODEX_HOME も、pane で手動起動したときに
962
+ // 効くよう main.mjs の syncTmuxProfileEnv / syncTmuxCodexEnv が
963
+ // `tmux set-environment -g` でサーバーのグローバル環境へ流し込んでいるため、
964
+ // ここでコマンド文字列に焼き込む必要があるのは CLI を起動する場合だけ。
965
+ if (!shouldLaunchPaneCli()) {
966
+ opts.logger?.debug?.(
967
+ { session: name, cwd: resolvedCwd },
968
+ "pane CLI launch skipped (set HUB_AGENT_TMUX_LAUNCH_CLI=1 to enable)",
969
+ )
970
+ return
971
+ }
921
972
  // cliKind に応じて claude / codex いずれの CLI を pane で起動するか分岐する。
922
973
  // 既定は claude(後方互換)。initialPrompt はどちらも位置引数として末尾注入する
923
974
  // (claude "<p>" / codex "<p>" / codex resume <id> "<p>" いずれも [PROMPT] を受ける)。