@cocorograph/hub-agent 0.7.46 → 0.7.48

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.46",
3
+ "version": "0.7.48",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
Binary file
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Codex アカウント・プロファイル管理 (Claude の profiles.mjs と対の Codex 版)。
3
+ *
4
+ * 1 台のマシンで複数の Codex (OpenAI) アカウントを `CODEX_HOME` で切り替えて使うための
5
+ * 仕組み。各プロファイルは独立した Codex ホームディレクトリを持つ。
6
+ *
7
+ * 認証: Codex は OAuth トークン / API キーを `<CODEX_HOME>/auth.json` に **ファイルで**
8
+ * 保存する (Claude の macOS のような Keychain 単一エントリ問題は無い)。したがって
9
+ * `CODEX_HOME` を切り替えるだけでアカウントも分離され、スワップ処理は一切不要。
10
+ *
11
+ * 共有方針 (2026-07, 中村さん決定「アカウントが変わっても振る舞いを変えない」):
12
+ * - **分離するのは認証と純粋なランタイム/ログ/キャッシュだけ**。
13
+ * - 設定 (config.toml) / hooks / skills / scripts / plugins / 記憶 (memories) /
14
+ * 会話履歴 (sessions, history.jsonl) など **振る舞いに影響する資産はすべて共有**する。
15
+ * - 実装は「共有をデフォルトにし、例外 (ISOLATED) だけ分離」する。これにより
16
+ * バージョン番号付き sqlite (memories_1 / goals_1 等) も列挙不要で自動共有される。
17
+ * - 共有は既定 (プライマリ) `~/.codex` への **symlink**。SQLite はシンボリックリンクを
18
+ * たどって実体パスに -wal/-shm を作るため、単一アクティブ運用 (切替時に全 Codex
19
+ * セッションを停止してから起動) では破損しない。
20
+ *
21
+ * 設計方針 (Claude 版と同じ):
22
+ * - 既定プロファイルは常に `~/.codex` (CODEX_DIR override 可)。リネーム・移動しない。
23
+ * - 2 つ目以降だけ `~/.codex-1` `~/.codex-2` … が生える (中立な連番)。
24
+ * - ディレクトリ名にプラン種別を埋め込まない。UI 表示は label で行う。
25
+ * - レジストリは `~/.hub/codex-profiles.json`。
26
+ */
27
+ import { promises as fs } from "node:fs"
28
+ import path from "node:path"
29
+ import os from "node:os"
30
+
31
+ import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
32
+
33
+ /** 既定 (プライマリ) プロファイルの ID。 */
34
+ export const DEFAULT_CODEX_PROFILE_ID = "default"
35
+
36
+ /** `~/.codex` の絶対パス (CODEX_DIR override は hub-bundle.mjs / skills.mjs と揃える)。 */
37
+ export function defaultCodexDir() {
38
+ return process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
39
+ }
40
+
41
+ /**
42
+ * 新規 Codex プロファイル用ディレクトリへ共有しない (= アカウントごとに独立させる) 資産。
43
+ * これ以外はすべて既定 `~/.codex` への symlink として共有する。
44
+ *
45
+ * - 認証: auth.json (これがアカウントの実体。分離の主目的)
46
+ * - 端末/インストール固有: installation_id
47
+ * - 純粋なランタイム/キャッシュ/ログ (振る舞いに影響しない・共有すると破損/肥大の恐れ):
48
+ * cache, .tmp, tmp, shell_snapshots, app-server-control, models_cache.json,
49
+ * version.json, .personality_migration
50
+ */
51
+ const CODEX_ISOLATED_NAMES = new Set([
52
+ "auth.json",
53
+ "installation_id",
54
+ "cache",
55
+ ".tmp",
56
+ "tmp",
57
+ "shell_snapshots",
58
+ "app-server-control",
59
+ "models_cache.json",
60
+ "version.json",
61
+ ".personality_migration",
62
+ ])
63
+
64
+ /**
65
+ * 分離するファイル名パターン (ランタイムログ/セッション状態の sqlite)。
66
+ * 記憶 (memories*.sqlite) / 目標 (goals*.sqlite) は **共有**するため除外する。
67
+ * - logs*.sqlite(-wal/-shm): 実行ログ。アカウント混在を避け分離。
68
+ * - state*.sqlite(-wal/-shm): ランタイム状態。分離。
69
+ */
70
+ function isIsolatedByPattern(name) {
71
+ return /^logs.*\.sqlite(-wal|-shm)?$/.test(name) || /^state.*\.sqlite(-wal|-shm)?$/.test(name)
72
+ }
73
+
74
+ /** その名前を新規プロファイルへ共有する (symlink する) か。 */
75
+ export function isSharedCodexEntry(name) {
76
+ if (CODEX_ISOLATED_NAMES.has(name)) return false
77
+ if (isIsolatedByPattern(name)) return false
78
+ return true
79
+ }
80
+
81
+ /** 既定プロファイルを合成して返す (永続化はしない)。 */
82
+ export function makeDefaultCodexProfile() {
83
+ return {
84
+ id: DEFAULT_CODEX_PROFILE_ID,
85
+ label: "メイン",
86
+ configDir: defaultCodexDir(),
87
+ }
88
+ }
89
+
90
+ /** `~/.hub/codex-profiles.json` の絶対パス。 */
91
+ export function registryPath() {
92
+ return path.join(hubPaths.configDir, "codex-profiles.json")
93
+ }
94
+
95
+ let _cache = null
96
+
97
+ /** チルダ展開。 */
98
+ function resolveDir(dir) {
99
+ if (!dir) return defaultCodexDir()
100
+ if (dir === "~") return os.homedir()
101
+ if (dir.startsWith("~/")) return path.join(os.homedir(), dir.slice(2))
102
+ return path.resolve(dir)
103
+ }
104
+
105
+ /**
106
+ * レジストリを読み込む。存在しなければ既定プロファイルのみの仮想レジストリを返す。
107
+ * @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
108
+ */
109
+ export async function loadRegistry() {
110
+ if (_cache) return _cache
111
+ let parsed = null
112
+ try {
113
+ const raw = await fs.readFile(registryPath(), "utf-8")
114
+ parsed = JSON.parse(raw)
115
+ } catch (err) {
116
+ if (err.code !== "ENOENT") throw err
117
+ }
118
+
119
+ const def = makeDefaultCodexProfile()
120
+ const profiles = [def]
121
+ let activeId = DEFAULT_CODEX_PROFILE_ID
122
+
123
+ if (parsed && Array.isArray(parsed.profiles)) {
124
+ const seen = new Set([DEFAULT_CODEX_PROFILE_ID])
125
+ for (const p of parsed.profiles) {
126
+ if (!p || typeof p.id !== "string" || p.id === DEFAULT_CODEX_PROFILE_ID) continue
127
+ if (seen.has(p.id)) continue
128
+ seen.add(p.id)
129
+ profiles.push({
130
+ id: p.id,
131
+ label: typeof p.label === "string" && p.label ? p.label : p.id,
132
+ configDir: resolveDir(p.configDir),
133
+ })
134
+ }
135
+ if (typeof parsed.defaultLabel === "string" && parsed.defaultLabel) {
136
+ def.label = parsed.defaultLabel
137
+ }
138
+ if (
139
+ typeof parsed.activeId === "string" &&
140
+ profiles.some((p) => p.id === parsed.activeId)
141
+ ) {
142
+ activeId = parsed.activeId
143
+ }
144
+ }
145
+
146
+ _cache = { activeId, profiles }
147
+ return _cache
148
+ }
149
+
150
+ /** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
151
+ async function saveRegistry(reg) {
152
+ await ensureConfigDir()
153
+ const def = reg.profiles.find((p) => p.id === DEFAULT_CODEX_PROFILE_ID)
154
+ const payload = {
155
+ activeId: reg.activeId,
156
+ defaultLabel: def ? def.label : undefined,
157
+ profiles: reg.profiles
158
+ .filter((p) => p.id !== DEFAULT_CODEX_PROFILE_ID)
159
+ .map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
160
+ }
161
+ await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
162
+ mode: 0o600,
163
+ })
164
+ _cache = null
165
+ }
166
+
167
+ /** プロファイル一覧と active を返す (UI 表示用)。 */
168
+ export async function listProfiles() {
169
+ const reg = await loadRegistry()
170
+ return { activeId: reg.activeId, profiles: reg.profiles }
171
+ }
172
+
173
+ /** active プロファイルオブジェクトを返す。 */
174
+ export async function getActiveProfile() {
175
+ const reg = await loadRegistry()
176
+ return reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultCodexProfile()
177
+ }
178
+
179
+ /** active の Codex ホーム絶対パス。 */
180
+ export async function getActiveConfigDir() {
181
+ return (await getActiveProfile()).configDir
182
+ }
183
+
184
+ /**
185
+ * active プロファイルを切り替える。存在しない id はエラー。
186
+ * env (CODEX_HOME) の反映と全 Codex セッション再起動は呼び出し側 (main.mjs) の責務。
187
+ */
188
+ export async function setActiveProfile(id) {
189
+ const reg = await loadRegistry()
190
+ const target = reg.profiles.find((p) => p.id === id)
191
+ if (!target) throw new Error(`unknown codex profile: ${id}`)
192
+ reg.activeId = id
193
+ await saveRegistry(reg)
194
+ return target
195
+ }
196
+
197
+ /**
198
+ * プロファイルの表示ラベルを変更する。id・configDir・認証には一切触れない。
199
+ * 既定プロファイルのラベルも変更可 (defaultLabel として永続化)。
200
+ */
201
+ export async function renameProfile(id, label) {
202
+ const trimmed = typeof label === "string" ? label.trim() : ""
203
+ if (!trimmed) throw new Error("label must be a non-empty string")
204
+ const reg = await loadRegistry()
205
+ const target = reg.profiles.find((p) => p.id === id)
206
+ if (!target) throw new Error(`unknown codex profile: ${id}`)
207
+ target.label = trimmed
208
+ await saveRegistry(reg)
209
+ return { id: target.id, label: target.label, configDir: target.configDir }
210
+ }
211
+
212
+ /** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
213
+ function nextNumericId(profiles) {
214
+ let max = 0
215
+ for (const p of profiles) {
216
+ const n = Number.parseInt(p.id, 10)
217
+ if (Number.isInteger(n) && n > max) max = n
218
+ }
219
+ return String(max + 1)
220
+ }
221
+
222
+ /**
223
+ * プロファイルを新規追加する (連番 id・中立パス `~/.codex-<n>`)。
224
+ * 共有資産の provisioning は provisionProfile が行う。追加直後はログイン未了
225
+ * (ユーザーが `CODEX_HOME=<dir> codex login` する必要がある)。
226
+ *
227
+ * @param {{label?: string}} [opts]
228
+ * @returns {Promise<{id:string,label:string,configDir:string, needsLogin: boolean}>}
229
+ */
230
+ export async function addProfile(opts = {}) {
231
+ const reg = await loadRegistry()
232
+ const id = nextNumericId(reg.profiles)
233
+ const configDir = path.join(os.homedir(), `.codex-${id}`)
234
+ const profile = {
235
+ id,
236
+ label:
237
+ opts.label && String(opts.label).trim()
238
+ ? String(opts.label).trim()
239
+ : `アカウント${id}`,
240
+ configDir,
241
+ }
242
+ reg.profiles.push(profile)
243
+ await saveRegistry(reg)
244
+ await provisionProfile(configDir)
245
+ return { ...profile, needsLogin: true }
246
+ }
247
+
248
+ /**
249
+ * Codex は auth.json (ファイル) の有無でログイン済みかを判定できる。
250
+ * @param {string} configDir
251
+ * @returns {Promise<boolean>}
252
+ */
253
+ export async function hasCodexCredentials(configDir) {
254
+ try {
255
+ await fs.access(path.join(configDir, "auth.json"))
256
+ return true
257
+ } catch {
258
+ return false
259
+ }
260
+ }
261
+
262
+ /**
263
+ * 新規 Codex プロファイルディレクトリを整備する。
264
+ *
265
+ * 「共有デフォルト・例外分離」: 既定 `~/.codex` の各エントリのうち、ISOLATED でない
266
+ * ものすべてを新ディレクトリへ symlink する (設定・hooks・skills・scripts・plugins・
267
+ * memories・sessions・history 等 = 振る舞いを共有)。ISOLATED (auth.json / ランタイム
268
+ * ログ・キャッシュ) は張らず、Codex 側で各アカウント固有に作らせる。
269
+ *
270
+ * 既存エントリ (symlink/実体) には触らない (冪等)。
271
+ *
272
+ * @param {string} configDir - 対象プロファイルの Codex ホーム絶対パス
273
+ * @returns {Promise<{linked: string[], skipped: string[]}>}
274
+ */
275
+ export async function provisionProfile(configDir) {
276
+ const base = defaultCodexDir()
277
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
278
+ const linked = []
279
+ const skipped = []
280
+
281
+ let entries = []
282
+ try {
283
+ entries = await fs.readdir(base)
284
+ } catch {
285
+ // 既定 ~/.codex がまだ無い (Codex 未初期化)。共有元が無いので何もしない。
286
+ return { linked, skipped: ["<base-missing>"] }
287
+ }
288
+
289
+ for (const name of entries) {
290
+ if (!isSharedCodexEntry(name)) {
291
+ skipped.push(`${name}(分離)`)
292
+ continue
293
+ }
294
+ const src = path.join(base, name)
295
+ const dst = path.join(configDir, name)
296
+ // dst が既に存在 (symlink/実体) なら触らない。
297
+ let exists = false
298
+ try {
299
+ await fs.lstat(dst)
300
+ exists = true
301
+ } catch {
302
+ /* not exists */
303
+ }
304
+ if (exists) {
305
+ skipped.push(`${name}(既存)`)
306
+ continue
307
+ }
308
+ await fs.symlink(src, dst)
309
+ linked.push(name)
310
+ }
311
+
312
+ return { linked, skipped }
313
+ }
314
+
315
+ /** テスト用: モジュールキャッシュを破棄する。 */
316
+ export function _clearCache() {
317
+ _cache = null
318
+ }
package/src/main.mjs CHANGED
@@ -58,6 +58,9 @@ import {
58
58
  addProfile,
59
59
  renameProfile,
60
60
  } from "./profiles.mjs"
61
+ // Codex アカウント・プロファイル (独立切替)。profiles.mjs と同名 export が多いため
62
+ // 名前衝突を避けて namespace import する。
63
+ import * as codexProfiles from "./codex-profiles.mjs"
61
64
  import {
62
65
  isKeychainPlatform,
63
66
  profileHasKeychainCredentials,
@@ -114,6 +117,7 @@ import {
114
117
  recordChatActivity,
115
118
  recordChatStreamTick,
116
119
  } from "./chat-signals.mjs"
120
+ import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
117
121
  import {
118
122
  ReadinessTracker,
119
123
  busyChildCount,
@@ -360,6 +364,45 @@ export async function syncTmuxProfileEnv(profile, log) {
360
364
  )
361
365
  }
362
366
 
367
+ /**
368
+ * active Codex プロファイルの configDir を process.env.CODEX_HOME へ反映する。
369
+ * 既定 (`~/.codex`) のときは変数を消して Codex 既定パスに戻す。codex app-server は
370
+ * この env を継承して起動するため、以後の Codex セッションはこのアカウントで動く。
371
+ * Claude 版 applyActiveProfileEnv の Codex 対応 (独立切替)。
372
+ */
373
+ export function applyActiveCodexEnv(profile, log) {
374
+ const isDefault =
375
+ !profile ||
376
+ profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
377
+ profile.configDir === codexProfiles.defaultCodexDir()
378
+ if (isDefault) {
379
+ delete process.env.CODEX_HOME
380
+ } else {
381
+ process.env.CODEX_HOME = profile.configDir
382
+ }
383
+ log?.info?.(
384
+ {
385
+ profile_id: profile?.id || codexProfiles.DEFAULT_CODEX_PROFILE_ID,
386
+ codex_home: process.env.CODEX_HOME || codexProfiles.defaultCodexDir(),
387
+ },
388
+ "active codex profile applied",
389
+ )
390
+ }
391
+
392
+ /**
393
+ * active Codex プロファイルの CODEX_HOME を tmux サーバーのグローバル環境へ同期する。
394
+ * tmux pane で直接 `codex` を打つケースや respawn の保険。tmux 未起動でも失敗しない。
395
+ */
396
+ export async function syncTmuxCodexEnv(profile, log) {
397
+ const isDefault =
398
+ !profile ||
399
+ profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
400
+ profile.configDir === codexProfiles.defaultCodexDir()
401
+ await setTmuxGlobalEnv("CODEX_HOME", isDefault ? null : profile.configDir, {
402
+ logger: log,
403
+ })
404
+ }
405
+
363
406
  /**
364
407
  * `setup_hub_ai.py --silent` を best-effort でキックし、全プロファイルの settings.json へ
365
408
  * bundle hooks (cockpit_permission_bridge 等) を反映させる。
@@ -440,6 +483,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
440
483
  )
441
484
  }
442
485
 
486
+ // 同様に active Codex プロファイルの CODEX_HOME を反映する (独立切替)。
487
+ try {
488
+ const activeCodex = await codexProfiles.getActiveProfile()
489
+ applyActiveCodexEnv(activeCodex, logger)
490
+ await syncTmuxCodexEnv(activeCodex, logger)
491
+ } catch (err) {
492
+ logger.warn(
493
+ { err: err.message },
494
+ "failed to apply active codex profile; falling back to default ~/.codex",
495
+ )
496
+ }
497
+
443
498
  // launchd / systemd 起動では .zshrc 等の対話シェル設定が読まれず、~/.claude.json の
444
499
  // MCP 定義が参照する環境変数 (例: Authorization: Bearer ${WRI_MCP_TOKEN}) が解決
445
500
  // できない。ユーザーのログインシェル env + dotenv 層を 1 回解決して process.env を
@@ -544,14 +599,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
544
599
  if (event?.type === "assistant") {
545
600
  const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
546
601
  try {
547
- recordChatActivity(cwd, {
602
+ // cliKind は chat-signals のキーの一部 (T05945)。同一 cwd で走る codex の信号と
603
+ // 混ざらないよう、claude 由来のイベントは必ず "claude" バケットへ書く。
604
+ // sessionId は同一 CLI 内でのセッション乗り換え検知用 identity (S1)。
605
+ recordChatActivity(cwd, "claude", {
548
606
  status: "processing",
549
607
  contextPct: pct,
550
608
  inputPending: false,
551
- // identity (S1): この cwd の chat 信号が「今どの CLI・どのセッションのものか」を
552
- // 明示する。claude.attach で確定させた identity と一致すれば温存、別セッション/
553
- // エンジンに切り替わっていれば chat-signals.mjs 側が自動でリセットする。
554
- cliKind: "claude",
555
609
  sessionId: session_id || null,
556
610
  })
557
611
  } catch {
@@ -559,10 +613,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
559
613
  }
560
614
  } else if (event?.type === "result") {
561
615
  try {
562
- recordChatActivity(cwd, {
616
+ recordChatActivity(cwd, "claude", {
563
617
  status: "waiting",
564
618
  inputPending: false,
565
- cliKind: "claude",
566
619
  sessionId: session_id || null,
567
620
  })
568
621
  } catch {
@@ -575,7 +628,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
575
628
  // 純 SDK チャットで三点リーダーが消える。stream_event 受信を throttle 付き processing
576
629
  // tick として記録し statusAt を前進させる (本文は読まずタイムスタンプ前進のみ)。
577
630
  try {
578
- recordChatStreamTick(cwd)
631
+ recordChatStreamTick(cwd, "claude")
579
632
  } catch {
580
633
  /* ignore */
581
634
  }
@@ -583,12 +636,14 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
583
636
  client.send({ type: "claude.event", stream_id, session_id, event })
584
637
  })
585
638
  claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
586
- // 確認待ち信号 (2026-06-02): cwd キーで inputPending を立て、サイドバーの
639
+ // 確認待ち信号 (2026-06-02): (cwd, cliKind) キーで inputPending を立て、サイドバーの
587
640
  // 「確認待ち」ドット & 通知の発火源にする。次の assistant/result で落ちる。
588
641
  // これにより「裏で動いているセッションがどれか分からない」発見性の問題を解消する。
642
+ // T05945: 旧実装は cliKind を伝播しておらず、codex の承認カードでも claude 行の
643
+ // 確認待ちドットが点く (逆も然り) 交差汚染があった。CLI を明示して分離する。
589
644
  if (cwd) {
590
645
  try {
591
- recordChatActivity(cwd, { inputPending: true })
646
+ recordChatActivity(cwd, "claude", { inputPending: true })
592
647
  } catch {
593
648
  /* ignore */
594
649
  }
@@ -613,7 +668,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
613
668
  // (正常完了は上の result ハンドラが既に waiting にしている)。
614
669
  if (cwd) {
615
670
  try {
616
- recordChatActivity(cwd, { status: "waiting", inputPending: false })
671
+ recordChatActivity(cwd, "claude", { status: "waiting", inputPending: false })
617
672
  } catch {
618
673
  /* ignore */
619
674
  }
@@ -645,11 +700,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
645
700
  /* ignore */
646
701
  }
647
702
  try {
648
- recordChatActivity(cwd, {
703
+ recordChatActivity(cwd, "codex", {
649
704
  status: "processing",
650
705
  contextPct,
651
706
  inputPending: false,
652
- cliKind: "codex",
653
707
  sessionId: thread_id || null,
654
708
  })
655
709
  } catch {
@@ -666,11 +720,10 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
666
720
  /* ignore */
667
721
  }
668
722
  try {
669
- recordChatActivity(cwd, {
723
+ recordChatActivity(cwd, "codex", {
670
724
  status: "waiting",
671
725
  contextPct,
672
726
  inputPending: false,
673
- cliKind: "codex",
674
727
  sessionId: thread_id || null,
675
728
  })
676
729
  } catch {
@@ -683,7 +736,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
683
736
  codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
684
737
  if (cwd) {
685
738
  try {
686
- recordChatActivity(cwd, { inputPending: true })
739
+ // T05945: codex の承認待ちは codex 行にだけ立てる (claude 行へ漏らさない)。
740
+ recordChatActivity(cwd, "codex", { inputPending: true })
687
741
  } catch {
688
742
  /* ignore */
689
743
  }
@@ -855,7 +909,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
855
909
  async ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
856
910
  if (cwd) {
857
911
  try {
858
- recordChatActivity(cwd, { inputPending: true })
912
+ // TUI 権限ブリッジ (PreToolUse フック) は Claude Code TUI 専用の経路なので
913
+ // 常に claude バケットへ記録する (T05945)。
914
+ recordChatActivity(cwd, "claude", { inputPending: true })
859
915
  } catch {
860
916
  /* ignore */
861
917
  }
@@ -1470,8 +1526,13 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
1470
1526
  *
1471
1527
  * cwd → session_name の解決: claude.attach / codex.attach は tmux の session_name を知らない
1472
1528
  * (browser は cwd/stream_id しか送らない) ため、listSessionStates で全 tmux session を走査し
1473
- * cwd が一致するものへ push する (通常は 1 cwd = 1 tmux session)。tmux 自体が無い / 一致が
1474
- * 無ければ何もしない (安全側、次の 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 する。
1475
1536
  *
1476
1537
  * `listSessionStatesFn` は単体テスト用の DI ポイント (既定は実 `listSessionStates`、tmux を
1477
1538
  * 起動する)。実運用では省略してよい。
@@ -1493,6 +1554,7 @@ export async function pushImmediateChatState({
1493
1554
  const states = await listSessionStatesFn({ plugins, logger })
1494
1555
  for (const s of states) {
1495
1556
  if (s.cwd !== cwd) continue
1557
+ if (cliKindForSessionName(s.session_name) !== cliKind) continue
1496
1558
  client.send({
1497
1559
  type: "session.state",
1498
1560
  session_name: s.session_name,
@@ -1546,18 +1608,35 @@ export function resolveStateLoopBoundSessionId(boundSessions, sessionName, cwd,
1546
1608
  * 1. chat 信号が processing → "chat-active" (true)
1547
1609
  * 2. chat 信号が waiting/idle → "chat-inactive" (false)
1548
1610
  * 3. fresh unbound bind (adopt 前の bind プレースホルダ) → "fresh-unbound" (null=不明、安全側)
1549
- * 4. cwd に一致するライブ codexBridge セッションがある → "codex" (rollout ベース判定)
1550
- * 5. cwd があれば → "claude" (jsonl ベース判定、TUI/bound session フォールバック)
1611
+ * 4. **その行が codex セッション** なら → codexThreadId があれば "codex" (rollout ベース判定)、
1612
+ * 無ければ "none" (null=不明)
1613
+ * 5. **その行が claude セッション** で cwd があれば → "claude" (jsonl ベース判定)
1551
1614
  * 6. cwd も無ければ → "none" (null=不明)
1552
1615
  *
1553
- * @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
1554
1627
  * @returns {'chat-active'|'chat-inactive'|'fresh-unbound'|'codex'|'claude'|'none'}
1555
1628
  */
1556
- export function decideTurnActiveSource({ chatStatus, cwd, isFreshUnbound, codexThreadId }) {
1629
+ export function decideTurnActiveSource({
1630
+ chatStatus,
1631
+ cwd,
1632
+ isFreshUnbound,
1633
+ codexThreadId,
1634
+ sessionCliKind = "claude",
1635
+ }) {
1557
1636
  if (chatStatus === "processing") return "chat-active"
1558
1637
  if (chatStatus === "waiting" || chatStatus === "idle") return "chat-inactive"
1559
1638
  if (cwd && isFreshUnbound) return "fresh-unbound"
1560
- if (codexThreadId) return "codex"
1639
+ if (sessionCliKind === "codex") return codexThreadId ? "codex" : "none"
1561
1640
  if (cwd) return "claude"
1562
1641
  return "none"
1563
1642
  }
@@ -1598,6 +1677,10 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1598
1677
  await refreshContextWindow()
1599
1678
  const states = await listSessionStates({ plugins, logger })
1600
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)
1601
1684
  // 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
1602
1685
  // tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
1603
1686
  const permissionMode = s.permission_mode ?? null
@@ -1612,7 +1695,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1612
1695
  // 返せないため、SDK チャットの補完は従来どおり効く。TUI 生成中に stale な SDK
1613
1696
  // status (waiting) が勝ってステータスドット/三点リーダーが消える事故の防止
1614
1697
  // (2026-06-12) は paneIsProcessing ガードのまま維持される。
1615
- 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
1616
1701
  const overlay = applyChatOverlay(s.status, s.context_pct, chat)
1617
1702
  let status = overlay.status
1618
1703
  let contextPct = overlay.contextPct
@@ -1701,12 +1786,17 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1701
1786
  // null=不明のまま、退行はしない)。
1702
1787
  // 分岐の優先順位は decideTurnActiveSource (純関数、単体テスト済み) が権威。
1703
1788
  let turnActive = null
1704
- 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
1705
1794
  const turnActiveSource = decideTurnActiveSource({
1706
1795
  chatStatus: chat?.status,
1707
1796
  cwd: s.cwd,
1708
1797
  isFreshUnbound: isFreshUnboundBind(boundSessions?.get(s.session_name)),
1709
1798
  codexThreadId,
1799
+ sessionCliKind,
1710
1800
  })
1711
1801
  switch (turnActiveSource) {
1712
1802
  case "chat-active":
@@ -1736,11 +1826,15 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1736
1826
  turnActive = null
1737
1827
  }
1738
1828
 
1739
- // S1 (2026-07): チャット信号 (getChatSignal) が identity (cliKind/sessionId) を
1740
- // 持っていれば、それをそのまま session.state に同梱する。frontend はこれで「今
1741
- // 表示中のエンジン/セッションと一致する push か」を判定し、不一致の値を適用しない
1742
- // (前セッションの値を上書きし続ける症状③④の frontend 側の受け口)。
1743
- 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
1744
1838
  const chatSessionId = chat?.sessionId ?? null
1745
1839
 
1746
1840
  const prev = lastByName.get(s.session_name)
@@ -1806,9 +1900,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1806
1900
  // turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
1807
1901
  // (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
1808
1902
  turn_active: turnActive,
1809
- // cli_kind / chat_session_id (S1, 2026-07): このセッションの現在の chat 信号が
1810
- // どの CLI・どのセッションのものか。チャット信号が無い (TUI 専用/未対応) 行では
1811
- // null (旧クライアント互換、frontend は無条件マージにフォールバック)。
1903
+ // cli_kind (T05945): セッション名由来の構造値 ('claude' | 'codex')。frontend の
1904
+ // 単一セレクタ (useCockpitSessionDisplay) が「この push はどの CLI 行のものか」を
1905
+ // 判定する唯一の軸。chat_session_id はチャット信号が無ければ null。
1812
1906
  cli_kind: chatCliKind,
1813
1907
  chat_session_id: chatSessionId,
1814
1908
  })
@@ -2608,12 +2702,11 @@ async function dispatch(msg, ctx) {
2608
2702
  const resolvedCwd = cwdArg || process.env.HOME || process.cwd()
2609
2703
  if (resolvedCwd) {
2610
2704
  if (!resumeSessionId) {
2611
- clearChatSignal(resolvedCwd)
2612
- recordChatActivity(resolvedCwd, {
2705
+ clearChatSignal(resolvedCwd, "claude")
2706
+ recordChatActivity(resolvedCwd, "claude", {
2613
2707
  status: "waiting",
2614
2708
  contextPct: 0,
2615
2709
  inputPending: false,
2616
- cliKind: "claude",
2617
2710
  sessionId: null,
2618
2711
  })
2619
2712
  pushImmediateChatState({
@@ -2631,11 +2724,10 @@ async function dispatch(msg, ctx) {
2631
2724
  .then((jsonlCtx) => {
2632
2725
  const contextPct =
2633
2726
  typeof jsonlCtx?.percent === "number" ? jsonlCtx.percent : 0
2634
- recordChatActivity(resolvedCwd, {
2727
+ recordChatActivity(resolvedCwd, "claude", {
2635
2728
  status: "waiting",
2636
2729
  contextPct,
2637
2730
  inputPending: false,
2638
- cliKind: "claude",
2639
2731
  sessionId: resumeSessionId,
2640
2732
  })
2641
2733
  return pushImmediateChatState({
@@ -3251,7 +3343,9 @@ async function dispatch(msg, ctx) {
3251
3343
  // status/context% 補完) は役目を終える。残すと TUI 権限カードの
3252
3344
  // inputPending 記録が stale status の鮮度を延命し、state loop が
3253
3345
  // ペイン実状態を waiting で上書きし続ける (2026-06-12 真因修正)。
3254
- if (cwd) clearChatSignal(cwd)
3346
+ // T05945: claude バケットのみ消す。同一 cwd で走っている codex チャットの
3347
+ // 信号まで消すと、codex 行のドット/ドーナツが巻き添えで消灯する。
3348
+ if (cwd) clearChatSignal(cwd, "claude")
3255
3349
  // 3) tmux claude を起動し直す (resume or fresh)。
3256
3350
  // 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
3257
3351
  // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
@@ -3640,12 +3734,11 @@ async function dispatch(msg, ctx) {
3640
3734
  // - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
3641
3735
  if (resolvedCwd) {
3642
3736
  if (!resumeThreadId) {
3643
- clearChatSignal(resolvedCwd)
3644
- recordChatActivity(resolvedCwd, {
3737
+ clearChatSignal(resolvedCwd, "codex")
3738
+ recordChatActivity(resolvedCwd, "codex", {
3645
3739
  status: "waiting",
3646
3740
  contextPct: 0,
3647
3741
  inputPending: false,
3648
- cliKind: "codex",
3649
3742
  sessionId: info.threadId || null,
3650
3743
  })
3651
3744
  pushImmediateChatState({
@@ -3662,11 +3755,10 @@ async function dispatch(msg, ctx) {
3662
3755
  .catch(() => null)
3663
3756
  .then((usage) => {
3664
3757
  const contextPct = typeof usage?.context === "number" ? usage.context : 0
3665
- recordChatActivity(resolvedCwd, {
3758
+ recordChatActivity(resolvedCwd, "codex", {
3666
3759
  status: "waiting",
3667
3760
  contextPct,
3668
3761
  inputPending: false,
3669
- cliKind: "codex",
3670
3762
  sessionId: info.threadId,
3671
3763
  })
3672
3764
  return pushImmediateChatState({
@@ -4018,6 +4110,129 @@ async function dispatch(msg, ctx) {
4018
4110
  }
4019
4111
  return
4020
4112
  }
4113
+ case "codex.profile.list": {
4114
+ // Codex アカウント・プロファイル一覧と active を返す (Cockpit の Codex 切替UI用)。
4115
+ // 認証はファイル (<CODEX_HOME>/auth.json) の有無で判定 (Keychain 不要)。
4116
+ const reg = await codexProfiles.listProfiles()
4117
+ const profiles = await Promise.all(
4118
+ reg.profiles.map(async (p) => ({
4119
+ id: p.id,
4120
+ label: p.label,
4121
+ config_dir: p.configDir,
4122
+ has_credentials: await codexProfiles.hasCodexCredentials(p.configDir),
4123
+ })),
4124
+ )
4125
+ ctx.client.send({
4126
+ type: "codex.profile.list.response",
4127
+ request_id: msg.request_id,
4128
+ active_id: reg.activeId,
4129
+ profiles,
4130
+ })
4131
+ return
4132
+ }
4133
+ case "codex.profile.switch": {
4134
+ // Codex アカウント切替。active 永続化 → CODEX_HOME を env/tmux に反映 → Codex
4135
+ // app-server セッションのみ再起動 (Claude 側は独立なので触らない)。認証はファイル
4136
+ // 分離のため CODEX_HOME 切替だけでアカウントが変わる (スワップ不要)。
4137
+ const id = msg.profile_id || msg.id || ""
4138
+ try {
4139
+ const target = await codexProfiles.setActiveProfile(id)
4140
+ applyActiveCodexEnv(target, ctx.logger)
4141
+ await syncTmuxCodexEnv(target, ctx.logger)
4142
+ // Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
4143
+ const before = ctx.codexBridge?.sessions?.size ?? 0
4144
+ ctx.codexBridge?.shutdown()
4145
+ const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
4146
+ const needsLogin = !hasCreds
4147
+ const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
4148
+ ctx.logger.info(
4149
+ { profile_id: target.id, reaped: before, needs_login: needsLogin },
4150
+ "codex profile switched; codex sessions reaped",
4151
+ )
4152
+ ctx.client.send({
4153
+ type: "codex.profile.switch.result",
4154
+ request_id: msg.request_id,
4155
+ ok: true,
4156
+ active_id: target.id,
4157
+ profile: {
4158
+ id: target.id,
4159
+ label: target.label,
4160
+ config_dir: target.configDir,
4161
+ },
4162
+ reaped: before,
4163
+ needs_login: needsLogin,
4164
+ login_command: needsLogin
4165
+ ? isDefaultTarget
4166
+ ? "codex login"
4167
+ : `CODEX_HOME=${target.configDir} codex login`
4168
+ : undefined,
4169
+ })
4170
+ } catch (err) {
4171
+ ctx.client.send({
4172
+ type: "codex.profile.switch.result",
4173
+ request_id: msg.request_id,
4174
+ ok: false,
4175
+ profile_id: id,
4176
+ error: err?.message || String(err),
4177
+ })
4178
+ }
4179
+ return
4180
+ }
4181
+ case "codex.profile.add": {
4182
+ // 新規 Codex プロファイル追加 (連番 id・中立パス `~/.codex-<n>`)。共有資産を
4183
+ // provisioning 済み。追加直後はログイン未了 (CODEX_HOME=<dir> codex login)。
4184
+ try {
4185
+ const created = await codexProfiles.addProfile({ label: msg.label })
4186
+ ctx.client.send({
4187
+ type: "codex.profile.add.result",
4188
+ request_id: msg.request_id,
4189
+ ok: true,
4190
+ profile: {
4191
+ id: created.id,
4192
+ label: created.label,
4193
+ config_dir: created.configDir,
4194
+ },
4195
+ needs_login: created.needsLogin,
4196
+ login_command: `CODEX_HOME=${created.configDir} codex login`,
4197
+ })
4198
+ // 新プロファイルへ bundle hooks / AGENTS.md を反映 (codex 向け setup)。
4199
+ kickSetupHubAi({ logger: ctx.logger, cliKind: "codex" })
4200
+ } catch (err) {
4201
+ ctx.client.send({
4202
+ type: "codex.profile.add.result",
4203
+ request_id: msg.request_id,
4204
+ ok: false,
4205
+ error: err?.message || String(err),
4206
+ })
4207
+ }
4208
+ return
4209
+ }
4210
+ case "codex.profile.rename": {
4211
+ // Codex プロファイルの表示ラベルのみ変更 (切替・認証に触れない)。
4212
+ const id = msg.profile_id || msg.id || ""
4213
+ try {
4214
+ const updated = await codexProfiles.renameProfile(id, msg.label)
4215
+ ctx.client.send({
4216
+ type: "codex.profile.rename.result",
4217
+ request_id: msg.request_id,
4218
+ ok: true,
4219
+ profile: {
4220
+ id: updated.id,
4221
+ label: updated.label,
4222
+ config_dir: updated.configDir,
4223
+ },
4224
+ })
4225
+ } catch (err) {
4226
+ ctx.client.send({
4227
+ type: "codex.profile.rename.result",
4228
+ request_id: msg.request_id,
4229
+ ok: false,
4230
+ profile_id: id,
4231
+ error: err?.message || String(err),
4232
+ })
4233
+ }
4234
+ return
4235
+ }
4021
4236
  case "tmux.exec": {
4022
4237
  const args = Array.isArray(msg.args) ? msg.args : []
4023
4238
  const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
@@ -4185,10 +4400,14 @@ async function dispatch(msg, ctx) {
4185
4400
  try {
4186
4401
  const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
4187
4402
  // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
4188
- // 解釈し、codex なら codex を pane 起動する(既定 claude)。worktree は別 cwd=
4189
- // 別セッション名なので、メイン=claude / worktree=codex の並走が成立する。
4190
- await createTmuxSession(wtName, wtPath, {
4191
- 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,
4192
4411
  claudeCmd:
4193
4412
  typeof msg.claude_cmd === "string"
4194
4413
  ? msg.claude_cmd
@@ -4214,7 +4433,11 @@ async function dispatch(msg, ctx) {
4214
4433
  type: "worktree.create.result",
4215
4434
  request_id: msg.request_id,
4216
4435
  ok: true,
4436
+ // wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
4217
4437
  wt_name: wtName,
4438
+ // session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
4439
+ // 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
4440
+ session_name: wtSessionName,
4218
4441
  wt_path: wtPath,
4219
4442
  branch,
4220
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
  )