@cocorograph/hub-agent 0.7.47 → 0.7.49

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.7.47",
3
+ "version": "0.7.49",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
Binary file
@@ -20,12 +20,31 @@
20
20
  * もともと config ディレクトリ単位で分離済み (profiles.mjs の hasFileCredentials を使う)。
21
21
  */
22
22
  import { promises as fs } from "node:fs"
23
+ import os from "node:os"
24
+ import path from "node:path"
23
25
  import { createHash } from "node:crypto"
24
26
  import { execFile } from "node:child_process"
25
27
  import { promisify } from "node:util"
26
28
 
27
29
  const execFileP = promisify(execFile)
28
30
 
31
+ /**
32
+ * 既定 (プライマリ) プロファイルで CLAUDE_CONFIG_DIR が未設定のとき、Claude Code は
33
+ * ハッシュ無しの素のサービス名 `Claude Code-credentials` に OAuth トークンを保存する
34
+ * (ハッシュ付き `-<sha256>` を使うのは CLAUDE_CONFIG_DIR を明示した非既定プロファイルだけ)。
35
+ *
36
+ * ⚠️ 2026-07 実バグ: 既定プロファイル (`~/.claude`) を「ハッシュ付き名だけ」で在否
37
+ * チェックしていたため、ログイン済みで実際に使用中でも常に空振りし、Cockpit の
38
+ * アカウント切替 UI に「要ログイン」が出っぱなしになっていた。既定プロファイルは
39
+ * 素のサービス名も候補に含めて判定する。
40
+ */
41
+ export const PLAIN_CREDENTIAL_SERVICE = "Claude Code-credentials"
42
+
43
+ /** 既定プロファイル (CLAUDE_CONFIG_DIR 未設定時) の config ディレクトリ (`~/.claude`)。 */
44
+ function defaultConfigDir() {
45
+ return path.join(os.homedir(), ".claude")
46
+ }
47
+
29
48
  /** Keychain を使うプラットフォームか (認証分離が Keychain 依存なのは darwin のみ)。 */
30
49
  export function isKeychainPlatform(platform = process.platform) {
31
50
  return platform === "darwin"
@@ -76,6 +95,11 @@ export async function existsGenericPassword(service, opts = {}) {
76
95
  * symlink パスと realpath のどちらでハッシュを計算するかに依存しないよう、両方の
77
96
  * ハッシュ候補を在否チェックし、いずれか存在すればログイン済みとみなす。
78
97
  *
98
+ * さらに既定プロファイル (~/.claude、CLAUDE_CONFIG_DIR 未設定) のときは、Claude Code が
99
+ * ハッシュ無しの素の `Claude Code-credentials` に保存するため、そのサービス名も候補に
100
+ * 含める (PLAIN_CREDENTIAL_SERVICE のコメント参照)。含めないと既定プロファイルが常に
101
+ * 空振りして「要ログイン」が出っぱなしになる。
102
+ *
79
103
  * @param {string} configDir
80
104
  * @param {{execFileP?: Function}} [opts]
81
105
  * @returns {Promise<boolean>}
@@ -87,9 +111,28 @@ export async function profileHasKeychainCredentials(configDir, opts = {}) {
87
111
  } catch {
88
112
  /* realpath 不能 (ディレクトリ未作成等) は無視して literal パスのみ評価 */
89
113
  }
90
- for (const dir of candidates) {
114
+
115
+ // チェックするサービス名を組み立てる。各 configDir 候補のハッシュ付き名に加え、
116
+ // 既定プロファイル (~/.claude、CLAUDE_CONFIG_DIR 未設定) のときだけ素のサービス名を足す。
117
+ const services = new Set()
118
+ for (const dir of candidates) services.add(credentialService(dir))
119
+
120
+ // 既定プロファイル判定: configDir 候補 (literal / realpath) のいずれかが
121
+ // ~/.claude (literal / realpath) に一致するか。iCloud 等への symlink でも拾える。
122
+ const defaultCandidates = new Set([defaultConfigDir()])
123
+ try {
124
+ defaultCandidates.add(await fs.realpath(defaultConfigDir()))
125
+ } catch {
126
+ /* ~/.claude 未作成なら literal のみで判定 */
127
+ }
128
+ const isDefaultProfile = [...candidates].some((dir) =>
129
+ defaultCandidates.has(dir),
130
+ )
131
+ if (isDefaultProfile) services.add(PLAIN_CREDENTIAL_SERVICE)
132
+
133
+ for (const service of services) {
91
134
  try {
92
- if (await existsGenericPassword(credentialService(dir), opts)) return true
135
+ if (await existsGenericPassword(service, opts)) return true
93
136
  } catch {
94
137
  /* security 実行エラーは「不明」→ 存在しない寄せ */
95
138
  }
package/src/main.mjs CHANGED
@@ -117,6 +117,7 @@ import {
117
117
  recordChatActivity,
118
118
  recordChatStreamTick,
119
119
  } from "./chat-signals.mjs"
120
+ import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
120
121
  import {
121
122
  ReadinessTracker,
122
123
  busyChildCount,
@@ -598,14 +599,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
598
599
  if (event?.type === "assistant") {
599
600
  const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
600
601
  try {
601
- recordChatActivity(cwd, {
602
+ // cliKind は chat-signals のキーの一部 (T05945)。同一 cwd で走る codex の信号と
603
+ // 混ざらないよう、claude 由来のイベントは必ず "claude" バケットへ書く。
604
+ // sessionId は同一 CLI 内でのセッション乗り換え検知用 identity (S1)。
605
+ recordChatActivity(cwd, "claude", {
602
606
  status: "processing",
603
607
  contextPct: pct,
604
608
  inputPending: false,
605
- // identity (S1): この cwd の chat 信号が「今どの CLI・どのセッションのものか」を
606
- // 明示する。claude.attach で確定させた identity と一致すれば温存、別セッション/
607
- // エンジンに切り替わっていれば chat-signals.mjs 側が自動でリセットする。
608
- cliKind: "claude",
609
609
  sessionId: session_id || null,
610
610
  })
611
611
  } catch {
@@ -613,10 +613,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
613
613
  }
614
614
  } else if (event?.type === "result") {
615
615
  try {
616
- recordChatActivity(cwd, {
616
+ recordChatActivity(cwd, "claude", {
617
617
  status: "waiting",
618
618
  inputPending: false,
619
- cliKind: "claude",
620
619
  sessionId: session_id || null,
621
620
  })
622
621
  } catch {
@@ -629,7 +628,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
629
628
  // 純 SDK チャットで三点リーダーが消える。stream_event 受信を throttle 付き processing
630
629
  // tick として記録し statusAt を前進させる (本文は読まずタイムスタンプ前進のみ)。
631
630
  try {
632
- recordChatStreamTick(cwd)
631
+ recordChatStreamTick(cwd, "claude")
633
632
  } catch {
634
633
  /* ignore */
635
634
  }
@@ -637,12 +636,14 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
637
636
  client.send({ type: "claude.event", stream_id, session_id, event })
638
637
  })
639
638
  claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
640
- // 確認待ち信号 (2026-06-02): cwd キーで inputPending を立て、サイドバーの
639
+ // 確認待ち信号 (2026-06-02): (cwd, cliKind) キーで inputPending を立て、サイドバーの
641
640
  // 「確認待ち」ドット & 通知の発火源にする。次の assistant/result で落ちる。
642
641
  // これにより「裏で動いているセッションがどれか分からない」発見性の問題を解消する。
642
+ // T05945: 旧実装は cliKind を伝播しておらず、codex の承認カードでも claude 行の
643
+ // 確認待ちドットが点く (逆も然り) 交差汚染があった。CLI を明示して分離する。
643
644
  if (cwd) {
644
645
  try {
645
- recordChatActivity(cwd, { inputPending: true })
646
+ recordChatActivity(cwd, "claude", { inputPending: true })
646
647
  } catch {
647
648
  /* ignore */
648
649
  }
@@ -667,7 +668,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
667
668
  // (正常完了は上の result ハンドラが既に waiting にしている)。
668
669
  if (cwd) {
669
670
  try {
670
- recordChatActivity(cwd, { status: "waiting", inputPending: false })
671
+ recordChatActivity(cwd, "claude", { status: "waiting", inputPending: false })
671
672
  } catch {
672
673
  /* ignore */
673
674
  }
@@ -699,11 +700,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
699
700
  /* ignore */
700
701
  }
701
702
  try {
702
- recordChatActivity(cwd, {
703
+ recordChatActivity(cwd, "codex", {
703
704
  status: "processing",
704
705
  contextPct,
705
706
  inputPending: false,
706
- cliKind: "codex",
707
707
  sessionId: thread_id || null,
708
708
  })
709
709
  } catch {
@@ -720,11 +720,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
720
720
  /* ignore */
721
721
  }
722
722
  try {
723
- recordChatActivity(cwd, {
723
+ recordChatActivity(cwd, "codex", {
724
724
  status: "waiting",
725
725
  contextPct,
726
726
  inputPending: false,
727
- cliKind: "codex",
728
727
  sessionId: thread_id || null,
729
728
  })
730
729
  } catch {
@@ -737,7 +736,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
737
736
  codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
738
737
  if (cwd) {
739
738
  try {
740
- recordChatActivity(cwd, { inputPending: true })
739
+ // T05945: codex の承認待ちは codex 行にだけ立てる (claude 行へ漏らさない)。
740
+ recordChatActivity(cwd, "codex", { inputPending: true })
741
741
  } catch {
742
742
  /* ignore */
743
743
  }
@@ -909,7 +909,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
909
909
  async ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
910
910
  if (cwd) {
911
911
  try {
912
- recordChatActivity(cwd, { inputPending: true })
912
+ // TUI 権限ブリッジ (PreToolUse フック) は Claude Code TUI 専用の経路なので
913
+ // 常に claude バケットへ記録する (T05945)。
914
+ recordChatActivity(cwd, "claude", { inputPending: true })
913
915
  } catch {
914
916
  /* ignore */
915
917
  }
@@ -1524,8 +1526,13 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
1524
1526
  *
1525
1527
  * cwd → session_name の解決: claude.attach / codex.attach は tmux の session_name を知らない
1526
1528
  * (browser は cwd/stream_id しか送らない) ため、listSessionStates で全 tmux session を走査し
1527
- * cwd が一致するものへ push する (通常は 1 cwd = 1 tmux session)。tmux 自体が無い / 一致が
1528
- * 無ければ何もしない (安全側、次の poll 系フォールバックに委ねる)。
1529
+ * cwd が一致するものへ push する。tmux 自体が無い / 一致が無ければ何もしない (安全側、次の
1530
+ * poll 系フォールバックに委ねる)。
1531
+ *
1532
+ * ⚠️ T05945: 同一 cwd に claude 行 (`<dir>`) と codex 行 (`<dir>__codex`) が並ぶため、cwd 一致
1533
+ * だけで push すると **attach した CLI のゼロ値/実値が反対側の CLI 行にも書き込まれる**
1534
+ * (codex を新規 attach した瞬間に claude 行のドーナツが 0% に落ちる)。行のセッション名から
1535
+ * 導いた cli_kind が attach 中の CLI と一致する行にだけ push する。
1529
1536
  *
1530
1537
  * `listSessionStatesFn` は単体テスト用の DI ポイント (既定は実 `listSessionStates`、tmux を
1531
1538
  * 起動する)。実運用では省略してよい。
@@ -1547,6 +1554,7 @@ export async function pushImmediateChatState({
1547
1554
  const states = await listSessionStatesFn({ plugins, logger })
1548
1555
  for (const s of states) {
1549
1556
  if (s.cwd !== cwd) continue
1557
+ if (cliKindForSessionName(s.session_name) !== cliKind) continue
1550
1558
  client.send({
1551
1559
  type: "session.state",
1552
1560
  session_name: s.session_name,
@@ -1600,18 +1608,35 @@ export function resolveStateLoopBoundSessionId(boundSessions, sessionName, cwd,
1600
1608
  * 1. chat 信号が processing → "chat-active" (true)
1601
1609
  * 2. chat 信号が waiting/idle → "chat-inactive" (false)
1602
1610
  * 3. fresh unbound bind (adopt 前の bind プレースホルダ) → "fresh-unbound" (null=不明、安全側)
1603
- * 4. cwd に一致するライブ codexBridge セッションがある → "codex" (rollout ベース判定)
1604
- * 5. cwd があれば → "claude" (jsonl ベース判定、TUI/bound session フォールバック)
1611
+ * 4. **その行が codex セッション** なら → codexThreadId があれば "codex" (rollout ベース判定)、
1612
+ * 無ければ "none" (null=不明)
1613
+ * 5. **その行が claude セッション** で cwd があれば → "claude" (jsonl ベース判定)
1605
1614
  * 6. cwd も無ければ → "none" (null=不明)
1606
1615
  *
1607
- * @param {{chatStatus?: string|null, cwd?: string|null, isFreshUnbound?: boolean, codexThreadId?: string|null}} params
1616
+ * ## sessionCliKind による分岐 (T05945, 2026-07-09)
1617
+ * 旧実装は `codexThreadId` (= cwd に一致するライブ codex セッションの有無) だけで codex 判定を
1618
+ * していた。同一 cwd に claude 行 (`<dir>`) と codex 行 (`<dir>__codex`) が並ぶ新運用では、
1619
+ * codex チャットが走っているだけで **claude 行の turn_active まで codex の rollout で判定** して
1620
+ * しまう (交差汚染)。行そのものの CLI 種別 (セッション名から構造的に導出) を第一の分岐軸にする。
1621
+ *
1622
+ * codex 行で codexThreadId が無い場合 (= codex TUI ペインだけがあり chat 未 attach) は "none"。
1623
+ * 旧実装はここで "claude" に落ちて **claude の jsonl を読んでいた** ため、無関係な claude
1624
+ * アクティビティで青ドットが点きうる欠陥があった。null=不明 に倒すのが安全側。
1625
+ *
1626
+ * @param {{chatStatus?: string|null, cwd?: string|null, isFreshUnbound?: boolean, codexThreadId?: string|null, sessionCliKind?: 'claude'|'codex'}} params
1608
1627
  * @returns {'chat-active'|'chat-inactive'|'fresh-unbound'|'codex'|'claude'|'none'}
1609
1628
  */
1610
- export function decideTurnActiveSource({ chatStatus, cwd, isFreshUnbound, codexThreadId }) {
1629
+ export function decideTurnActiveSource({
1630
+ chatStatus,
1631
+ cwd,
1632
+ isFreshUnbound,
1633
+ codexThreadId,
1634
+ sessionCliKind = "claude",
1635
+ }) {
1611
1636
  if (chatStatus === "processing") return "chat-active"
1612
1637
  if (chatStatus === "waiting" || chatStatus === "idle") return "chat-inactive"
1613
1638
  if (cwd && isFreshUnbound) return "fresh-unbound"
1614
- if (codexThreadId) return "codex"
1639
+ if (sessionCliKind === "codex") return codexThreadId ? "codex" : "none"
1615
1640
  if (cwd) return "claude"
1616
1641
  return "none"
1617
1642
  }
@@ -1652,6 +1677,10 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1652
1677
  await refreshContextWindow()
1653
1678
  const states = await listSessionStates({ plugins, logger })
1654
1679
  for (const s of states) {
1680
+ // T05945: この行がどの CLI のセッションかは **セッション名から構造的に決まる**
1681
+ // (`<dir>` = claude / `<dir>__codex` = codex)。chat 信号の読み出し・turn_active の
1682
+ // 判定源・session.state に載せる cli_kind はすべてこの 1 値から導く (正本一本化)。
1683
+ const sessionCliKind = cliKindForSessionName(s.session_name)
1655
1684
  // 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
1656
1685
  // tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
1657
1686
  const permissionMode = s.permission_mode ?? null
@@ -1666,7 +1695,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1666
1695
  // 返せないため、SDK チャットの補完は従来どおり効く。TUI 生成中に stale な SDK
1667
1696
  // status (waiting) が勝ってステータスドット/三点リーダーが消える事故の防止
1668
1697
  // (2026-06-12) は paneIsProcessing ガードのまま維持される。
1669
- const chat = s.cwd ? getChatSignal(s.cwd) : null
1698
+ // (cwd, cliKind) キーで自 CLI のチャット信号だけを読む。同一 cwd の別 CLI 行が
1699
+ // 立てた status/context_pct を拾わない (交差汚染の遮断)。
1700
+ const chat = s.cwd ? getChatSignal(s.cwd, sessionCliKind) : null
1670
1701
  const overlay = applyChatOverlay(s.status, s.context_pct, chat)
1671
1702
  let status = overlay.status
1672
1703
  let contextPct = overlay.contextPct
@@ -1755,12 +1786,17 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1755
1786
  // null=不明のまま、退行はしない)。
1756
1787
  // 分岐の優先順位は decideTurnActiveSource (純関数、単体テスト済み) が権威。
1757
1788
  let turnActive = null
1758
- const codexThreadId = s.cwd ? codexBridge?.threadIdForCwd?.(s.cwd) || null : null
1789
+ // codex 行のときだけ rollout ベース判定の材料を引く (claude 行では常に null)。
1790
+ const codexThreadId =
1791
+ s.cwd && sessionCliKind === "codex"
1792
+ ? codexBridge?.threadIdForCwd?.(s.cwd) || null
1793
+ : null
1759
1794
  const turnActiveSource = decideTurnActiveSource({
1760
1795
  chatStatus: chat?.status,
1761
1796
  cwd: s.cwd,
1762
1797
  isFreshUnbound: isFreshUnboundBind(boundSessions?.get(s.session_name)),
1763
1798
  codexThreadId,
1799
+ sessionCliKind,
1764
1800
  })
1765
1801
  switch (turnActiveSource) {
1766
1802
  case "chat-active":
@@ -1790,11 +1826,15 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1790
1826
  turnActive = null
1791
1827
  }
1792
1828
 
1793
- // S1 (2026-07): チャット信号 (getChatSignal) が identity (cliKind/sessionId) を
1794
- // 持っていれば、それをそのまま session.state に同梱する。frontend はこれで「今
1795
- // 表示中のエンジン/セッションと一致する push か」を判定し、不一致の値を適用しない
1796
- // (前セッションの値を上書きし続ける症状③④の frontend 側の受け口)。
1797
- const chatCliKind = chat?.cliKind ?? null
1829
+ // cli_kind (T05945): **セッション名から導いた構造的な値**を常に載せる。旧実装は
1830
+ // 「チャット信号があればその cliKind、無ければ null」だったため、TUI 専用行や
1831
+ // チャット未接続行で null になり、frontend が行の CLI を知る術が無かった。
1832
+ // (cwd, cliKind) キー化により「チャット信号の cliKind == 行の cliKind」が恒真に
1833
+ // なったので、常に非 null の構造値を送る方が真に厳しい (= 情報量が多い) 上位互換。
1834
+ //
1835
+ // chat_session_id は「その行のチャットが今どのセッション/スレッドか」。チャット信号が
1836
+ // 無ければ null (TUI 専用行)。
1837
+ const chatCliKind = sessionCliKind
1798
1838
  const chatSessionId = chat?.sessionId ?? null
1799
1839
 
1800
1840
  const prev = lastByName.get(s.session_name)
@@ -1860,9 +1900,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1860
1900
  // turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
1861
1901
  // (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
1862
1902
  turn_active: turnActive,
1863
- // cli_kind / chat_session_id (S1, 2026-07): このセッションの現在の chat 信号が
1864
- // どの CLI・どのセッションのものか。チャット信号が無い (TUI 専用/未対応) 行では
1865
- // null (旧クライアント互換、frontend は無条件マージにフォールバック)。
1903
+ // cli_kind (T05945): セッション名由来の構造値 ('claude' | 'codex')。frontend の
1904
+ // 単一セレクタ (useCockpitSessionDisplay) が「この push はどの CLI 行のものか」を
1905
+ // 判定する唯一の軸。chat_session_id はチャット信号が無ければ null。
1866
1906
  cli_kind: chatCliKind,
1867
1907
  chat_session_id: chatSessionId,
1868
1908
  })
@@ -2662,12 +2702,11 @@ async function dispatch(msg, ctx) {
2662
2702
  const resolvedCwd = cwdArg || process.env.HOME || process.cwd()
2663
2703
  if (resolvedCwd) {
2664
2704
  if (!resumeSessionId) {
2665
- clearChatSignal(resolvedCwd)
2666
- recordChatActivity(resolvedCwd, {
2705
+ clearChatSignal(resolvedCwd, "claude")
2706
+ recordChatActivity(resolvedCwd, "claude", {
2667
2707
  status: "waiting",
2668
2708
  contextPct: 0,
2669
2709
  inputPending: false,
2670
- cliKind: "claude",
2671
2710
  sessionId: null,
2672
2711
  })
2673
2712
  pushImmediateChatState({
@@ -2685,11 +2724,10 @@ async function dispatch(msg, ctx) {
2685
2724
  .then((jsonlCtx) => {
2686
2725
  const contextPct =
2687
2726
  typeof jsonlCtx?.percent === "number" ? jsonlCtx.percent : 0
2688
- recordChatActivity(resolvedCwd, {
2727
+ recordChatActivity(resolvedCwd, "claude", {
2689
2728
  status: "waiting",
2690
2729
  contextPct,
2691
2730
  inputPending: false,
2692
- cliKind: "claude",
2693
2731
  sessionId: resumeSessionId,
2694
2732
  })
2695
2733
  return pushImmediateChatState({
@@ -3305,7 +3343,9 @@ async function dispatch(msg, ctx) {
3305
3343
  // status/context% 補完) は役目を終える。残すと TUI 権限カードの
3306
3344
  // inputPending 記録が stale status の鮮度を延命し、state loop が
3307
3345
  // ペイン実状態を waiting で上書きし続ける (2026-06-12 真因修正)。
3308
- if (cwd) clearChatSignal(cwd)
3346
+ // T05945: claude バケットのみ消す。同一 cwd で走っている codex チャットの
3347
+ // 信号まで消すと、codex 行のドット/ドーナツが巻き添えで消灯する。
3348
+ if (cwd) clearChatSignal(cwd, "claude")
3309
3349
  // 3) tmux claude を起動し直す (resume or fresh)。
3310
3350
  // 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
3311
3351
  // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
@@ -3694,12 +3734,11 @@ async function dispatch(msg, ctx) {
3694
3734
  // - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
3695
3735
  if (resolvedCwd) {
3696
3736
  if (!resumeThreadId) {
3697
- clearChatSignal(resolvedCwd)
3698
- recordChatActivity(resolvedCwd, {
3737
+ clearChatSignal(resolvedCwd, "codex")
3738
+ recordChatActivity(resolvedCwd, "codex", {
3699
3739
  status: "waiting",
3700
3740
  contextPct: 0,
3701
3741
  inputPending: false,
3702
- cliKind: "codex",
3703
3742
  sessionId: info.threadId || null,
3704
3743
  })
3705
3744
  pushImmediateChatState({
@@ -3716,11 +3755,10 @@ async function dispatch(msg, ctx) {
3716
3755
  .catch(() => null)
3717
3756
  .then((usage) => {
3718
3757
  const contextPct = typeof usage?.context === "number" ? usage.context : 0
3719
- recordChatActivity(resolvedCwd, {
3758
+ recordChatActivity(resolvedCwd, "codex", {
3720
3759
  status: "waiting",
3721
3760
  contextPct,
3722
3761
  inputPending: false,
3723
- cliKind: "codex",
3724
3762
  sessionId: info.threadId,
3725
3763
  })
3726
3764
  return pushImmediateChatState({
@@ -4362,10 +4400,14 @@ async function dispatch(msg, ctx) {
4362
4400
  try {
4363
4401
  const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
4364
4402
  // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
4365
- // 解釈し、codex なら codex を pane 起動する(既定 claude)。worktree は別 cwd=
4366
- // 別セッション名なので、メイン=claude / worktree=codex の並走が成立する。
4367
- await createTmuxSession(wtName, wtPath, {
4368
- cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
4403
+ // 解釈し、codex なら codex を pane 起動する(既定 claude)。
4404
+ // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
4405
+ // 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
4406
+ // 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
4407
+ const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
4408
+ const wtSessionName = sessionNameFor(wtName, wtCliKind)
4409
+ await createTmuxSession(wtSessionName, wtPath, {
4410
+ cliKind: wtCliKind,
4369
4411
  claudeCmd:
4370
4412
  typeof msg.claude_cmd === "string"
4371
4413
  ? msg.claude_cmd
@@ -4391,7 +4433,11 @@ async function dispatch(msg, ctx) {
4391
4433
  type: "worktree.create.result",
4392
4434
  request_id: msg.request_id,
4393
4435
  ok: true,
4436
+ // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
4394
4437
  wt_name: wtName,
4438
+ // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
4439
+ // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
4440
+ session_name: wtSessionName,
4395
4441
  wt_path: wtPath,
4396
4442
  branch,
4397
4443
  created_branch: createdBranch,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * tmux セッション名と CLI 種別 (cli_kind) の対応規約 — **正本 (SoT)**。
3
+ *
4
+ * ## 背景
5
+ * 従来は「1 tmux セッション名 = 1 ワークスペース dir」で、そのペインで claude を動かすか
6
+ * codex を動かすかは起動時の一過性パラメータでしかなかった。そのため同一 dir で claude と
7
+ * codex を独立に起動/終了できず、サイドバーのステータスドット・コンテキストドーナツ・kill は
8
+ * すべて「dir 単位」の粒度しか持てなかった (2026-07-09 調査)。
9
+ *
10
+ * ## 規約 (2026-07-09 確定)
11
+ * claude → `<dir>` (後方互換。既存セッションはそのまま claude として解釈される)
12
+ * codex → `<dir>__codex` (予約サフィックス)
13
+ *
14
+ * `__codex` を予約サフィックスに選べる根拠 (Phase0 スパイク済み):
15
+ * - frontend の `sanitizeSessionName` は `.` `:` のみ `-` へ置換し、`_` は保持する
16
+ * - 既存のセッション命名 (workspace dir / worktree dir 由来) に `__` 区切りの使用例が無い
17
+ *
18
+ * ## 不変条件
19
+ * - `sessionNameFor(baseSessionName(n), cliKindForSessionName(n)) === n` (往復同一)
20
+ * - `cliKindForSessionName` は「末尾が `__codex` か」だけで決まる純関数。tmux / 設定 /
21
+ * ブリッジの状態を一切参照しない。これが「セッション行の CLI 種別を 1 箇所だけ見れば
22
+ * 済む」ための SoT である。
23
+ *
24
+ * frontend 側の対応実装は `frontend/lib/cockpit/sessionKey.ts` (同じ規約を TypeScript で再掲)。
25
+ * どちらかを変更したら必ず両方揃えること。
26
+ */
27
+
28
+ /** codex セッションを表す予約サフィックス。 */
29
+ export const CODEX_SESSION_SUFFIX = "__codex"
30
+
31
+ /** 有効な cli_kind。 */
32
+ const VALID_CLI_KINDS = new Set(["claude", "codex"])
33
+
34
+ /**
35
+ * 任意の入力を cli_kind へ正規化する。未知値 / null / undefined は "claude" (既定 CLI)。
36
+ *
37
+ * 既定を claude にする理由: 本リポジトリの歴史的既定が claude であり、cli_kind を伝播し
38
+ * 忘れた呼び出し元があっても「codex の状態が claude 行に混ざる」より「claude 行に落ちる」
39
+ * 方が退行が小さい (claude が圧倒的多数のセッション)。
40
+ *
41
+ * @param {unknown} value
42
+ * @returns {'claude'|'codex'}
43
+ */
44
+ export function normalizeCliKind(value) {
45
+ return VALID_CLI_KINDS.has(value) ? value : "claude"
46
+ }
47
+
48
+ /**
49
+ * tmux セッション名からその行の CLI 種別を導出する (構造的判定)。
50
+ *
51
+ * @param {string} sessionName
52
+ * @returns {'claude'|'codex'}
53
+ */
54
+ export function cliKindForSessionName(sessionName) {
55
+ if (typeof sessionName !== "string") return "claude"
56
+ return sessionName.endsWith(CODEX_SESSION_SUFFIX) ? "codex" : "claude"
57
+ }
58
+
59
+ /**
60
+ * tmux セッション名から CLI サフィックスを剥がしてベース名 (= ワークスペース / worktree の
61
+ * dir 名) を返す。worktree の親子解決・stub 突合など「dir 単位」で扱いたい箇所で使う。
62
+ *
63
+ * @param {string} sessionName
64
+ * @returns {string}
65
+ */
66
+ export function baseSessionName(sessionName) {
67
+ if (typeof sessionName !== "string") return ""
68
+ return sessionName.endsWith(CODEX_SESSION_SUFFIX)
69
+ ? sessionName.slice(0, -CODEX_SESSION_SUFFIX.length)
70
+ : sessionName
71
+ }
72
+
73
+ /**
74
+ * dir 名 (ベース名) と cli_kind から tmux セッション名を組み立てる。
75
+ *
76
+ * 既に `__codex` が付いたベース名を渡しても二重付与しない (冪等)。
77
+ *
78
+ * @param {string} dir
79
+ * @param {unknown} cliKind
80
+ * @returns {string}
81
+ */
82
+ export function sessionNameFor(dir, cliKind) {
83
+ const base = baseSessionName(String(dir || ""))
84
+ if (!base) return ""
85
+ return normalizeCliKind(cliKind) === "codex" ? `${base}${CODEX_SESSION_SUFFIX}` : base
86
+ }
package/src/tmux.mjs CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  detectSessionState,
29
29
  getSessionCwd,
30
30
  } from "./state.mjs"
31
+ import { baseSessionName, sessionNameFor } from "./session-names.mjs"
31
32
  import { boundSessionId, getSessionUsages, jsonlContextForCwd } from "./usage.mjs"
32
33
 
33
34
  const execFileP = promisify(execFile)
@@ -222,12 +223,18 @@ async function buildWorktreeParentMap() {
222
223
  * 走行中の tmux session 名 set を受け取り、それに含まれない worktree (= 未起動 stub)
223
224
  * のリストを返す。cockpit `tmux.sessions` 応答の `worktree_stubs` フィールドに乗せる
224
225
  * ためのヘルパー。
226
+ *
227
+ * T05945: live 判定は **CLI サフィックスを剥がしたベース名** で行う。codex だけを起動した
228
+ * worktree (`<wt>__codex`) が「未起動 stub」としても同時に列挙され、サイドバーに live 行と
229
+ * stub 行が二重表示される事故を防ぐ。
225
230
  */
226
231
  export async function listWorktreeStubs(liveSessionNames) {
227
232
  const idx = await buildWorktreeIndex()
233
+ const liveBaseNames = new Set()
234
+ for (const n of liveSessionNames) liveBaseNames.add(baseSessionName(n))
228
235
  const stubs = []
229
236
  for (const [name, info] of idx) {
230
- if (liveSessionNames.has(name)) continue
237
+ if (liveBaseNames.has(name)) continue
231
238
  stubs.push(info)
232
239
  }
233
240
  return stubs
@@ -295,7 +302,9 @@ async function inspectWorktreeDirty(wtPath) {
295
302
  * @returns {Promise<{ name: string, wt_path: string, dirty?: boolean, changes?: string[], unpushed_commits?: number }>}
296
303
  */
297
304
  export async function removeWorktree(name, opts = {}) {
298
- const sanitized = sanitizeTmuxName(String(name || "").trim())
305
+ // T05945: frontend は常にベース名を送るが、万一 `<wt>__codex` が来ても worktree index を
306
+ // 引けるようベース名へ正規化する。
307
+ const sanitized = baseSessionName(sanitizeTmuxName(String(name || "").trim()))
299
308
  if (!sanitized) throw new Error("worktree name required")
300
309
  const idx = await buildWorktreeIndex()
301
310
  const info = idx.get(sanitized)
@@ -321,18 +330,25 @@ export async function removeWorktree(name, opts = {}) {
321
330
  }
322
331
  }
323
332
 
324
- // 走行中の session があれば kill
325
- try {
326
- await execFileP(tmuxBin(opts), ["kill-session", "-t", sanitized])
327
- } catch (err) {
328
- const msg = err?.message || String(err)
329
- // session 不在は正常 (停止済みの worktree を消すパスもある)
330
- if (
331
- !msg.includes("can't find session") &&
332
- !msg.includes("no current session") &&
333
- !msg.includes("no server running")
334
- ) {
335
- throw err
333
+ // 走行中の session があれば kill。T05945: 同一 worktree に claude (`<wt>`) と codex
334
+ // (`<wt>__codex`) の 2 セッションが並びうるため、両方を kill してから dir を消す
335
+ // (片方が残ったまま dir を消すと、生存セッションの cwd が消滅した状態になる)。
336
+ for (const target of [
337
+ sessionNameFor(sanitized, "claude"),
338
+ sessionNameFor(sanitized, "codex"),
339
+ ]) {
340
+ try {
341
+ await execFileP(tmuxBin(opts), ["kill-session", "-t", target])
342
+ } catch (err) {
343
+ const msg = err?.message || String(err)
344
+ // session 不在は正常 (停止済みの worktree を消すパス / 片方の CLI しか起動していない)
345
+ if (
346
+ !msg.includes("can't find session") &&
347
+ !msg.includes("no current session") &&
348
+ !msg.includes("no server running")
349
+ ) {
350
+ throw err
351
+ }
336
352
  }
337
353
  }
338
354
  // 親 repo を解決し `git -C <parent> worktree remove --force <wtPath>` を実行
@@ -667,7 +683,10 @@ export async function listSessions(opts = {}) {
667
683
  status: state.status,
668
684
  context_pct,
669
685
  cwd,
670
- parent_session_name: wtParentMap.get(s.name) || null,
686
+ // T05945: worktree の親解決はベース名で行う (`<wt>__codex` も `<wt>` と同じ親配下)。
687
+ // これをしないと codex の worktree セッションが親を失い「その他 (workspace 未登録)」へ
688
+ // 落ちる。
689
+ parent_session_name: wtParentMap.get(baseSessionName(s.name)) || null,
671
690
  }
672
691
  }),
673
692
  )