@cocorograph/hub-agent 0.8.17 → 0.8.19
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-md-sync.mjs +704 -57
- package/src/claude-md.mjs +12 -8
- package/src/live-sessions.mjs +61 -0
- package/src/main.mjs +187 -143
- package/src/pane-cli-flag.mjs +30 -0
- package/src/sibling-inject.mjs +21 -12
- package/src/state.mjs +39 -195
- package/src/tmux.mjs +46 -83
package/src/claude-md.mjs
CHANGED
|
@@ -21,8 +21,7 @@ import os from "node:os"
|
|
|
21
21
|
import path from "node:path"
|
|
22
22
|
import {
|
|
23
23
|
sha256,
|
|
24
|
-
|
|
25
|
-
writeBaseBody,
|
|
24
|
+
writeSyncState,
|
|
26
25
|
nowIso,
|
|
27
26
|
} from "./claude-md-sync.mjs"
|
|
28
27
|
|
|
@@ -381,13 +380,18 @@ export async function ensureClaudeMd({
|
|
|
381
380
|
const hubInitialized = !!hub?.claude_md_updated_at
|
|
382
381
|
if (hubInitialized && hubBody) {
|
|
383
382
|
await fs.writeFile(configMdPath, hubBody, "utf-8")
|
|
384
|
-
await writeMeta(targetDir, {
|
|
385
|
-
lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
|
|
386
|
-
lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
|
|
387
|
-
lastSyncedAt: nowIso(),
|
|
388
|
-
}, configFilename)
|
|
389
383
|
// 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
|
|
390
|
-
|
|
384
|
+
// ⚠️ base → meta の順で書くこと(writeSyncState に集約)。meta は「判定の
|
|
385
|
+
// commit marker」なので先に進めると、base の書込が失敗しても「同期済み」と
|
|
386
|
+
// 見なされ、以降の sync が通常 noop に入って base が壊れたまま固定される。
|
|
387
|
+
await writeSyncState(targetDir, {
|
|
388
|
+
body: hubBody,
|
|
389
|
+
meta: {
|
|
390
|
+
lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
|
|
391
|
+
lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
|
|
392
|
+
lastSyncedAt: nowIso(),
|
|
393
|
+
},
|
|
394
|
+
}, configFilename)
|
|
391
395
|
logger?.info?.(
|
|
392
396
|
{ dirName, configMdPath },
|
|
393
397
|
"wrote project instruction file from Hub project claude_md (initial sync)",
|
|
@@ -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
|
@@ -62,9 +62,6 @@ import { listAgents } from "./agents.mjs"
|
|
|
62
62
|
import { listSkills } from "./skills.mjs"
|
|
63
63
|
import { readSidebarMessageSnapshot } from "./sidebar-message-snapshot.mjs"
|
|
64
64
|
import {
|
|
65
|
-
capturePane,
|
|
66
|
-
detectPermissionModeFromText,
|
|
67
|
-
detectSessionState,
|
|
68
65
|
invalidateSessionCache,
|
|
69
66
|
listSessionStates,
|
|
70
67
|
StallTracker,
|
|
@@ -110,6 +107,7 @@ import {
|
|
|
110
107
|
stopTerminalMirror,
|
|
111
108
|
} from "./tmux.mjs"
|
|
112
109
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
110
|
+
import { isSessionWarm } from "./live-sessions.mjs"
|
|
113
111
|
import {
|
|
114
112
|
boundSessionId,
|
|
115
113
|
codexContextPercentFromTokenUsage,
|
|
@@ -199,14 +197,17 @@ function codexDefaultsFromAgentConfig(config) {
|
|
|
199
197
|
/**
|
|
200
198
|
* プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
|
|
201
199
|
*
|
|
202
|
-
* 再作成された pane
|
|
203
|
-
*
|
|
200
|
+
* 再作成された pane は **既定では素のシェル** (createSession が CLI を起動しない)。
|
|
201
|
+
* アカウント切替は SDK チャット側の process.env と、tmux サーバーのグローバル環境
|
|
202
|
+
* (syncTmuxProfileEnv の `set-environment -g`) で追従する。
|
|
203
|
+
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` で TUI を起動する運用の場合のみ、createSession 内の
|
|
204
|
+
* injectConfigDirEnv が新しい CLAUDE_CONFIG_DIR をコマンドへ焼き込む。
|
|
204
205
|
* initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
|
|
205
206
|
* 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
|
|
206
207
|
* cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
|
|
207
208
|
* tmux サーバー未起動なら何もしない。
|
|
208
209
|
*/
|
|
209
|
-
async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
210
|
+
export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
210
211
|
const result = { killed: [], recreated: [], failed: [] }
|
|
211
212
|
let sessions = []
|
|
212
213
|
try {
|
|
@@ -232,6 +233,10 @@ async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
|
232
233
|
}
|
|
233
234
|
try {
|
|
234
235
|
await createTmuxSession(s.name, s.cwd, {
|
|
236
|
+
// cliKind はセッション名 (`<dir>__codex` か否か) だけで決まる。渡し忘れると
|
|
237
|
+
// createSession が既定の claude として扱い、codex セッションの再生成でも
|
|
238
|
+
// AGENTS.md ではなく CLAUDE.md を同期対象にしてしまう。
|
|
239
|
+
cliKind: cliKindForSessionName(s.name),
|
|
235
240
|
claudeCmd: claudeCmdFromAgentConfig(ctx.config),
|
|
236
241
|
logger: ctx.logger,
|
|
237
242
|
})
|
|
@@ -410,9 +415,11 @@ export function applyActiveProfileEnv(profile, log) {
|
|
|
410
415
|
|
|
411
416
|
/**
|
|
412
417
|
* active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
|
|
413
|
-
* tmux pane のシェルは hub-agent の process.env
|
|
414
|
-
*
|
|
415
|
-
*
|
|
418
|
+
* tmux pane のシェルは hub-agent の process.env を見ないため、set-environment -g で流し込む。
|
|
419
|
+
* pane は既定で素のシェル (CLI を自動起動しない) なので、ユーザーが pane 内で直接
|
|
420
|
+
* `claude` を打つケースにはこれが主経路になる
|
|
421
|
+
* (`HUB_AGENT_TMUX_LAUNCH_CLI=1` で CLI を pane 起動するときだけ、createSession 内の
|
|
422
|
+
* injectConfigDirEnv によるコマンド焼き込みが併用される)。
|
|
416
423
|
* tmux サーバー未起動でも失敗しない。
|
|
417
424
|
*/
|
|
418
425
|
export async function syncTmuxProfileEnv(profile, log) {
|
|
@@ -2013,17 +2020,27 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
|
|
|
2013
2020
|
* 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
|
|
2014
2021
|
* 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
|
|
2015
2022
|
*
|
|
2023
|
+
* `warm` (2026-08-01): 「このセッションに依頼 (cockpit_orch.py send) を送れるか」。frontend は
|
|
2024
|
+
* 従来「tmux のセッション行が存在するか」だけで起動中を判定していたため、**依頼を送れない
|
|
2025
|
+
* セッションが起動中と表示される**食い違いがあった。判定は live-sessions.mjs の
|
|
2026
|
+
* `isSessionWarm` (sibling-inject の宛先候補列挙と同一関数) に一本化する。accessor 未指定
|
|
2027
|
+
* (=旧呼び出し) では `warm` を付与しない(推測で false を配らない)。
|
|
2028
|
+
*
|
|
2016
2029
|
* @param {Array} sessions tmux.list_sessions が返した素の session 行
|
|
2017
2030
|
* @param {Map<string, object>} lastEventByName session_name → 最新 hook event
|
|
2018
2031
|
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
2019
2032
|
* accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
|
|
2033
|
+
* @param {((row: {name: string, cwd?: string|null}) => boolean) | undefined} isWarm
|
|
2034
|
+
* warm 判定 accessor (既定は live-sessions.mjs の isSessionWarm を bind したもの)。
|
|
2035
|
+
* undefined のときは `warm` を付与しない
|
|
2020
2036
|
*/
|
|
2021
|
-
export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap) {
|
|
2037
|
+
export function enrichTmuxSessionsForListResponse(sessions, lastEventByName, getSnap, isWarm) {
|
|
2022
2038
|
return sessions.map((s) => {
|
|
2023
2039
|
const base = {
|
|
2024
2040
|
...s,
|
|
2025
2041
|
last_event: lastEventByName.get(s.name) || null,
|
|
2026
2042
|
}
|
|
2043
|
+
if (isWarm) base.warm = !!isWarm(s)
|
|
2027
2044
|
if (!getSnap) return base
|
|
2028
2045
|
const snap = getSnap(s.name)
|
|
2029
2046
|
if (!snap) return base
|
|
@@ -2151,7 +2168,6 @@ export async function pushImmediateChatState({
|
|
|
2151
2168
|
session_name: s.session_name,
|
|
2152
2169
|
status: "waiting",
|
|
2153
2170
|
context_pct: typeof contextPct === "number" ? contextPct : 0,
|
|
2154
|
-
permission_mode: s.permission_mode ?? null,
|
|
2155
2171
|
stable: true,
|
|
2156
2172
|
proc_busy: false,
|
|
2157
2173
|
child_busy: false,
|
|
@@ -2295,9 +2311,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2295
2311
|
// (`<dir>` = claude / `<dir>__codex` = codex)。chat 信号の読み出し・turn_active の
|
|
2296
2312
|
// 判定源・session.state に載せる cli_kind はすべてこの 1 値から導く (正本一本化)。
|
|
2297
2313
|
const sessionCliKind = cliKindForSessionName(s.session_name)
|
|
2298
|
-
// 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
|
|
2299
|
-
// tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
|
|
2300
|
-
const permissionMode = s.permission_mode ?? null
|
|
2301
2314
|
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
2302
2315
|
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
2303
2316
|
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
@@ -2517,7 +2530,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2517
2530
|
!prev ||
|
|
2518
2531
|
prev.status !== status ||
|
|
2519
2532
|
prev.context_pct !== contextPct ||
|
|
2520
|
-
prev.permission_mode !== permissionMode ||
|
|
2521
2533
|
prev.stable !== stable ||
|
|
2522
2534
|
prev.proc_busy !== procBusy ||
|
|
2523
2535
|
prev.child_busy !== childBusy ||
|
|
@@ -2529,7 +2541,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2529
2541
|
lastByName.set(s.session_name, {
|
|
2530
2542
|
status,
|
|
2531
2543
|
context_pct: contextPct,
|
|
2532
|
-
permission_mode: permissionMode,
|
|
2533
2544
|
stable,
|
|
2534
2545
|
proc_busy: procBusy,
|
|
2535
2546
|
child_busy: childBusy,
|
|
@@ -2570,7 +2581,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2570
2581
|
session_name: s.session_name,
|
|
2571
2582
|
status,
|
|
2572
2583
|
context_pct: contextPct,
|
|
2573
|
-
permission_mode: permissionMode,
|
|
2574
2584
|
stable,
|
|
2575
2585
|
proc_busy: procBusy,
|
|
2576
2586
|
// child_busy: outputActive を含まない真の子プロセス busy。frontend の shouldQueue が
|
|
@@ -3542,6 +3552,157 @@ export async function handleUsageRequest(msg, ctx, deps = {}) {
|
|
|
3542
3552
|
}
|
|
3543
3553
|
}
|
|
3544
3554
|
|
|
3555
|
+
/**
|
|
3556
|
+
* `tmux.create_session` ハンドラ。指定名・cwd で detached の tmux セッションを作る。
|
|
3557
|
+
*
|
|
3558
|
+
* ⚠️ pane に CLI (claude / codex) を起動するかは **createSession 内の
|
|
3559
|
+
* shouldLaunchPaneCli() が一元判定**する (既定は起動しない)。ここで渡す cliKind /
|
|
3560
|
+
* claudeCmd / codexCmd / initialPrompt 等は、`HUB_AGENT_TMUX_LAUNCH_CLI=1` で
|
|
3561
|
+
* CLI を起動するときにだけ使われる。呼び出し元ごとに既定を変えないため、
|
|
3562
|
+
* このハンドラ側で起動可否を判断しないこと。
|
|
3563
|
+
*
|
|
3564
|
+
* dispatch から切り出して export しているのは、実際に tmux へ渡る引数を
|
|
3565
|
+
* テストから検査できるようにするため (test/tmux-pane-cli-launch.test.mjs)。
|
|
3566
|
+
*/
|
|
3567
|
+
export async function handleTmuxCreateSession(msg, ctx) {
|
|
3568
|
+
const name = (msg.session_name || "").trim()
|
|
3569
|
+
const cwd = (msg.cwd || process.env.HOME || "/").trim()
|
|
3570
|
+
if (!name) {
|
|
3571
|
+
ctx.client.send({
|
|
3572
|
+
type: "tmux.create_session.result",
|
|
3573
|
+
request_id: msg.request_id,
|
|
3574
|
+
ok: false,
|
|
3575
|
+
error: "session_name is required",
|
|
3576
|
+
})
|
|
3577
|
+
return
|
|
3578
|
+
}
|
|
3579
|
+
try {
|
|
3580
|
+
await createTmuxSession(name, cwd, {
|
|
3581
|
+
// cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
|
|
3582
|
+
cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
|
|
3583
|
+
claudeCmd:
|
|
3584
|
+
typeof msg.claude_cmd === "string"
|
|
3585
|
+
? msg.claude_cmd
|
|
3586
|
+
: claudeCmdFromAgentConfig(ctx.config),
|
|
3587
|
+
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
3588
|
+
// codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
|
|
3589
|
+
model:
|
|
3590
|
+
typeof msg.model === "string"
|
|
3591
|
+
? msg.model
|
|
3592
|
+
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
3593
|
+
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
3594
|
+
approvalPolicy:
|
|
3595
|
+
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
3596
|
+
effort:
|
|
3597
|
+
typeof msg.effort === "string"
|
|
3598
|
+
? msg.effort
|
|
3599
|
+
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
3600
|
+
initialPrompt:
|
|
3601
|
+
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
3602
|
+
logger: ctx.logger,
|
|
3603
|
+
})
|
|
3604
|
+
tmuxSessionsSnapshotCache.clear()
|
|
3605
|
+
ctx.client.send({
|
|
3606
|
+
type: "tmux.create_session.result",
|
|
3607
|
+
request_id: msg.request_id,
|
|
3608
|
+
ok: true,
|
|
3609
|
+
session_name: name,
|
|
3610
|
+
})
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
ctx.client.send({
|
|
3613
|
+
type: "tmux.create_session.result",
|
|
3614
|
+
request_id: msg.request_id,
|
|
3615
|
+
ok: false,
|
|
3616
|
+
error: err.message,
|
|
3617
|
+
})
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
/**
|
|
3622
|
+
* `worktree.create` ハンドラ。git worktree を作ってから、その dir で tmux セッションを作る。
|
|
3623
|
+
* body: { request_id, dir, branch, cli_kind?, claude_cmd?, codex_cmd?, model?, sandbox?,
|
|
3624
|
+
* approval_policy?, effort?, initial_prompt? }
|
|
3625
|
+
*
|
|
3626
|
+
* ⚠️ pane の CLI 起動可否は handleTmuxCreateSession と同じく createSession 側の
|
|
3627
|
+
* 一元判定に従う (既定は起動しない)。
|
|
3628
|
+
*/
|
|
3629
|
+
export async function handleWorktreeCreate(msg, ctx) {
|
|
3630
|
+
const dir = (msg.dir || "").trim()
|
|
3631
|
+
const branch = (msg.branch || "").trim()
|
|
3632
|
+
if (!dir || !branch) {
|
|
3633
|
+
ctx.client.send({
|
|
3634
|
+
type: "worktree.create.result",
|
|
3635
|
+
request_id: msg.request_id,
|
|
3636
|
+
ok: false,
|
|
3637
|
+
error: "dir and branch required",
|
|
3638
|
+
})
|
|
3639
|
+
return
|
|
3640
|
+
}
|
|
3641
|
+
if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
|
|
3642
|
+
ctx.client.send({
|
|
3643
|
+
type: "worktree.create.result",
|
|
3644
|
+
request_id: msg.request_id,
|
|
3645
|
+
ok: false,
|
|
3646
|
+
error: "invalid branch name",
|
|
3647
|
+
})
|
|
3648
|
+
return
|
|
3649
|
+
}
|
|
3650
|
+
try {
|
|
3651
|
+
const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
|
|
3652
|
+
// worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
|
|
3653
|
+
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
3654
|
+
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
3655
|
+
// 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
|
|
3656
|
+
// 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
|
|
3657
|
+
const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
3658
|
+
const wtSessionName = sessionNameFor(wtName, wtCliKind)
|
|
3659
|
+
await createTmuxSession(wtSessionName, wtPath, {
|
|
3660
|
+
cliKind: wtCliKind,
|
|
3661
|
+
claudeCmd:
|
|
3662
|
+
typeof msg.claude_cmd === "string"
|
|
3663
|
+
? msg.claude_cmd
|
|
3664
|
+
: claudeCmdFromAgentConfig(ctx.config),
|
|
3665
|
+
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
3666
|
+
// codex 用設定 (claude 経路では無視される)。
|
|
3667
|
+
model:
|
|
3668
|
+
typeof msg.model === "string"
|
|
3669
|
+
? msg.model
|
|
3670
|
+
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
3671
|
+
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
3672
|
+
approvalPolicy:
|
|
3673
|
+
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
3674
|
+
effort:
|
|
3675
|
+
typeof msg.effort === "string"
|
|
3676
|
+
? msg.effort
|
|
3677
|
+
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
3678
|
+
initialPrompt:
|
|
3679
|
+
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
3680
|
+
logger: ctx.logger,
|
|
3681
|
+
})
|
|
3682
|
+
tmuxSessionsSnapshotCache.clear()
|
|
3683
|
+
ctx.client.send({
|
|
3684
|
+
type: "worktree.create.result",
|
|
3685
|
+
request_id: msg.request_id,
|
|
3686
|
+
ok: true,
|
|
3687
|
+
// wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
|
|
3688
|
+
wt_name: wtName,
|
|
3689
|
+
// session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
|
|
3690
|
+
// 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
|
|
3691
|
+
session_name: wtSessionName,
|
|
3692
|
+
wt_path: wtPath,
|
|
3693
|
+
branch,
|
|
3694
|
+
created_branch: createdBranch,
|
|
3695
|
+
})
|
|
3696
|
+
} catch (err) {
|
|
3697
|
+
ctx.client.send({
|
|
3698
|
+
type: "worktree.create.result",
|
|
3699
|
+
request_id: msg.request_id,
|
|
3700
|
+
ok: false,
|
|
3701
|
+
error: err.message,
|
|
3702
|
+
})
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3545
3706
|
async function dispatch(msg, ctx) {
|
|
3546
3707
|
const t = msg?.type || ""
|
|
3547
3708
|
try {
|
|
@@ -4889,6 +5050,13 @@ async function dispatch(msg, ctx) {
|
|
|
4889
5050
|
sessions,
|
|
4890
5051
|
lastEventByName,
|
|
4891
5052
|
ctx.getSessionStateSnapshot,
|
|
5053
|
+
// warm = このセッションへ依頼 (cockpit_orch.py send) を送れるか。sibling-inject の
|
|
5054
|
+
// 宛先候補列挙と同一関数 (live-sessions.mjs) を通す。
|
|
5055
|
+
(row) =>
|
|
5056
|
+
isSessionWarm(
|
|
5057
|
+
{ claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
|
|
5058
|
+
row,
|
|
5059
|
+
),
|
|
4892
5060
|
)
|
|
4893
5061
|
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4894
5062
|
let worktreeStubs = []
|
|
@@ -4939,135 +5107,11 @@ async function dispatch(msg, ctx) {
|
|
|
4939
5107
|
return
|
|
4940
5108
|
}
|
|
4941
5109
|
case "tmux.create_session": {
|
|
4942
|
-
|
|
4943
|
-
const cwd = (msg.cwd || process.env.HOME || "/").trim()
|
|
4944
|
-
if (!name) {
|
|
4945
|
-
ctx.client.send({
|
|
4946
|
-
type: "tmux.create_session.result",
|
|
4947
|
-
request_id: msg.request_id,
|
|
4948
|
-
ok: false,
|
|
4949
|
-
error: "session_name is required",
|
|
4950
|
-
})
|
|
4951
|
-
return
|
|
4952
|
-
}
|
|
4953
|
-
try {
|
|
4954
|
-
await createTmuxSession(name, cwd, {
|
|
4955
|
-
// cli_kind: 'codex' なら codex を、それ以外は claude を pane 起動する (既定 claude)。
|
|
4956
|
-
cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
|
|
4957
|
-
claudeCmd:
|
|
4958
|
-
typeof msg.claude_cmd === "string"
|
|
4959
|
-
? msg.claude_cmd
|
|
4960
|
-
: claudeCmdFromAgentConfig(ctx.config),
|
|
4961
|
-
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
4962
|
-
// codex 用設定 (claude 経路では claudeCmd を直接使うため無視される)。
|
|
4963
|
-
model:
|
|
4964
|
-
typeof msg.model === "string"
|
|
4965
|
-
? msg.model
|
|
4966
|
-
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
4967
|
-
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
4968
|
-
approvalPolicy:
|
|
4969
|
-
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
4970
|
-
effort:
|
|
4971
|
-
typeof msg.effort === "string"
|
|
4972
|
-
? msg.effort
|
|
4973
|
-
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
4974
|
-
initialPrompt:
|
|
4975
|
-
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4976
|
-
logger: ctx.logger,
|
|
4977
|
-
})
|
|
4978
|
-
tmuxSessionsSnapshotCache.clear()
|
|
4979
|
-
ctx.client.send({
|
|
4980
|
-
type: "tmux.create_session.result",
|
|
4981
|
-
request_id: msg.request_id,
|
|
4982
|
-
ok: true,
|
|
4983
|
-
session_name: name,
|
|
4984
|
-
})
|
|
4985
|
-
} catch (err) {
|
|
4986
|
-
ctx.client.send({
|
|
4987
|
-
type: "tmux.create_session.result",
|
|
4988
|
-
request_id: msg.request_id,
|
|
4989
|
-
ok: false,
|
|
4990
|
-
error: err.message,
|
|
4991
|
-
})
|
|
4992
|
-
}
|
|
5110
|
+
await handleTmuxCreateSession(msg, ctx)
|
|
4993
5111
|
return
|
|
4994
5112
|
}
|
|
4995
5113
|
case "worktree.create": {
|
|
4996
|
-
|
|
4997
|
-
const dir = (msg.dir || "").trim()
|
|
4998
|
-
const branch = (msg.branch || "").trim()
|
|
4999
|
-
if (!dir || !branch) {
|
|
5000
|
-
ctx.client.send({
|
|
5001
|
-
type: "worktree.create.result",
|
|
5002
|
-
request_id: msg.request_id,
|
|
5003
|
-
ok: false,
|
|
5004
|
-
error: "dir and branch required",
|
|
5005
|
-
})
|
|
5006
|
-
return
|
|
5007
|
-
}
|
|
5008
|
-
if (!/^[A-Za-z0-9._\-/]+$/.test(branch) || branch.startsWith("-")) {
|
|
5009
|
-
ctx.client.send({
|
|
5010
|
-
type: "worktree.create.result",
|
|
5011
|
-
request_id: msg.request_id,
|
|
5012
|
-
ok: false,
|
|
5013
|
-
error: "invalid branch name",
|
|
5014
|
-
})
|
|
5015
|
-
return
|
|
5016
|
-
}
|
|
5017
|
-
try {
|
|
5018
|
-
const { wtName, wtPath, createdBranch } = await createWorktreeDir(dir, branch)
|
|
5019
|
-
// worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
|
|
5020
|
-
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
5021
|
-
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
5022
|
-
// 同一 worktree でも claude と codex を独立に起動/終了できる (旧実装は cli_kind に
|
|
5023
|
-
// 関わらず `<wt>` 固定だったため、片方しか立てられなかった)。
|
|
5024
|
-
const wtCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
5025
|
-
const wtSessionName = sessionNameFor(wtName, wtCliKind)
|
|
5026
|
-
await createTmuxSession(wtSessionName, wtPath, {
|
|
5027
|
-
cliKind: wtCliKind,
|
|
5028
|
-
claudeCmd:
|
|
5029
|
-
typeof msg.claude_cmd === "string"
|
|
5030
|
-
? msg.claude_cmd
|
|
5031
|
-
: claudeCmdFromAgentConfig(ctx.config),
|
|
5032
|
-
codexCmd: typeof msg.codex_cmd === "string" ? msg.codex_cmd : undefined,
|
|
5033
|
-
// codex 用設定 (claude 経路では無視される)。
|
|
5034
|
-
model:
|
|
5035
|
-
typeof msg.model === "string"
|
|
5036
|
-
? msg.model
|
|
5037
|
-
: codexDefaultsFromAgentConfig(ctx.config).model,
|
|
5038
|
-
sandbox: typeof msg.sandbox === "string" ? msg.sandbox : undefined,
|
|
5039
|
-
approvalPolicy:
|
|
5040
|
-
typeof msg.approval_policy === "string" ? msg.approval_policy : undefined,
|
|
5041
|
-
effort:
|
|
5042
|
-
typeof msg.effort === "string"
|
|
5043
|
-
? msg.effort
|
|
5044
|
-
: codexDefaultsFromAgentConfig(ctx.config).effort,
|
|
5045
|
-
initialPrompt:
|
|
5046
|
-
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
5047
|
-
logger: ctx.logger,
|
|
5048
|
-
})
|
|
5049
|
-
tmuxSessionsSnapshotCache.clear()
|
|
5050
|
-
ctx.client.send({
|
|
5051
|
-
type: "worktree.create.result",
|
|
5052
|
-
request_id: msg.request_id,
|
|
5053
|
-
ok: true,
|
|
5054
|
-
// wt_name は従来どおり **ベース名** (dir 名)。frontend のツリー構築キーに使う。
|
|
5055
|
-
wt_name: wtName,
|
|
5056
|
-
// session_name は実際に作った tmux セッション名 (codex なら __codex 付き)。
|
|
5057
|
-
// 旧 frontend はこのフィールドを見ないため後方互換 (wt_name で選択する)。
|
|
5058
|
-
session_name: wtSessionName,
|
|
5059
|
-
wt_path: wtPath,
|
|
5060
|
-
branch,
|
|
5061
|
-
created_branch: createdBranch,
|
|
5062
|
-
})
|
|
5063
|
-
} catch (err) {
|
|
5064
|
-
ctx.client.send({
|
|
5065
|
-
type: "worktree.create.result",
|
|
5066
|
-
request_id: msg.request_id,
|
|
5067
|
-
ok: false,
|
|
5068
|
-
error: err.message,
|
|
5069
|
-
})
|
|
5070
|
-
}
|
|
5114
|
+
await handleWorktreeCreate(msg, ctx)
|
|
5071
5115
|
return
|
|
5072
5116
|
}
|
|
5073
5117
|
case "worktree.list_history": {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「tmux pane に CLI (claude / codex) の TUI を起動しているか」の単一判定。
|
|
3
|
+
*
|
|
4
|
+
* この 1 フラグが tmux.mjs (pane で TUI を起動するか) と state.mjs (pane を
|
|
5
|
+
* capture-pane で読む意味があるか) の両方の分岐条件になるため、どちらからも
|
|
6
|
+
* import できる依存の無い最小モジュールに切り出してある。tmux.mjs 側に置いたまま
|
|
7
|
+
* state.mjs から import すると tmux.mjs → state.mjs → tmux.mjs の循環になる。
|
|
8
|
+
*
|
|
9
|
+
* 後方互換のため `tmux.mjs` からも re-export している (既存の import 先を壊さない)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
|
|
14
|
+
*
|
|
15
|
+
* **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
|
|
16
|
+
* 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
|
|
17
|
+
* app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
|
|
18
|
+
* **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
|
|
19
|
+
* 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
|
|
20
|
+
*
|
|
21
|
+
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
|
|
22
|
+
* 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
|
|
23
|
+
* この関数 1 箇所に集約し、opts では上書きさせない。
|
|
24
|
+
*
|
|
25
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function shouldLaunchPaneCli(env = process.env) {
|
|
29
|
+
return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
|
|
30
|
+
}
|
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 本も無い。` +
|