@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 +1 -1
- package/src/chat-signals.mjs +0 -0
- package/src/codex-profiles.mjs +318 -0
- package/src/main.mjs +272 -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1474
|
-
*
|
|
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.
|
|
1550
|
-
*
|
|
1611
|
+
* 4. **その行が codex セッション** なら → codexThreadId があれば "codex" (rollout ベース判定)、
|
|
1612
|
+
* 無ければ "none" (null=不明)
|
|
1613
|
+
* 5. **その行が claude セッション** で cwd があれば → "claude" (jsonl ベース判定)
|
|
1551
1614
|
* 6. cwd も無ければ → "none" (null=不明)
|
|
1552
1615
|
*
|
|
1553
|
-
*
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
// (
|
|
1743
|
-
|
|
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
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
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
|
-
|
|
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)。
|
|
4189
|
-
//
|
|
4190
|
-
|
|
4191
|
-
|
|
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 (
|
|
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
|
)
|