@cocorograph/hub-agent 0.8.16 → 0.8.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/claude-credentials.mjs +76 -0
- package/src/codex-stream-bridge.mjs +37 -4
- package/src/live-sessions.mjs +61 -0
- package/src/main.mjs +425 -272
- package/src/sibling-inject.mjs +21 -12
- package/src/tmux.mjs +60 -9
package/package.json
CHANGED
|
@@ -26,8 +26,51 @@ import { createHash } from "node:crypto"
|
|
|
26
26
|
import { execFile } from "node:child_process"
|
|
27
27
|
import { promisify } from "node:util"
|
|
28
28
|
|
|
29
|
+
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
30
|
+
import { hasFileCredentials } from "./profiles.mjs"
|
|
31
|
+
|
|
29
32
|
const execFileP = promisify(execFile)
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 認証在否チェックの短期キャッシュ TTL (ms)。
|
|
36
|
+
*
|
|
37
|
+
* ログイン状態は分単位でしか変わらないのに、`usage.request` (Cockpit のウィンドウ数 ×
|
|
38
|
+
* 60 秒周期) が来るたびに `security` コマンドを 1〜3 回 spawn していた。config
|
|
39
|
+
* ディレクトリ単位で 30 秒だけ結果を共有し、同時要求は single-flight に畳む。
|
|
40
|
+
* プロファイル切替直後は呼び出し側が clearCredentialCheckCache() で明示的に捨てる。
|
|
41
|
+
*/
|
|
42
|
+
export const CREDENTIAL_CHECK_TTL_MS = 30_000
|
|
43
|
+
|
|
44
|
+
/** @type {Map<string, AsyncTtlCache>} configDir -> Keychain 在否 */
|
|
45
|
+
const keychainCheckCache = new Map()
|
|
46
|
+
/** @type {Map<string, AsyncTtlCache>} configDir -> .credentials.json 在否 */
|
|
47
|
+
const fileCheckCache = new Map()
|
|
48
|
+
|
|
49
|
+
function cachedCheck(store, configDir, build) {
|
|
50
|
+
const key = String(configDir)
|
|
51
|
+
let cache = store.get(key)
|
|
52
|
+
if (!cache) {
|
|
53
|
+
cache = new AsyncTtlCache({ ttlMs: CREDENTIAL_CHECK_TTL_MS })
|
|
54
|
+
store.set(key, cache)
|
|
55
|
+
}
|
|
56
|
+
return cache.get(build)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 認証在否キャッシュを捨てる (プロファイル切替直後・テストの状態初期化用)。
|
|
61
|
+
* configDir 省略時は全件。
|
|
62
|
+
*/
|
|
63
|
+
export function clearCredentialCheckCache(configDir) {
|
|
64
|
+
if (configDir === undefined) {
|
|
65
|
+
for (const cache of keychainCheckCache.values()) cache.clear()
|
|
66
|
+
for (const cache of fileCheckCache.values()) cache.clear()
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const key = String(configDir)
|
|
70
|
+
keychainCheckCache.get(key)?.clear()
|
|
71
|
+
fileCheckCache.get(key)?.clear()
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
/**
|
|
32
75
|
* 既定 (プライマリ) プロファイルで CLAUDE_CONFIG_DIR が未設定のとき、Claude Code は
|
|
33
76
|
* ハッシュ無しの素のサービス名 `Claude Code-credentials` に OAuth トークンを保存する
|
|
@@ -100,11 +143,30 @@ export async function existsGenericPassword(service, opts = {}) {
|
|
|
100
143
|
* 含める (PLAIN_CREDENTIAL_SERVICE のコメント参照)。含めないと既定プロファイルが常に
|
|
101
144
|
* 空振りして「要ログイン」が出っぱなしになる。
|
|
102
145
|
*
|
|
146
|
+
* ⚠️ キャッシュは **opts 無しの呼び出しだけ**に効かせる。`opts.execFileP` 等で挙動を
|
|
147
|
+
* 差し替える呼び出し (テストの分岐検査) までキャッシュすると、実際の分岐を通らないまま
|
|
148
|
+
* 前回値を返してしまい検査が空振りする。
|
|
149
|
+
*
|
|
103
150
|
* @param {string} configDir
|
|
104
151
|
* @param {{execFileP?: Function}} [opts]
|
|
105
152
|
* @returns {Promise<boolean>}
|
|
106
153
|
*/
|
|
107
154
|
export async function profileHasKeychainCredentials(configDir, opts = {}) {
|
|
155
|
+
if (opts && Object.keys(opts).length > 0) {
|
|
156
|
+
return probeKeychainCredentials(configDir, opts)
|
|
157
|
+
}
|
|
158
|
+
return cachedCheck(keychainCheckCache, configDir, () =>
|
|
159
|
+
probeKeychainCredentials(configDir, {}),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Keychain 在否の実処理 (キャッシュを挟まない)。
|
|
165
|
+
* @param {string} configDir
|
|
166
|
+
* @param {{execFileP?: Function}} opts
|
|
167
|
+
* @returns {Promise<boolean>}
|
|
168
|
+
*/
|
|
169
|
+
async function probeKeychainCredentials(configDir, opts) {
|
|
108
170
|
const candidates = new Set([configDir])
|
|
109
171
|
try {
|
|
110
172
|
candidates.add(await fs.realpath(configDir))
|
|
@@ -139,3 +201,17 @@ export async function profileHasKeychainCredentials(configDir, opts = {}) {
|
|
|
139
201
|
}
|
|
140
202
|
return false
|
|
141
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Keychain の無いプラットフォーム向け在否判定 (profiles.mjs の hasFileCredentials) の
|
|
207
|
+
* キャッシュ版。TTL・クリア導線は Keychain 版と共通 (clearCredentialCheckCache)。
|
|
208
|
+
*
|
|
209
|
+
* ⚠️ 実処理は profiles.mjs 側が正本。ここは呼び出し頻度を落とすためのラッパで、
|
|
210
|
+
* 判定ロジックは持たない。
|
|
211
|
+
*
|
|
212
|
+
* @param {string} configDir
|
|
213
|
+
* @returns {Promise<boolean>}
|
|
214
|
+
*/
|
|
215
|
+
export async function hasFileCredentialsCached(configDir) {
|
|
216
|
+
return cachedCheck(fileCheckCache, configDir, () => hasFileCredentials(configDir))
|
|
217
|
+
}
|
|
@@ -329,6 +329,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
329
329
|
historyMaxItems,
|
|
330
330
|
historyMaxBytes,
|
|
331
331
|
listThreadsCacheTtlMs,
|
|
332
|
+
accountRateLimitsCacheTtlMs,
|
|
332
333
|
} = {}) {
|
|
333
334
|
super()
|
|
334
335
|
this.codexCmd = codexCmd
|
|
@@ -369,6 +370,14 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
369
370
|
// AsyncTtlCache が single-flight と、thread/start 中の世代切替をまとめて担う。
|
|
370
371
|
this._listThreadsCacheTtlMs = listThreadsCacheTtlMs ?? 5_000
|
|
371
372
|
this._listThreadsCache = new Map()
|
|
373
|
+
// アカウント利用制限 (5h/7d) は **アカウント全体の値** で、Cockpit のウィンドウ数だけ
|
|
374
|
+
// 別々に取りに行く必要がない。1 回の取得ごとに `codex app-server` を spawn →
|
|
375
|
+
// initialize → 停止しており、control レーンの実測占有 (2026-08-01: 92 秒中 73.3 秒) の
|
|
376
|
+
// 主因になっていた。profileId 単位で短期キャッシュし、TTL 内は app-server を
|
|
377
|
+
// **一切起動しない**。同時要求は AsyncTtlCache の single-flight で 1 本に畳む。
|
|
378
|
+
this._accountRateLimitsCacheTtlMs = accountRateLimitsCacheTtlMs ?? 45_000
|
|
379
|
+
/** @type {Map<string, AsyncTtlCache>} profileId -> cache */
|
|
380
|
+
this._accountRateLimitsCache = new Map()
|
|
372
381
|
}
|
|
373
382
|
|
|
374
383
|
/**
|
|
@@ -1037,6 +1046,25 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1037
1046
|
* Cockpit フッターを最新化できる。呼び出し側は認証済みプロファイルの場合だけ呼ぶ。
|
|
1038
1047
|
*/
|
|
1039
1048
|
async readAccountRateLimits({ cwd, profileId = "default" } = {}) {
|
|
1049
|
+
let cache = this._accountRateLimitsCache.get(profileId)
|
|
1050
|
+
if (!cache) {
|
|
1051
|
+
cache = new AsyncTtlCache({ ttlMs: this._accountRateLimitsCacheTtlMs })
|
|
1052
|
+
this._accountRateLimitsCache.set(profileId, cache)
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
// ⚠️ 失敗を **キャッシュの外** で受けるのが要点。AsyncTtlCache は build 失敗を
|
|
1056
|
+
// キャッシュしないため、一時的な取得失敗が TTL 分だけ再試行を止めることがない。
|
|
1057
|
+
// 失敗時フォールバック (直近の成功値) は従来どおりここで効かせる。
|
|
1058
|
+
return await cache.get(() => this._fetchAccountRateLimits({ cwd, profileId }))
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
const cached = this._accountRateLimitsByProfile.get(profileId)
|
|
1061
|
+
if (cached) return cached
|
|
1062
|
+
throw err
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** app-server を 1 本 spawn して account/rateLimits/read を取る実処理 (キャッシュ miss 時のみ)。 */
|
|
1067
|
+
async _fetchAccountRateLimits({ cwd, profileId }) {
|
|
1040
1068
|
const client = this._clientFactory({ cwd: cwd || process.cwd(), codexCmd: this.codexCmd, logger: this.logger })
|
|
1041
1069
|
client.start()
|
|
1042
1070
|
try {
|
|
@@ -1045,15 +1073,20 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1045
1073
|
const result = await client.request("account/rateLimits/read", {})
|
|
1046
1074
|
this._accountRateLimitsByProfile.set(profileId, result)
|
|
1047
1075
|
return result
|
|
1048
|
-
} catch (err) {
|
|
1049
|
-
const cached = this._accountRateLimitsByProfile.get(profileId)
|
|
1050
|
-
if (cached) return cached
|
|
1051
|
-
throw err
|
|
1052
1076
|
} finally {
|
|
1053
1077
|
client.stop()
|
|
1054
1078
|
}
|
|
1055
1079
|
}
|
|
1056
1080
|
|
|
1081
|
+
/** プロファイル切替等で「別アカウントの値を見せない」ために使う (省略時は全 profile)。 */
|
|
1082
|
+
clearAccountRateLimitsCache(profileId) {
|
|
1083
|
+
if (profileId === undefined) {
|
|
1084
|
+
for (const cache of this._accountRateLimitsCache.values()) cache.clear()
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
this._accountRateLimitsCache.get(profileId)?.clear()
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1057
1090
|
_wireClient(session) {
|
|
1058
1091
|
const { client } = session
|
|
1059
1092
|
client.on("notification", (msg) => this._onNotification(session, msg))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「そのセッションに依頼を送れるか (warm)」判定の **正本 (SoT)** (2026-08-01)。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景 (WHY)
|
|
5
|
+
* セッション間の依頼 (`cockpit_orch.py send`) は sibling-inject が宛先を解決するが、その
|
|
6
|
+
* 対象は **claudeBridge / codexBridge の `describeLiveSessions()` に載るセッションだけ**で
|
|
7
|
+
* ある (載らない宛先は `sendToStream` / `sendToCwd` が `not_found` を返す)。一方 Cockpit の
|
|
8
|
+
* サイドバーは「tmux のセッション行が存在するか」しか見ていないため、**依頼を送れない
|
|
9
|
+
* セッションが「起動中」と表示される**食い違いがあった。
|
|
10
|
+
*
|
|
11
|
+
* そこで `tmux.list_sessions` 応答に `warm` を載せて表示と配送の判定を揃えるが、その際に
|
|
12
|
+
* **同じ判定を別実装で書き直すと再び食い違う** (グレー表示なのに宛先一覧に出ない、等)。
|
|
13
|
+
* 判定はこのモジュール 1 箇所に集約し、sibling-inject の候補列挙 (op:list / op:list-all /
|
|
14
|
+
* 失敗時の診断) と `warm` の算出が**必ず同じ関数**を通るようにする。
|
|
15
|
+
*
|
|
16
|
+
* ## 不変条件
|
|
17
|
+
* - `isSessionWarm(bridges, row) === true`
|
|
18
|
+
* ⇔ `describeLiveSessionsUnion(bridges, {cwd: row.cwd, cliKind: 行の cli_kind}).length > 0`
|
|
19
|
+
* ⇔ sibling-inject の `op:"list"` が同じ cwd / kind で 1 本以上返す
|
|
20
|
+
* - 行の cli_kind は `session-names.mjs` の命名規約 (`<dir>` = claude /
|
|
21
|
+
* `<dir>__codex` = codex) からのみ導く。tmux / ブリッジの状態は参照しない。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { cliKindForSessionName } from "./session-names.mjs"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* claude / codex ブリッジの live セッションを union で返す (宛先解決の候補集合)。
|
|
28
|
+
*
|
|
29
|
+
* `cwd` を渡すとその cwd のみ、`cliKind` に 'claude' / 'codex' を渡すとその CLI のみへ
|
|
30
|
+
* 絞り込む。どちらも省略すると全 cwd・両 CLI (op:"list-all" 用)。`cliKind` が既知値以外
|
|
31
|
+
* なら絞り込まない (両方返す) — 呼び出し側で既に検証済みの前提。
|
|
32
|
+
*
|
|
33
|
+
* @param {{claudeBridge?: object, codexBridge?: object}} bridges
|
|
34
|
+
* @param {{cwd?: string, cliKind?: 'claude'|'codex'}} [filter]
|
|
35
|
+
* @returns {Array<object>} describeLiveSessions の記述子配列 (claude → codex の順)
|
|
36
|
+
*/
|
|
37
|
+
export function describeLiveSessionsUnion(bridges, { cwd, cliKind } = {}) {
|
|
38
|
+
const { claudeBridge, codexBridge } = bridges || {}
|
|
39
|
+
const out = []
|
|
40
|
+
if (cliKind !== "codex") out.push(...(claudeBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
41
|
+
if (cliKind !== "claude") out.push(...(codexBridge?.describeLiveSessions?.(cwd) ?? []))
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* tmux セッション行が「依頼を受け取れる (warm)」かを判定する。
|
|
47
|
+
*
|
|
48
|
+
* 判定は上記 union に一本化する。`cwd` が取れていない行 (tmux の cwd 解決失敗など) は
|
|
49
|
+
* 宛先解決のスコープを作れないため false (fail-closed。送れないものを送れると見せない)。
|
|
50
|
+
*
|
|
51
|
+
* @param {{claudeBridge?: object, codexBridge?: object}} bridges
|
|
52
|
+
* @param {{name?: string, cwd?: string|null}} row tmux.list_sessions の session 行
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isSessionWarm(bridges, { name, cwd } = {}) {
|
|
56
|
+
if (!cwd) return false
|
|
57
|
+
const cliKind = cliKindForSessionName(name)
|
|
58
|
+
return describeLiveSessionsUnion(bridges, { cwd, cliKind }).length > 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default { describeLiveSessionsUnion, isSessionWarm }
|
package/src/main.mjs
CHANGED
|
@@ -86,6 +86,8 @@ import {
|
|
|
86
86
|
// 名前衝突を避けて namespace import する。
|
|
87
87
|
import * as codexProfiles from "./codex-profiles.mjs"
|
|
88
88
|
import {
|
|
89
|
+
clearCredentialCheckCache,
|
|
90
|
+
hasFileCredentialsCached,
|
|
89
91
|
isKeychainPlatform,
|
|
90
92
|
profileHasKeychainCredentials,
|
|
91
93
|
} from "./claude-credentials.mjs"
|
|
@@ -108,6 +110,7 @@ import {
|
|
|
108
110
|
stopTerminalMirror,
|
|
109
111
|
} from "./tmux.mjs"
|
|
110
112
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
113
|
+
import { isSessionWarm } from "./live-sessions.mjs"
|
|
111
114
|
import {
|
|
112
115
|
boundSessionId,
|
|
113
116
|
codexContextPercentFromTokenUsage,
|
|
@@ -197,14 +200,17 @@ function codexDefaultsFromAgentConfig(config) {
|
|
|
197
200
|
/**
|
|
198
201
|
* プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
|
|
199
202
|
*
|
|
200
|
-
* 再作成された pane
|
|
201
|
-
*
|
|
203
|
+
* 再作成された pane は **既定では素のシェル** (createSession が CLI を起動しない)。
|
|
204
|
+
* アカウント切替は SDK チャット側の process.env と、tmux サーバーのグローバル環境
|
|
205
|
+
* (syncTmuxProfileEnv の `set-environment -g`) で追従する。
|
|
206
|
+
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` で TUI を起動する運用の場合のみ、createSession 内の
|
|
207
|
+
* injectConfigDirEnv が新しい CLAUDE_CONFIG_DIR をコマンドへ焼き込む。
|
|
202
208
|
* initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
|
|
203
209
|
* 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
|
|
204
210
|
* cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
|
|
205
211
|
* tmux サーバー未起動なら何もしない。
|
|
206
212
|
*/
|
|
207
|
-
async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
213
|
+
export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
208
214
|
const result = { killed: [], recreated: [], failed: [] }
|
|
209
215
|
let sessions = []
|
|
210
216
|
try {
|
|
@@ -408,9 +414,11 @@ export function applyActiveProfileEnv(profile, log) {
|
|
|
408
414
|
|
|
409
415
|
/**
|
|
410
416
|
* active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
|
|
411
|
-
* tmux pane のシェルは hub-agent の process.env
|
|
412
|
-
*
|
|
413
|
-
*
|
|
417
|
+
* tmux pane のシェルは hub-agent の process.env を見ないため、set-environment -g で流し込む。
|
|
418
|
+
* pane は既定で素のシェル (CLI を自動起動しない) なので、ユーザーが pane 内で直接
|
|
419
|
+
* `claude` を打つケースにはこれが主経路になる
|
|
420
|
+
* (`HUB_AGENT_TMUX_LAUNCH_CLI=1` で CLI を pane 起動するときだけ、createSession 内の
|
|
421
|
+
* injectConfigDirEnv によるコマンド焼き込みが併用される)。
|
|
414
422
|
* tmux サーバー未起動でも失敗しない。
|
|
415
423
|
*/
|
|
416
424
|
export async function syncTmuxProfileEnv(profile, log) {
|
|
@@ -2011,17 +2019,27 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
|
|
|
2011
2019
|
* 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
|
|
2012
2020
|
* 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
|
|
2013
2021
|
*
|
|
2022
|
+
* `warm` (2026-08-01): 「このセッションに依頼 (cockpit_orch.py send) を送れるか」。frontend は
|
|
2023
|
+
* 従来「tmux のセッション行が存在するか」だけで起動中を判定していたため、**依頼を送れない
|
|
2024
|
+
* セッションが起動中と表示される**食い違いがあった。判定は live-sessions.mjs の
|
|
2025
|
+
* `isSessionWarm` (sibling-inject の宛先候補列挙と同一関数) に一本化する。accessor 未指定
|
|
2026
|
+
* (=旧呼び出し) では `warm` を付与しない(推測で false を配らない)。
|
|
2027
|
+
*
|
|
2014
2028
|
* @param {Array} sessions tmux.list_sessions が返した素の session 行
|
|
2015
2029
|
* @param {Map<string, object>} lastEventByName session_name → 最新 hook event
|
|
2016
2030
|
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
2017
2031
|
* accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
|
|
2032
|
+
* @param {((row: {name: string, cwd?: string|null}) => boolean) | undefined} isWarm
|
|
2033
|
+
* warm 判定 accessor (既定は live-sessions.mjs の isSessionWarm を bind したもの)。
|
|
2034
|
+
* undefined のときは `warm` を付与しない
|
|
2018
2035
|
*/
|
|
2019
|
-
export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
|
|
2036
|
+
export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap, isWarm) {
|
|
2020
2037
|
return sessions.map((s) => {
|
|
2021
2038
|
const base = {
|
|
2022
2039
|
...s,
|
|
2023
2040
|
last_event: lastEventByName.get(s.name) || null,
|
|
2024
2041
|
}
|
|
2042
|
+
if (isWarm) base.warm = !!isWarm(s)
|
|
2025
2043
|
if (!getSnap) return base
|
|
2026
2044
|
const snap = getSnap(s.name)
|
|
2027
2045
|
if (!snap) return base
|
|
@@ -3309,6 +3327,388 @@ export async function handleKillSession(msg, ctx) {
|
|
|
3309
3327
|
}
|
|
3310
3328
|
}
|
|
3311
3329
|
|
|
3330
|
+
/** usage.response の中身を共有する TTL (ms)。frontend の poll は既定 60 秒周期。 */
|
|
3331
|
+
export const USAGE_RESPONSE_TTL_MS = 4_000
|
|
3332
|
+
|
|
3333
|
+
/**
|
|
3334
|
+
* usage.response の **中身だけ** を共有する短期キャッシュ。
|
|
3335
|
+
*
|
|
3336
|
+
* usage.request が返すのはアカウント全体の 5h/7d 利用量で、どのウィンドウから聞いても
|
|
3337
|
+
* 同じ答えになる。にもかかわらず N 個のウィンドウが別々に取りに行き、1 回ごとに
|
|
3338
|
+
* `security` spawn ・SDK control RPC ・`codex app-server` の起動/停止を伴っていたため、
|
|
3339
|
+
* control レーンの実測占有 (2026-08-01: 92 秒中 73.3 秒) の主因になっていた。
|
|
3340
|
+
*
|
|
3341
|
+
* ⚠️ **request_id は絶対にここへ入れない**。キャッシュした payload に前の要求の
|
|
3342
|
+
* request_id が混ざると、別ウィンドウの要求に他人の応答が返る。送信時に各要求の
|
|
3343
|
+
* request_id を後から差し込む (handleUsageRequest 参照)。
|
|
3344
|
+
*/
|
|
3345
|
+
export const usageResponseCache = new AsyncTtlCache({ ttlMs: USAGE_RESPONSE_TTL_MS })
|
|
3346
|
+
|
|
3347
|
+
/** 前回 build 以降に「キャッシュで返した」要求数 (= 畳めた重複の実測値)。 */
|
|
3348
|
+
let usageServedFromCacheSinceLastBuild = 0
|
|
3349
|
+
|
|
3350
|
+
/**
|
|
3351
|
+
* usage.response の中身 (usage / sessions / codexUsage / codexSessions) を組み立てる。
|
|
3352
|
+
* キャッシュ miss のときだけ実行される。
|
|
3353
|
+
*
|
|
3354
|
+
* 戻り値は `{ payload, timings }`。**payload はそのまま応答に展開する中身**、timings は
|
|
3355
|
+
* 内訳の所要時間で、ログにだけ使う (応答には出さない)。ログの発火は呼び出し側
|
|
3356
|
+
* (handleUsageRequest) が「実際に build したときだけ」行う。
|
|
3357
|
+
*
|
|
3358
|
+
* @returns {Promise<{payload: object, timings: object}>}
|
|
3359
|
+
*/
|
|
3360
|
+
export async function buildUsagePayload(ctx) {
|
|
3361
|
+
const startedAt = Date.now()
|
|
3362
|
+
let credentialsMs = 0
|
|
3363
|
+
let accountUsageMs = 0
|
|
3364
|
+
let localUsageMs = 0
|
|
3365
|
+
let codexRateLimitsMs = 0
|
|
3366
|
+
|
|
3367
|
+
const credentialsStartedAt = Date.now()
|
|
3368
|
+
const activeClaudeProfile = await getActiveProfile()
|
|
3369
|
+
const claudeHasCredentials = isKeychainPlatform()
|
|
3370
|
+
? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
|
|
3371
|
+
: await hasFileCredentialsCached(activeClaudeProfile.configDir)
|
|
3372
|
+
credentialsMs = Date.now() - credentialsStartedAt
|
|
3373
|
+
|
|
3374
|
+
// 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
|
|
3375
|
+
// statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
|
|
3376
|
+
// ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
|
|
3377
|
+
// 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
|
|
3378
|
+
// API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
|
|
3379
|
+
// は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
|
|
3380
|
+
if (ctx.claudeBridge) {
|
|
3381
|
+
const accountUsageStartedAt = Date.now()
|
|
3382
|
+
try {
|
|
3383
|
+
// ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
|
|
3384
|
+
// 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
|
|
3385
|
+
// control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
|
|
3386
|
+
// blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
|
|
3387
|
+
// 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
|
|
3388
|
+
const acct = await withDeadline(
|
|
3389
|
+
ctx.claudeBridge.getAccountUsage(),
|
|
3390
|
+
ACCOUNT_USAGE_TIMEOUT_MS,
|
|
3391
|
+
() =>
|
|
3392
|
+
ctx.logger?.warn?.(
|
|
3393
|
+
{ timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
|
|
3394
|
+
"account usage timed out (dispatch lane released)",
|
|
3395
|
+
),
|
|
3396
|
+
)
|
|
3397
|
+
if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
|
|
3398
|
+
} catch (err) {
|
|
3399
|
+
ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
|
|
3400
|
+
}
|
|
3401
|
+
accountUsageMs = Date.now() - accountUsageStartedAt
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
const localUsageStartedAt = Date.now()
|
|
3405
|
+
let [usage, sessions] = await Promise.all([
|
|
3406
|
+
getUsage({ profileId: activeClaudeProfile.id }),
|
|
3407
|
+
getSessionUsages(),
|
|
3408
|
+
])
|
|
3409
|
+
localUsageMs = Date.now() - localUsageStartedAt
|
|
3410
|
+
// 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
|
|
3411
|
+
if (!claudeHasCredentials) {
|
|
3412
|
+
usage = null
|
|
3413
|
+
sessions = []
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
|
|
3417
|
+
// 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
|
|
3418
|
+
// の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
|
|
3419
|
+
// 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
|
|
3420
|
+
// として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
|
|
3421
|
+
let codexUsage = null
|
|
3422
|
+
let codexSessions = []
|
|
3423
|
+
const activeCodexProfile = await codexProfiles.getActiveProfile()
|
|
3424
|
+
const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
|
|
3425
|
+
if (ctx.codexBridge && codexHasCredentials) {
|
|
3426
|
+
try {
|
|
3427
|
+
// T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
|
|
3428
|
+
// 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
|
|
3429
|
+
// 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
|
|
3430
|
+
// 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
|
|
3431
|
+
// 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
|
|
3432
|
+
// producerSeq を context% の live source として通して両経路を収束させる。
|
|
3433
|
+
const cwds = new Set()
|
|
3434
|
+
for (const s of ctx.codexBridge.sessions.values()) {
|
|
3435
|
+
if (s.threadId && s.cwd) cwds.add(s.cwd)
|
|
3436
|
+
}
|
|
3437
|
+
const liveCodex = []
|
|
3438
|
+
for (const cwd of cwds) {
|
|
3439
|
+
const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
|
|
3440
|
+
if (authoritative?.threadId) {
|
|
3441
|
+
liveCodex.push({
|
|
3442
|
+
sessionId: authoritative.threadId,
|
|
3443
|
+
cwd: authoritative.cwd,
|
|
3444
|
+
threadId: authoritative.threadId,
|
|
3445
|
+
producerSeq: authoritative.seq,
|
|
3446
|
+
})
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
if (liveCodex.length > 0) {
|
|
3450
|
+
// 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
|
|
3451
|
+
// なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
|
|
3452
|
+
codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
|
|
3453
|
+
producerSeq: liveCodex[0].producerSeq,
|
|
3454
|
+
})
|
|
3455
|
+
codexSessions = await codexSessionUsages(liveCodex)
|
|
3456
|
+
// ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
|
|
3457
|
+
// 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
|
|
3458
|
+
// 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
|
|
3459
|
+
// codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
|
|
3460
|
+
// live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
|
|
3461
|
+
const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
|
|
3462
|
+
const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
|
|
3463
|
+
codexSessions = filterCodexSessionsByAuthority(
|
|
3464
|
+
codexSessions,
|
|
3465
|
+
producerByCwd,
|
|
3466
|
+
seqForCwd,
|
|
3467
|
+
)
|
|
3468
|
+
// codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
|
|
3469
|
+
// (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
|
|
3470
|
+
// (context だけ null 化。5h/7d は account-wide なので保持する)。
|
|
3471
|
+
if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
|
|
3472
|
+
codexUsage = { ...codexUsage, context: null }
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
// ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
|
|
3476
|
+
// rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
|
|
3477
|
+
const codexRateLimitsStartedAt = Date.now()
|
|
3478
|
+
const rateLimits = await ctx.codexBridge.readAccountRateLimits({
|
|
3479
|
+
profileId: activeCodexProfile.id,
|
|
3480
|
+
})
|
|
3481
|
+
codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
|
|
3482
|
+
const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
|
|
3483
|
+
if (accountUsage) {
|
|
3484
|
+
codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
|
|
3485
|
+
recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
|
|
3486
|
+
}
|
|
3487
|
+
} catch (err) {
|
|
3488
|
+
ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
// Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
|
|
3492
|
+
// 表示する。未ログインならここへ到達せず、表示も完全に隠す。
|
|
3493
|
+
if (codexHasCredentials && !codexUsage) {
|
|
3494
|
+
codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
return {
|
|
3498
|
+
payload: { usage, sessions, codexUsage, codexSessions },
|
|
3499
|
+
timings: {
|
|
3500
|
+
credentials_ms: credentialsMs,
|
|
3501
|
+
account_usage_ms: accountUsageMs,
|
|
3502
|
+
local_usage_ms: localUsageMs,
|
|
3503
|
+
codex_ratelimits_ms: codexRateLimitsMs,
|
|
3504
|
+
total_ms: Date.now() - startedAt,
|
|
3505
|
+
},
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
/**
|
|
3510
|
+
* usage.request の処理。中身は TTL キャッシュ + single-flight で共有し、request_id
|
|
3511
|
+
* だけを要求ごとに差し込む。
|
|
3512
|
+
*
|
|
3513
|
+
* ⚠️ 送信オブジェクトは `...payload` を先に展開し **request_id を後から上書き** する。
|
|
3514
|
+
* 万一 payload 側に request_id が混入しても、他人の応答が返る事故にならないため。
|
|
3515
|
+
*
|
|
3516
|
+
* テストから直接呼べるよう export。中身の組み立ては deps.build で差し替え可能。
|
|
3517
|
+
*/
|
|
3518
|
+
export async function handleUsageRequest(msg, ctx, deps = {}) {
|
|
3519
|
+
const build = deps.build || (() => buildUsagePayload(ctx))
|
|
3520
|
+
try {
|
|
3521
|
+
// build を実行したのが自分かどうか。single-flight で畳まれた caller の closure は
|
|
3522
|
+
// 呼ばれないため、そのまま「キャッシュで返した件数」の計測になる。
|
|
3523
|
+
let built = false
|
|
3524
|
+
const result = await usageResponseCache.get(() => {
|
|
3525
|
+
built = true
|
|
3526
|
+
return build()
|
|
3527
|
+
})
|
|
3528
|
+
if (built) {
|
|
3529
|
+
// 効果を数字で確認するための内訳。**build したときだけ** 出す (ヒット時は出さない。
|
|
3530
|
+
// ウィンドウ数 × poll で件数が多く、ログが埋まるため)。
|
|
3531
|
+
// served_from_cache_since_last_build が「畳めた重複」の実測値になる。
|
|
3532
|
+
ctx.logger?.info?.(
|
|
3533
|
+
{
|
|
3534
|
+
...result.timings,
|
|
3535
|
+
served_from_cache_since_last_build: usageServedFromCacheSinceLastBuild,
|
|
3536
|
+
},
|
|
3537
|
+
"usage request timing",
|
|
3538
|
+
)
|
|
3539
|
+
usageServedFromCacheSinceLastBuild = 0
|
|
3540
|
+
} else {
|
|
3541
|
+
usageServedFromCacheSinceLastBuild += 1
|
|
3542
|
+
}
|
|
3543
|
+
ctx.client.send({
|
|
3544
|
+
type: "usage.response",
|
|
3545
|
+
...result.payload,
|
|
3546
|
+
request_id: msg.request_id,
|
|
3547
|
+
})
|
|
3548
|
+
} catch (err) {
|
|
3549
|
+
ctx.client.send({
|
|
3550
|
+
type: "usage.response",
|
|
3551
|
+
usage: null,
|
|
3552
|
+
sessions: [],
|
|
3553
|
+
codexUsage: null,
|
|
3554
|
+
codexSessions: [],
|
|
3555
|
+
error: err.message,
|
|
3556
|
+
request_id: msg.request_id,
|
|
3557
|
+
})
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
/**
|
|
3562
|
+
* `tmux.create_session` ハンドラ。指定名・cwd で detached の tmux セッションを作る。
|
|
3563
|
+
*
|
|
3564
|
+
* ⚠️ pane に CLI (claude / codex) を起動するかは **createSession 内の
|
|
3565
|
+
* shouldLaunchPaneCli() が一元判定**する (既定は起動しない)。ここで渡す cliKind /
|
|
3566
|
+
* claudeCmd / codexCmd / initialPrompt 等は、`HUB_AGENT_TMUX_LAUNCH_CLI=1` で
|
|
3567
|
+
* CLI を起動するときにだけ使われる。呼び出し元ごとに既定を変えないため、
|
|
3568
|
+
* このハンドラ側で起動可否を判断しないこと。
|
|
3569
|
+
*
|
|
3570
|
+
* dispatch から切り出して export しているのは、実際に tmux へ渡る引数を
|
|
3571
|
+
* テストから検査できるようにするため (test/tmux-pane-cli-launch.test.mjs)。
|
|
3572
|
+
*/
|
|
3573
|
+
export async function handleTmuxCreateSession(msg, ctx) {
|
|
3574
|
+
const name = (msg.session_name || "").trim()
|
|
3575
|
+
const cwd = (msg.cwd || process.env.HOME || "/").trim()
|
|
3576
|
+
if (!name) {
|
|
3577
|
+
ctx.client.send({
|
|
3578
|
+
type: "tmux.create_session.result",
|
|
3579
|
+
request_id: msg.request_id,
|
|
3580
|
+
ok: false,
|
|
3581
|
+
error: "session_name is required",
|
|
3582
|
+
})
|
|
3583
|
+
return
|
|
3584
|
+
}
|
|
3585
|
+
try {
|
|
3586
|
+
await createTmuxSession(name, cwd, {
|
|
3587
|
+
// cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
|
|
3588
|
+
cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
|
|
3589
|
+
claudeCmd:
|
|
3590
|
+
typeof msg.claude_cmd === "string"
|
|
3591
|
+
? msg.claude_cmd
|
|
3592
|
+
: claudeCmdFromAgentConfig(ctx.config),
|
|
3593
|
+
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
3594
|
+
// codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
|
|
3595
|
+
model:
|
|
3596
|
+
typeof msg.model === "string"
|
|
3597
|
+
? msg.model
|
|
3598
|
+
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
3599
|
+
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
3600
|
+
approvalPolicy:
|
|
3601
|
+
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
3602
|
+
effort:
|
|
3603
|
+
typeof msg.effort === "string"
|
|
3604
|
+
? msg.effort
|
|
3605
|
+
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
3606
|
+
initialPrompt:
|
|
3607
|
+
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
3608
|
+
logger: ctx.logger,
|
|
3609
|
+
})
|
|
3610
|
+
tmuxSessionsSnapshotCache.clear()
|
|
3611
|
+
ctx.client.send({
|
|
3612
|
+
type: "tmux.create_session.result",
|
|
3613
|
+
request_id: msg.request_id,
|
|
3614
|
+
ok: true,
|
|
3615
|
+
session_name: name,
|
|
3616
|
+
})
|
|
3617
|
+
} catch (err) {
|
|
3618
|
+
ctx.client.send({
|
|
3619
|
+
type: "tmux.create_session.result",
|
|
3620
|
+
request_id: msg.request_id,
|
|
3621
|
+
ok: false,
|
|
3622
|
+
error: err.message,
|
|
3623
|
+
})
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
/**
|
|
3628
|
+
* `worktree.create` ハンドラ。git worktree を作ってから、その dir で tmux セッションを作る。
|
|
3629
|
+
* body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?,
|
|
3630
|
+
* approval_policy?, effort?, initial_prompt? }
|
|
3631
|
+
*
|
|
3632
|
+
* ⚠️ pane の CLI 起動可否は handleTmuxCreateSession と同じく createSession 側の
|
|
3633
|
+
* 一元判定に従う (既定は起動しない)。
|
|
3634
|
+
*/
|
|
3635
|
+
export async function handleWorktreeCreate(msg, ctx) {
|
|
3636
|
+
const dir = (msg.dir || "").trim()
|
|
3637
|
+
const branch = (msg.branch || "").trim()
|
|
3638
|
+
if (!dir || !branch) {
|
|
3639
|
+
ctx.client.send({
|
|
3640
|
+
type: "worktree.create.result",
|
|
3641
|
+
request_id: msg.request_id,
|
|
3642
|
+
ok: false,
|
|
3643
|
+
error: "dir and branch required",
|
|
3644
|
+
})
|
|
3645
|
+
return
|
|
3646
|
+
}
|
|
3647
|
+
if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
|
|
3648
|
+
ctx.client.send({
|
|
3649
|
+
type: "worktree.create.result",
|
|
3650
|
+
request_id: msg.request_id,
|
|
3651
|
+
ok: false,
|
|
3652
|
+
error: "invalid branch name",
|
|
3653
|
+
})
|
|
3654
|
+
return
|
|
3655
|
+
}
|
|
3656
|
+
try {
|
|
3657
|
+
const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
|
|
3658
|
+
// worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
|
|
3659
|
+
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
3660
|
+
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
3661
|
+
// 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
|
|
3662
|
+
// 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
|
|
3663
|
+
const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
3664
|
+
const wtSessionName = sessionNameFor(wtName, wtCliKind)
|
|
3665
|
+
await createTmuxSession(wtSessionName, wtPath, {
|
|
3666
|
+
cliKind: wtCliKind,
|
|
3667
|
+
claudeCmd:
|
|
3668
|
+
typeof msg.claude_cmd === "string"
|
|
3669
|
+
? msg.claude_cmd
|
|
3670
|
+
: claudeCmdFromAgentConfig(ctx.config),
|
|
3671
|
+
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
3672
|
+
// codex 用設定 (claude 経路では無視される)。
|
|
3673
|
+
model:
|
|
3674
|
+
typeof msg.model === "string"
|
|
3675
|
+
? msg.model
|
|
3676
|
+
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
3677
|
+
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
3678
|
+
approvalPolicy:
|
|
3679
|
+
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
3680
|
+
effort:
|
|
3681
|
+
typeof msg.effort === "string"
|
|
3682
|
+
? msg.effort
|
|
3683
|
+
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
3684
|
+
initialPrompt:
|
|
3685
|
+
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
3686
|
+
logger: ctx.logger,
|
|
3687
|
+
})
|
|
3688
|
+
tmuxSessionsSnapshotCache.clear()
|
|
3689
|
+
ctx.client.send({
|
|
3690
|
+
type: "worktree.create.result",
|
|
3691
|
+
request_id: msg.request_id,
|
|
3692
|
+
ok: true,
|
|
3693
|
+
// wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
|
|
3694
|
+
wt_name: wtName,
|
|
3695
|
+
// session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
|
|
3696
|
+
// 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
|
|
3697
|
+
session_name: wtSessionName,
|
|
3698
|
+
wt_path: wtPath,
|
|
3699
|
+
branch,
|
|
3700
|
+
created_branch: createdBranch,
|
|
3701
|
+
})
|
|
3702
|
+
} catch (err) {
|
|
3703
|
+
ctx.client.send({
|
|
3704
|
+
type: "worktree.create.result",
|
|
3705
|
+
request_id: msg.request_id,
|
|
3706
|
+
ok: false,
|
|
3707
|
+
error: err.message,
|
|
3708
|
+
})
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3312
3712
|
async function dispatch(msg, ctx) {
|
|
3313
3713
|
const t = msg?.type || ""
|
|
3314
3714
|
try {
|
|
@@ -4357,6 +4757,10 @@ async function dispatch(msg, ctx) {
|
|
|
4357
4757
|
// tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
|
|
4358
4758
|
const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
|
|
4359
4759
|
tmuxSessionsSnapshotCache.clear()
|
|
4760
|
+
// アカウントが変わったので、旧アカウントの利用量・認証在否を TTL の残り時間だけ
|
|
4761
|
+
// 見せ続けないよう明示的に捨てる (直後の needs_login 判定も実測値になる)。
|
|
4762
|
+
usageResponseCache.clear()
|
|
4763
|
+
clearCredentialCheckCache()
|
|
4360
4764
|
// 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
|
|
4361
4765
|
const hasCreds = isKeychainPlatform()
|
|
4362
4766
|
? await profileHasKeychainCredentials(target.configDir)
|
|
@@ -4497,6 +4901,10 @@ async function dispatch(msg, ctx) {
|
|
|
4497
4901
|
const before = ctx.codexBridge?.sessions?.size ?? 0
|
|
4498
4902
|
ctx.codexBridge?.shutdown()
|
|
4499
4903
|
tmuxSessionsSnapshotCache.clear()
|
|
4904
|
+
// Codex アカウントが変わったので、旧アカウントの 5h/7d を TTL の残り時間だけ
|
|
4905
|
+
// 見せ続けない (rate limits 自体は profileId 別キャッシュなので混ざらないが、
|
|
4906
|
+
// usage.response の中身は両アカウントを束ねているため明示的に捨てる)。
|
|
4907
|
+
usageResponseCache.clear()
|
|
4500
4908
|
const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
|
|
4501
4909
|
const needsLogin = !hasCreds
|
|
4502
4910
|
const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
|
|
@@ -4648,6 +5056,13 @@ async function dispatch(msg, ctx) {
|
|
|
4648
5056
|
sessions,
|
|
4649
5057
|
lastEventByName,
|
|
4650
5058
|
ctx.getSessionStateSnapshot,
|
|
5059
|
+
// warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
|
|
5060
|
+
// 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
|
|
5061
|
+
(row) =>
|
|
5062
|
+
isSessionWarm(
|
|
5063
|
+
{ claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
|
|
5064
|
+
row,
|
|
5065
|
+
),
|
|
4651
5066
|
)
|
|
4652
5067
|
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4653
5068
|
let worktreeStubs = []
|
|
@@ -4698,135 +5113,11 @@ async function dispatch(msg, ctx) {
|
|
|
4698
5113
|
return
|
|
4699
5114
|
}
|
|
4700
5115
|
case "tmux.create_session": {
|
|
4701
|
-
|
|
4702
|
-
const cwd = (msg.cwd || process.env.HOME || "/").trim()
|
|
4703
|
-
if (!name) {
|
|
4704
|
-
ctx.client.send({
|
|
4705
|
-
type: "tmux.create_session.result",
|
|
4706
|
-
request_id: msg.request_id,
|
|
4707
|
-
ok: false,
|
|
4708
|
-
error: "session_name is required",
|
|
4709
|
-
})
|
|
4710
|
-
return
|
|
4711
|
-
}
|
|
4712
|
-
try {
|
|
4713
|
-
await createTmuxSession(name, cwd, {
|
|
4714
|
-
// cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
|
|
4715
|
-
cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
|
|
4716
|
-
claudeCmd:
|
|
4717
|
-
typeof msg.claude_cmd === "string"
|
|
4718
|
-
? msg.claude_cmd
|
|
4719
|
-
: claudeCmdFromAgentConfig(ctx.config),
|
|
4720
|
-
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
4721
|
-
// codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
|
|
4722
|
-
model:
|
|
4723
|
-
typeof msg.model === "string"
|
|
4724
|
-
? msg.model
|
|
4725
|
-
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
4726
|
-
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
4727
|
-
approvalPolicy:
|
|
4728
|
-
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
4729
|
-
effort:
|
|
4730
|
-
typeof msg.effort === "string"
|
|
4731
|
-
? msg.effort
|
|
4732
|
-
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
4733
|
-
initialPrompt:
|
|
4734
|
-
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4735
|
-
logger: ctx.logger,
|
|
4736
|
-
})
|
|
4737
|
-
tmuxSessionsSnapshotCache.clear()
|
|
4738
|
-
ctx.client.send({
|
|
4739
|
-
type: "tmux.create_session.result",
|
|
4740
|
-
request_id: msg.request_id,
|
|
4741
|
-
ok: true,
|
|
4742
|
-
session_name: name,
|
|
4743
|
-
})
|
|
4744
|
-
} catch (err) {
|
|
4745
|
-
ctx.client.send({
|
|
4746
|
-
type: "tmux.create_session.result",
|
|
4747
|
-
request_id: msg.request_id,
|
|
4748
|
-
ok: false,
|
|
4749
|
-
error: err.message,
|
|
4750
|
-
})
|
|
4751
|
-
}
|
|
5116
|
+
await handleTmuxCreateSession(msg, ctx)
|
|
4752
5117
|
return
|
|
4753
5118
|
}
|
|
4754
5119
|
case "worktree.create": {
|
|
4755
|
-
|
|
4756
|
-
const dir = (msg.dir || "").trim()
|
|
4757
|
-
const branch = (msg.branch || "").trim()
|
|
4758
|
-
if (!dir || !branch) {
|
|
4759
|
-
ctx.client.send({
|
|
4760
|
-
type: "worktree.create.result",
|
|
4761
|
-
request_id: msg.request_id,
|
|
4762
|
-
ok: false,
|
|
4763
|
-
error: "dir and branch required",
|
|
4764
|
-
})
|
|
4765
|
-
return
|
|
4766
|
-
}
|
|
4767
|
-
if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
|
|
4768
|
-
ctx.client.send({
|
|
4769
|
-
type: "worktree.create.result",
|
|
4770
|
-
request_id: msg.request_id,
|
|
4771
|
-
ok: false,
|
|
4772
|
-
error: "invalid branch name",
|
|
4773
|
-
})
|
|
4774
|
-
return
|
|
4775
|
-
}
|
|
4776
|
-
try {
|
|
4777
|
-
const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
|
|
4778
|
-
// worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
|
|
4779
|
-
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
4780
|
-
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
4781
|
-
// 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
|
|
4782
|
-
// 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
|
|
4783
|
-
const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
4784
|
-
const wtSessionName = sessionNameFor(wtName, wtCliKind)
|
|
4785
|
-
await createTmuxSession(wtSessionName, wtPath, {
|
|
4786
|
-
cliKind: wtCliKind,
|
|
4787
|
-
claudeCmd:
|
|
4788
|
-
typeof msg.claude_cmd === "string"
|
|
4789
|
-
? msg.claude_cmd
|
|
4790
|
-
: claudeCmdFromAgentConfig(ctx.config),
|
|
4791
|
-
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
4792
|
-
// codex 用設定 (claude 経路では無視される)。
|
|
4793
|
-
model:
|
|
4794
|
-
typeof msg.model === "string"
|
|
4795
|
-
? msg.model
|
|
4796
|
-
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
4797
|
-
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
4798
|
-
approvalPolicy:
|
|
4799
|
-
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
4800
|
-
effort:
|
|
4801
|
-
typeof msg.effort === "string"
|
|
4802
|
-
? msg.effort
|
|
4803
|
-
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
4804
|
-
initialPrompt:
|
|
4805
|
-
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4806
|
-
logger: ctx.logger,
|
|
4807
|
-
})
|
|
4808
|
-
tmuxSessionsSnapshotCache.clear()
|
|
4809
|
-
ctx.client.send({
|
|
4810
|
-
type: "worktree.create.result",
|
|
4811
|
-
request_id: msg.request_id,
|
|
4812
|
-
ok: true,
|
|
4813
|
-
// wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
|
|
4814
|
-
wt_name: wtName,
|
|
4815
|
-
// session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
|
|
4816
|
-
// 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
|
|
4817
|
-
session_name: wtSessionName,
|
|
4818
|
-
wt_path: wtPath,
|
|
4819
|
-
branch,
|
|
4820
|
-
created_branch: createdBranch,
|
|
4821
|
-
})
|
|
4822
|
-
} catch (err) {
|
|
4823
|
-
ctx.client.send({
|
|
4824
|
-
type: "worktree.create.result",
|
|
4825
|
-
request_id: msg.request_id,
|
|
4826
|
-
ok: false,
|
|
4827
|
-
error: err.message,
|
|
4828
|
-
})
|
|
4829
|
-
}
|
|
5120
|
+
await handleWorktreeCreate(msg, ctx)
|
|
4830
5121
|
return
|
|
4831
5122
|
}
|
|
4832
5123
|
case "worktree.list_history": {
|
|
@@ -4963,145 +5254,7 @@ async function dispatch(msg, ctx) {
|
|
|
4963
5254
|
return
|
|
4964
5255
|
}
|
|
4965
5256
|
case "usage.request": {
|
|
4966
|
-
|
|
4967
|
-
const activeClaudeProfile = await getActiveProfile()
|
|
4968
|
-
const claudeHasCredentials = isKeychainPlatform()
|
|
4969
|
-
? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
|
|
4970
|
-
: await hasFileCredentials(activeClaudeProfile.configDir)
|
|
4971
|
-
// 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
|
|
4972
|
-
// statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
|
|
4973
|
-
// ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
|
|
4974
|
-
// 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
|
|
4975
|
-
// API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
|
|
4976
|
-
// は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
|
|
4977
|
-
if (ctx.claudeBridge) {
|
|
4978
|
-
try {
|
|
4979
|
-
// ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
|
|
4980
|
-
// 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
|
|
4981
|
-
// control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
|
|
4982
|
-
// blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
|
|
4983
|
-
// 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
|
|
4984
|
-
const acct = await withDeadline(
|
|
4985
|
-
ctx.claudeBridge.getAccountUsage(),
|
|
4986
|
-
ACCOUNT_USAGE_TIMEOUT_MS,
|
|
4987
|
-
() =>
|
|
4988
|
-
ctx.logger?.warn?.(
|
|
4989
|
-
{ timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
|
|
4990
|
-
"account usage timed out (dispatch lane released)",
|
|
4991
|
-
),
|
|
4992
|
-
)
|
|
4993
|
-
if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
|
|
4994
|
-
} catch (err) {
|
|
4995
|
-
ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
|
|
4996
|
-
}
|
|
4997
|
-
}
|
|
4998
|
-
let [usage, sessions] = await Promise.all([
|
|
4999
|
-
getUsage({ profileId: activeClaudeProfile.id }),
|
|
5000
|
-
getSessionUsages(),
|
|
5001
|
-
])
|
|
5002
|
-
// 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
|
|
5003
|
-
if (!claudeHasCredentials) {
|
|
5004
|
-
usage = null
|
|
5005
|
-
sessions = []
|
|
5006
|
-
}
|
|
5007
|
-
|
|
5008
|
-
// Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
|
|
5009
|
-
// 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
|
|
5010
|
-
// の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
|
|
5011
|
-
// 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
|
|
5012
|
-
// として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
|
|
5013
|
-
let codexUsage = null
|
|
5014
|
-
let codexSessions = []
|
|
5015
|
-
const activeCodexProfile = await codexProfiles.getActiveProfile()
|
|
5016
|
-
const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
|
|
5017
|
-
if (ctx.codexBridge && codexHasCredentials) {
|
|
5018
|
-
try {
|
|
5019
|
-
// T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
|
|
5020
|
-
// 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
|
|
5021
|
-
// 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
|
|
5022
|
-
// 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
|
|
5023
|
-
// 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
|
|
5024
|
-
// producerSeq を context% の live source として通して両経路を収束させる。
|
|
5025
|
-
const cwds = new Set()
|
|
5026
|
-
for (const s of ctx.codexBridge.sessions.values()) {
|
|
5027
|
-
if (s.threadId && s.cwd) cwds.add(s.cwd)
|
|
5028
|
-
}
|
|
5029
|
-
const liveCodex = []
|
|
5030
|
-
for (const cwd of cwds) {
|
|
5031
|
-
const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
|
|
5032
|
-
if (authoritative?.threadId) {
|
|
5033
|
-
liveCodex.push({
|
|
5034
|
-
sessionId: authoritative.threadId,
|
|
5035
|
-
cwd: authoritative.cwd,
|
|
5036
|
-
threadId: authoritative.threadId,
|
|
5037
|
-
producerSeq: authoritative.seq,
|
|
5038
|
-
})
|
|
5039
|
-
}
|
|
5040
|
-
}
|
|
5041
|
-
if (liveCodex.length > 0) {
|
|
5042
|
-
// 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
|
|
5043
|
-
// なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
|
|
5044
|
-
codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
|
|
5045
|
-
producerSeq: liveCodex[0].producerSeq,
|
|
5046
|
-
})
|
|
5047
|
-
codexSessions = await codexSessionUsages(liveCodex)
|
|
5048
|
-
// ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
|
|
5049
|
-
// 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
|
|
5050
|
-
// 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
|
|
5051
|
-
// codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
|
|
5052
|
-
// live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
|
|
5053
|
-
const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
|
|
5054
|
-
const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
|
|
5055
|
-
codexSessions = filterCodexSessionsByAuthority(
|
|
5056
|
-
codexSessions,
|
|
5057
|
-
producerByCwd,
|
|
5058
|
-
seqForCwd,
|
|
5059
|
-
)
|
|
5060
|
-
// codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
|
|
5061
|
-
// (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
|
|
5062
|
-
// (context だけ null 化。5h/7d は account-wide なので保持する)。
|
|
5063
|
-
if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
|
|
5064
|
-
codexUsage = { ...codexUsage, context: null }
|
|
5065
|
-
}
|
|
5066
|
-
}
|
|
5067
|
-
// ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
|
|
5068
|
-
// rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
|
|
5069
|
-
const accountUsage = codexUsageFromAccountRateLimits(
|
|
5070
|
-
await ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
|
|
5071
|
-
)
|
|
5072
|
-
if (accountUsage) {
|
|
5073
|
-
codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
|
|
5074
|
-
recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
|
|
5075
|
-
}
|
|
5076
|
-
} catch (err) {
|
|
5077
|
-
ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
|
|
5078
|
-
}
|
|
5079
|
-
}
|
|
5080
|
-
// Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
|
|
5081
|
-
// 表示する。未ログインならここへ到達せず、表示も完全に隠す。
|
|
5082
|
-
if (codexHasCredentials && !codexUsage) {
|
|
5083
|
-
codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
|
|
5084
|
-
}
|
|
5085
|
-
|
|
5086
|
-
ctx.client.send({
|
|
5087
|
-
type: "usage.response",
|
|
5088
|
-
request_id: msg.request_id,
|
|
5089
|
-
usage,
|
|
5090
|
-
sessions,
|
|
5091
|
-
codexUsage,
|
|
5092
|
-
codexSessions,
|
|
5093
|
-
})
|
|
5094
|
-
} catch (err) {
|
|
5095
|
-
ctx.client.send({
|
|
5096
|
-
type: "usage.response",
|
|
5097
|
-
request_id: msg.request_id,
|
|
5098
|
-
usage: null,
|
|
5099
|
-
sessions: [],
|
|
5100
|
-
codexUsage: null,
|
|
5101
|
-
codexSessions: [],
|
|
5102
|
-
error: err.message,
|
|
5103
|
-
})
|
|
5104
|
-
}
|
|
5257
|
+
await handleUsageRequest(msg, ctx)
|
|
5105
5258
|
return
|
|
5106
5259
|
}
|
|
5107
5260
|
default:
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/pro
|
|
|
41
41
|
import path from "node:path"
|
|
42
42
|
|
|
43
43
|
import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
|
|
44
|
+
import { describeLiveSessionsUnion } from "./live-sessions.mjs"
|
|
44
45
|
|
|
45
46
|
export const SIBLING_REQUESTS_DIR =
|
|
46
47
|
process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
|
|
@@ -69,6 +70,14 @@ export class SiblingInjectBridge {
|
|
|
69
70
|
this._watcher = null
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* live-sessions.mjs (warm 判定の正本) へ渡すブリッジ束。宛先候補の列挙は必ずここを
|
|
75
|
+
* 経由し、`tmux.list_sessions` の `warm` と同じ判定を共有する。
|
|
76
|
+
*/
|
|
77
|
+
_bridges() {
|
|
78
|
+
return { claudeBridge: this.claudeBridge, codexBridge: this.codexBridge }
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
/** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
|
|
73
82
|
async start() {
|
|
74
83
|
await mkdir(this.dir, { recursive: true })
|
|
@@ -159,13 +168,8 @@ export class SiblingInjectBridge {
|
|
|
159
168
|
this.logger?.warn({ request_id }, "sibling-inject: invalid sibling token (list-all)")
|
|
160
169
|
result = { ok: false, error: "invalid_token" }
|
|
161
170
|
} else {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
live_sessions: [
|
|
165
|
-
...(this.claudeBridge?.describeLiveSessions?.() ?? []),
|
|
166
|
-
...(this.codexBridge?.describeLiveSessions?.() ?? []),
|
|
167
|
-
],
|
|
168
|
-
}
|
|
171
|
+
// 判定は live-sessions.mjs に一本化する (tmux.list_sessions の warm と同じ関数)。
|
|
172
|
+
result = { ok: true, live_sessions: describeLiveSessionsUnion(this._bridges()) }
|
|
169
173
|
}
|
|
170
174
|
} else if (!cwd || badKind || badBody) {
|
|
171
175
|
result = { ok: false, error: "invalid_request" }
|
|
@@ -192,7 +196,15 @@ export class SiblingInjectBridge {
|
|
|
192
196
|
const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
193
197
|
if (isList) {
|
|
194
198
|
// 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
|
|
195
|
-
|
|
199
|
+
// ⚠️ tmux.list_sessions の `warm` と同じ関数を通す (二重定義にすると「グレー表示
|
|
200
|
+
// なのに宛先一覧に出ない」食い違いが再発する)。
|
|
201
|
+
result = {
|
|
202
|
+
ok: true,
|
|
203
|
+
live_sessions: describeLiveSessionsUnion(this._bridges(), {
|
|
204
|
+
cwd,
|
|
205
|
+
cliKind: target_cli_kind,
|
|
206
|
+
}),
|
|
207
|
+
}
|
|
196
208
|
} else if (isExplicit) {
|
|
197
209
|
// 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
|
|
198
210
|
// sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
|
|
@@ -361,10 +373,7 @@ export class SiblingInjectBridge {
|
|
|
361
373
|
* @param {string} targetKind
|
|
362
374
|
*/
|
|
363
375
|
_diagnose(cwd, targetKind) {
|
|
364
|
-
const sameCwd =
|
|
365
|
-
...(this.claudeBridge?.describeLiveSessions?.(cwd) ?? []),
|
|
366
|
-
...(this.codexBridge?.describeLiveSessions?.(cwd) ?? []),
|
|
367
|
-
]
|
|
376
|
+
const sameCwd = describeLiveSessionsUnion(this._bridges(), { cwd })
|
|
368
377
|
const hint = sameCwd.length
|
|
369
378
|
? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
|
|
370
379
|
: `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
|
package/src/tmux.mjs
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* - `listSessions()`: 構造化された TmuxSession[] を返す。各 session に
|
|
6
6
|
* status (state.mjs の detectSessionState) と cwd (active pane の current_path)
|
|
7
7
|
* を並列で付与
|
|
8
|
-
* - `createSession(name, cwd, opts)`: detached new-session
|
|
9
|
-
*
|
|
8
|
+
* - `createSession(name, cwd, opts)`: detached new-session。既定では pane に CLI
|
|
9
|
+
* (claude / codex) を起動せず素のシェルのまま置く。`HUB_AGENT_TMUX_LAUNCH_CLI=1`
|
|
10
|
+
* のときだけ従来どおり CLAUDE_CMD / CODEX_CMD を send-keys する。既存ならエラー throw
|
|
10
11
|
* - `killSession(name)`: 1 session を kill。存在しない場合は 'session not found' throw
|
|
11
12
|
* - `killManySessions(names)`: 複数 kill、存在しないものは無視
|
|
12
13
|
*
|
|
@@ -509,6 +510,26 @@ export function composeClaudeCmdFromOptions(opts = {}) {
|
|
|
509
510
|
|
|
510
511
|
const DEFAULT_CLAUDE_CMD = buildClaudeCmd()
|
|
511
512
|
|
|
513
|
+
/**
|
|
514
|
+
* tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
|
|
515
|
+
*
|
|
516
|
+
* **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
|
|
517
|
+
* 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
|
|
518
|
+
* app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
|
|
519
|
+
* **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
|
|
520
|
+
* 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
|
|
521
|
+
*
|
|
522
|
+
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
|
|
523
|
+
* 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
|
|
524
|
+
* この 1 箇所 (createSession 内) だけで行い、opts では上書きさせない。
|
|
525
|
+
*
|
|
526
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
527
|
+
* @returns {boolean}
|
|
528
|
+
*/
|
|
529
|
+
export function shouldLaunchPaneCli(env = process.env) {
|
|
530
|
+
return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
|
|
531
|
+
}
|
|
532
|
+
|
|
512
533
|
function tmuxBin(opts = {}) {
|
|
513
534
|
return opts.tmuxBin || DEFAULT_TMUX_BIN
|
|
514
535
|
}
|
|
@@ -546,6 +567,10 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
|
|
|
546
567
|
* しか効かないため、injectInitialPrompt と同様に `||` で split して両側に付ける。
|
|
547
568
|
*
|
|
548
569
|
* configDir が空 (= 既定プロファイル) のときは何もしない。
|
|
570
|
+
*
|
|
571
|
+
* ⚠️ この関数は **CLI を pane 起動するときだけ** 使われる
|
|
572
|
+
* (`HUB_AGENT_TMUX_LAUNCH_CLI=1`)。素のシェルの pane へは setTmuxGlobalEnv の
|
|
573
|
+
* `set-environment -g` 経由で CLAUDE_CONFIG_DIR が渡る。
|
|
549
574
|
*/
|
|
550
575
|
export function injectConfigDirEnv(claudeCmd, configDir) {
|
|
551
576
|
if (!configDir || typeof configDir !== "string") return claudeCmd
|
|
@@ -559,8 +584,10 @@ export function injectConfigDirEnv(claudeCmd, configDir) {
|
|
|
559
584
|
|
|
560
585
|
/**
|
|
561
586
|
* tmux サーバーのグローバル環境変数を設定/解除する。
|
|
562
|
-
*
|
|
563
|
-
*
|
|
587
|
+
* pane は既定で素のシェルなので、ユーザーが pane 内で手動起動する claude / codex に
|
|
588
|
+
* CLAUDE_CONFIG_DIR (CODEX_HOME) を届ける **主経路** がこれ。
|
|
589
|
+
* (CLI を pane 起動する `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ、
|
|
590
|
+
* injectConfigDirEnv のコマンド焼き込みが併用される)。
|
|
564
591
|
* value が null/undefined なら unset (-gu)。tmux サーバー未起動などの失敗は握り潰す。
|
|
565
592
|
*/
|
|
566
593
|
export async function setTmuxGlobalEnv(name, value, opts = {}) {
|
|
@@ -766,12 +793,21 @@ export async function killManySessions(names, opts = {}) {
|
|
|
766
793
|
}
|
|
767
794
|
|
|
768
795
|
/**
|
|
769
|
-
* 新規 session を detached
|
|
796
|
+
* 新規 session を detached で作成する。
|
|
797
|
+
*
|
|
798
|
+
* ⚠️ **既定では pane に CLI (claude / codex) を起動しない**(pane は素のシェルのまま)。
|
|
799
|
+
* Cockpit の通常運用はチャットモードで、pane の TUI は誰も見ないまま常駐して
|
|
800
|
+
* メモリを食うだけだったため (詳細は shouldLaunchPaneCli の JSDoc)。
|
|
801
|
+
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり CLI を send-keys する。
|
|
802
|
+
*
|
|
770
803
|
* - 同名 session が既にあれば 'duplicate session' throw
|
|
771
|
-
* -
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
804
|
+
* - CLAUDE.md / AGENTS.md の生成・Hub 同期、cwd 自動作成、mouse mode 設定は
|
|
805
|
+
* **CLI を起動するかどうかに関わらず**常に行う(素のシェルでも必要なため)
|
|
806
|
+
* - 以下は CLI を起動するときだけ意味を持つので、起動時のみ評価される:
|
|
807
|
+
* opts.claudeCmd / opts.codexCmd / opts.cliKind / opts.claudeConfigDir /
|
|
808
|
+
* opts.codexHome / opts.initialPrompt / model・sandbox 等の codex フラグ
|
|
809
|
+
* - opts.initialPrompt は CLI 起動時に位置引数として末尾へ shell-safe に埋め込まれ、
|
|
810
|
+
* TUI の初回プロンプトとして実行される (`/orchestrate` 等のスキル発火に利用)
|
|
775
811
|
*/
|
|
776
812
|
export async function createSession(name, cwd, opts = {}) {
|
|
777
813
|
// tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
|
|
@@ -918,6 +954,21 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
918
954
|
// display-message 失敗は致命的でないので飲み込む
|
|
919
955
|
}
|
|
920
956
|
}
|
|
957
|
+
// 既定では pane を素のシェルのまま残す (CLI は起動しない)。ここから下は
|
|
958
|
+
// HUB_AGENT_TMUX_LAUNCH_CLI=1 のときだけ走る。
|
|
959
|
+
//
|
|
960
|
+
// 素のシェルでも必要な処理 (cwd 解決・CLAUDE.md 生成/同期・mouse mode) は上で
|
|
961
|
+
// 済ませてある。CLAUDE_CONFIG_DIR / CODEX_HOME も、pane で手動起動したときに
|
|
962
|
+
// 効くよう main.mjs の syncTmuxProfileEnv / syncTmuxCodexEnv が
|
|
963
|
+
// `tmux set-environment -g` でサーバーのグローバル環境へ流し込んでいるため、
|
|
964
|
+
// ここでコマンド文字列に焼き込む必要があるのは CLI を起動する場合だけ。
|
|
965
|
+
if (!shouldLaunchPaneCli()) {
|
|
966
|
+
opts.logger?.debug?.(
|
|
967
|
+
{ session: name, cwd: resolvedCwd },
|
|
968
|
+
"pane CLI launch skipped (set HUB_AGENT_TMUX_LAUNCH_CLI=1 to enable)",
|
|
969
|
+
)
|
|
970
|
+
return
|
|
971
|
+
}
|
|
921
972
|
// cliKind に応じて claude / codex いずれの CLI を pane で起動するか分岐する。
|
|
922
973
|
// 既定は claude(後方互換)。initialPrompt はどちらも位置引数として末尾注入する
|
|
923
974
|
// (claude "<p>" / codex "<p>" / codex resume <id> "<p>" いずれも [PROMPT] を受ける)。
|