@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 +1 -1
- package/src/chat-signals.mjs +0 -0
- package/src/claude-credentials.mjs +45 -2
- package/src/main.mjs +95 -49
- package/src/session-names.mjs +86 -0
- package/src/tmux.mjs +34 -15
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1528
|
-
*
|
|
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.
|
|
1604
|
-
*
|
|
1611
|
+
* 4. **その行が codex セッション** なら → codexThreadId があれば "codex" (rollout ベース判定)、
|
|
1612
|
+
* 無ければ "none" (null=不明)
|
|
1613
|
+
* 5. **その行が claude セッション** で cwd があれば → "claude" (jsonl ベース判定)
|
|
1605
1614
|
* 6. cwd も無ければ → "none" (null=不明)
|
|
1606
1615
|
*
|
|
1607
|
-
*
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1794
|
-
//
|
|
1795
|
-
//
|
|
1796
|
-
// (
|
|
1797
|
-
|
|
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
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
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
|
-
|
|
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)。
|
|
4366
|
-
//
|
|
4367
|
-
|
|
4368
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
)
|