@cocorograph/hub-agent 0.8.76 → 0.8.77
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/async-ttl-cache.mjs +25 -11
- package/src/chat-removal-guard.mjs +62 -0
- package/src/chat-session-starter.mjs +90 -0
- package/src/chat-sessions-snapshot.mjs +9 -0
- package/src/chat-targets.mjs +60 -0
- package/src/chat-workspace.mjs +279 -0
- package/src/claude-stream-bridge.mjs +11 -5
- package/src/codex-stream-bridge.mjs +21 -1
- package/src/dispatch-scheduler.mjs +45 -5
- package/src/main.mjs +271 -17
- package/src/sibling-inject.mjs +40 -3
- package/src/tmux.mjs +53 -174
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import { EventEmitter } from "node:events"
|
|
44
44
|
import { randomUUID } from "node:crypto"
|
|
45
45
|
import path from "node:path"
|
|
46
|
+
import { isUnderRemoval } from "./chat-removal-guard.mjs"
|
|
46
47
|
|
|
47
48
|
import { lookupInjectSource, recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
48
49
|
|
|
@@ -800,6 +801,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
800
801
|
if (this.sessions.has(stream_id)) {
|
|
801
802
|
throw new Error(`stream_id already attached: ${stream_id}`)
|
|
802
803
|
}
|
|
804
|
+
if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
|
|
803
805
|
// 計測 (2026-07-31): attach 1 回 = 1 行の内訳ログ (`codex attach timing`)。
|
|
804
806
|
// frontend の attach_sent→ready_received は 1 つの数字でしか見えず、その中身が
|
|
805
807
|
// 「app-server の cold spawn + initialize」なのか「thread/resume の rollout 読み込み」なのか、
|
|
@@ -923,6 +925,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
923
925
|
}
|
|
924
926
|
}
|
|
925
927
|
|
|
928
|
+
// ⚠️ attach の入口でも同じ検査をしているが、そこから **_tryReattach の await** を挟んで
|
|
929
|
+
// ここへ来る(既存 session の thread 確定待ち)。その間に削除が始まっていることがあるので、
|
|
930
|
+
// プロセスを起こす直前にもう一度見る。ここを外すと「停止対象を列挙し終えたあとに新しい
|
|
931
|
+
// プロセスが生まれる」経路が残り、削除された cwd を指す会話ができる。
|
|
932
|
+
if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
|
|
926
933
|
const client = this._clientFactory({
|
|
927
934
|
cwd,
|
|
928
935
|
codexCmd: this.codexCmd,
|
|
@@ -1974,6 +1981,14 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1974
1981
|
return addCodexSessionTitles(sessions, sessionsDir)
|
|
1975
1982
|
}
|
|
1976
1983
|
|
|
1984
|
+
/** 指定 stream の live 会話を停止する。履歴ファイルは削除しない。 */
|
|
1985
|
+
stopSessionForStream({ stream_id, reason }) {
|
|
1986
|
+
const session = this.sessions.get(stream_id)
|
|
1987
|
+
if (!session || session.dead) return 0
|
|
1988
|
+
this._reapSession(session, Date.now(), reason || "explicit-stop")
|
|
1989
|
+
return 1
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1977
1992
|
async operateSession(options) {
|
|
1978
1993
|
const invalid = validateCodexSessionOperation(options)
|
|
1979
1994
|
if (invalid) return { ok: false, error: invalid }
|
|
@@ -4730,7 +4745,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4730
4745
|
producer_seq: session.seq,
|
|
4731
4746
|
thread_id: session.threadId,
|
|
4732
4747
|
code: 0,
|
|
4733
|
-
|
|
4748
|
+
// ⚠️ ここは「明示的に止めた理由」の許可リスト。載っていない理由はすべて "idle-reaped"
|
|
4749
|
+
// へ丸められ、利用者には「放置されて片付いた」と通知される。明示停止を足したら必ず
|
|
4750
|
+
// ここへも足すこと(足し忘れても停止自体は動くので、通知だけが静かに嘘になる)。
|
|
4751
|
+
reason: ["session-deleted", "chat-stop", "chat-stop-all", "worktree-remove"].includes(reason)
|
|
4752
|
+
? reason
|
|
4753
|
+
: "idle-reaped",
|
|
4734
4754
|
})
|
|
4735
4755
|
}
|
|
4736
4756
|
|
|
@@ -83,8 +83,14 @@ function globalMutationMode(type) {
|
|
|
83
83
|
return null
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// ⚠️ cwd を省略した attach は、ハンドラ側で config.claude_cwd に補われ、さらに claude では
|
|
87
|
+
// bridge が HOME へ補完して「その cwd の会話」になる (main.mjs の claude.attach / codex.attach
|
|
88
|
+
// と claude-stream-bridge の attach)。レーン分類がその補完を知らないと、同じ会話への attach が
|
|
89
|
+
// stream レーンへ、個別停止 (cwd を持つ) が cwd レーンへ散り、直列化されないまま同時に走る。
|
|
90
|
+
// scheduler へ設定も HOME も読ませず、補完後の既定 cwd を呼び出し側から mode 別に渡してもらう
|
|
91
|
+
// (補完の規則は CLI 種別で違うため、mode を知らない scheduler 側では決められない)。
|
|
92
|
+
function attachLaneKey(mode, msg, defaultCwd = "") {
|
|
93
|
+
const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || defaultCwd
|
|
88
94
|
if (cwd) return `${mode}:cwd:${cwd}`
|
|
89
95
|
const resumeId =
|
|
90
96
|
mode === "claude"
|
|
@@ -100,14 +106,18 @@ function attachLaneKey(mode, msg) {
|
|
|
100
106
|
* run: (msg: object) => Promise<void>|void,
|
|
101
107
|
* logger?: {debug?: Function, warn?: Function, error?: Function},
|
|
102
108
|
* shouldBypass?: (type: string|undefined) => boolean,
|
|
109
|
+
* defaultCwd?: (mode: "claude"|"codex") => string,
|
|
103
110
|
* }} options
|
|
104
111
|
*/
|
|
105
112
|
export class DispatchScheduler {
|
|
106
|
-
constructor({ run, logger, shouldBypass }) {
|
|
113
|
+
constructor({ run, logger, shouldBypass, defaultCwd }) {
|
|
107
114
|
if (typeof run !== "function") throw new TypeError("DispatchScheduler run is required")
|
|
108
115
|
this.run = run
|
|
109
116
|
this.logger = logger
|
|
110
117
|
this.shouldBypass = typeof shouldBypass === "function" ? shouldBypass : () => false
|
|
118
|
+
// cwd 省略時にハンドラ/bridge が使う既定 cwd を CLI 種別ごとに返す関数。展開済みの値を
|
|
119
|
+
// 返すこと (scheduler は `~` 展開も HOME 参照もしない)。
|
|
120
|
+
this.defaultCwd = typeof defaultCwd === "function" ? defaultCwd : () => ""
|
|
111
121
|
this.generation = 0
|
|
112
122
|
this.sequence = 0
|
|
113
123
|
this.laneTails = new Map()
|
|
@@ -154,6 +164,21 @@ export class DispatchScheduler {
|
|
|
154
164
|
}
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
/**
|
|
168
|
+
* cwd 省略時にハンドラ/bridge が使う既定 cwd を、CLI 種別ごとに引く。
|
|
169
|
+
* ⚠️ レーン分類は全メッセージが通る経路なので、設定の読み取りが失敗しても dispatch を
|
|
170
|
+
* 止めない。取れなければ省略時の従来分類 (stream / control) へ戻す。
|
|
171
|
+
* @param {"claude"|"codex"} mode
|
|
172
|
+
*/
|
|
173
|
+
_resolveDefaultCwd(mode) {
|
|
174
|
+
try {
|
|
175
|
+
const cwd = this.defaultCwd(mode)
|
|
176
|
+
return typeof cwd === "string" ? cwd.trim() : ""
|
|
177
|
+
} catch {
|
|
178
|
+
return ""
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
157
182
|
_laneFor(msg) {
|
|
158
183
|
const type = msg?.type
|
|
159
184
|
const mutationMode = globalMutationMode(type)
|
|
@@ -163,20 +188,35 @@ export class DispatchScheduler {
|
|
|
163
188
|
// frontend の 10 秒打ち切りで捨てられていたため分離する (2026-09-04 本番計測)。
|
|
164
189
|
if (type === "usage.request") return "usage"
|
|
165
190
|
|
|
191
|
+
if (type === "chat.session.stop") {
|
|
192
|
+
if (msg.cli_kind === "claude") {
|
|
193
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd.trim() : ""
|
|
194
|
+
return cwd ? `claude:cwd:${cwd}` : "control"
|
|
195
|
+
}
|
|
196
|
+
if (msg.cli_kind === "codex" && msg.stream_id) {
|
|
197
|
+
return this.streamLaneKeys.get("codex:" + msg.stream_id) || `codex:stream:${msg.stream_id}`
|
|
198
|
+
}
|
|
199
|
+
return "control"
|
|
200
|
+
}
|
|
201
|
+
// chat.stop_all は全レーンに跨るため control に置く。その時点で live な会話だけを
|
|
202
|
+
// 止める契約であり、接続処理中のまだ live でない会話は対象外。sessions.request も control。
|
|
166
203
|
const mode = chatMode(type)
|
|
167
204
|
if (!mode) return "control"
|
|
168
205
|
|
|
169
206
|
const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
|
|
170
207
|
const streamKey = streamId ? `${mode}:${streamId}` : ""
|
|
208
|
+
const fallbackCwd = this._resolveDefaultCwd(mode)
|
|
171
209
|
if (type === `${mode}.attach`) {
|
|
172
|
-
const laneKey = attachLaneKey(mode, msg)
|
|
210
|
+
const laneKey = attachLaneKey(mode, msg, fallbackCwd)
|
|
173
211
|
this._rememberStreamLane(streamId, laneKey)
|
|
174
212
|
return laneKey
|
|
175
213
|
}
|
|
176
214
|
if (streamKey && this.streamLaneKeys.has(streamKey)) {
|
|
177
215
|
return this.streamLaneKeys.get(streamKey)
|
|
178
216
|
}
|
|
179
|
-
|
|
217
|
+
// attach と同じ補完を使う。片方だけ既定 cwd を見ると、同じ会話宛の upload / history 等が
|
|
218
|
+
// attach とは別レーンへ散り、attach で直したはずの取り違えが型ごと再発する。
|
|
219
|
+
const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || fallbackCwd
|
|
180
220
|
if (cwd) return `${mode}:cwd:${cwd}`
|
|
181
221
|
if (streamId) return `${mode}:stream:${streamId}`
|
|
182
222
|
// codex.profile.list 等の stream 非依存 read は main 側の read-only bypass
|
package/src/main.mjs
CHANGED
|
@@ -115,8 +115,13 @@ import {
|
|
|
115
115
|
shouldLaunchPaneCli,
|
|
116
116
|
validateInitialPrompt,
|
|
117
117
|
} from "./tmux.mjs"
|
|
118
|
+
import { expandTilde, prepareChatWorkspace, isCwdUnavailableError } from "./chat-workspace.mjs"
|
|
119
|
+
import { buildChatSessionsSnapshot } from "./chat-sessions-snapshot.mjs"
|
|
120
|
+
import { chatTargetsCache } from "./chat-targets.mjs"
|
|
121
|
+
import { ensureChatSession } from "./chat-session-starter.mjs"
|
|
122
|
+
import { beginRemoval } from "./chat-removal-guard.mjs"
|
|
118
123
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
119
|
-
import { isSessionWarm } from "./live-sessions.mjs"
|
|
124
|
+
import { describeLiveSessionsUnion, isSessionWarm } from "./live-sessions.mjs"
|
|
120
125
|
import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
|
|
121
126
|
import {
|
|
122
127
|
boundSessionId,
|
|
@@ -178,6 +183,26 @@ const BUNDLE_MANIFEST_PATH =
|
|
|
178
183
|
process.env.HUB_BUNDLE_MANIFEST ||
|
|
179
184
|
path.join(os.homedir(), ".claude", "scripts", "manifest.json")
|
|
180
185
|
|
|
186
|
+
/** 通常接続と自動起動で、現在の Claude 設定を共通に解決する。 */
|
|
187
|
+
export function resolveClaudeAttachOptions(config, msg = {}) {
|
|
188
|
+
return {
|
|
189
|
+
model: msg.model || config?.claude_model || null,
|
|
190
|
+
permissionMode:
|
|
191
|
+
msg.permission_mode ||
|
|
192
|
+
config?.claude_permission_mode ||
|
|
193
|
+
null,
|
|
194
|
+
// Phase B: チャット SDK に効くオプション (session 単位 override)。
|
|
195
|
+
maxTurns: typeof msg.max_turns === "number" ? msg.max_turns : null,
|
|
196
|
+
maxThinkingTokens:
|
|
197
|
+
typeof msg.max_thinking_tokens === "number"
|
|
198
|
+
? msg.max_thinking_tokens
|
|
199
|
+
: null,
|
|
200
|
+
// effort (Opus 4.6+): browser がチャット既定 (agent 設定由来) を送る。
|
|
201
|
+
// 空文字/未指定なら null (SDK / Claude Code 既定 = Opus は high)。
|
|
202
|
+
effort: msg.effort || config?.claude_effort || null,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
181
206
|
/**
|
|
182
207
|
* `~/.hub/agent.json` の Claude CLI 設定から claude コマンド文字列を組み立てる。
|
|
183
208
|
*
|
|
@@ -414,6 +439,25 @@ export function isFastPathMessage(type) {
|
|
|
414
439
|
* codex.sessions.request は rollout の disk 走査のみで app-server を spawn しない。
|
|
415
440
|
* CodexStreamBridge.listThreads 側の single-flight が同一走査の重複 I/O を畳む。
|
|
416
441
|
*/
|
|
442
|
+
/**
|
|
443
|
+
* cwd を省略したメッセージがレーン分類で使う既定 cwd を、CLI 種別ごとに返す。
|
|
444
|
+
*
|
|
445
|
+
* ⚠️ ここが返す値は、ハンドラと bridge が実際に行う補完と **同じ結果**でなければならない。
|
|
446
|
+
* ずれると、同じ会話への attach が stream レーン・その会話の個別停止が cwd レーンへ散り、
|
|
447
|
+
* 直列化されないまま同時に走る(停止が 0 件で先に終わった後に会話が起き上がる)。
|
|
448
|
+
* claude … handler が `msg.cwd || config.claude_cwd`、bridge がさらに
|
|
449
|
+
* `|| process.env.HOME || process.cwd()` まで補う(claude-stream-bridge の attach)
|
|
450
|
+
* codex … handler の `msg.cwd || config.claude_cwd` まで。bridge は補完せず cwd 未設定の
|
|
451
|
+
* まま session を持つので、ここで HOME へ寄せない(寄せると誰も居ない cwd の
|
|
452
|
+
* レーンへ codex の会話を集めてしまう。codex の個別停止は stream_id の記録済み
|
|
453
|
+
* レーンを使うので、そもそも cwd に依存しない)
|
|
454
|
+
*/
|
|
455
|
+
export function defaultLaneCwd(config, mode) {
|
|
456
|
+
const configured = expandTilde(config?.claude_cwd || "") || ""
|
|
457
|
+
if (configured || mode !== "claude") return configured
|
|
458
|
+
return process.env.HOME || process.cwd()
|
|
459
|
+
}
|
|
460
|
+
|
|
417
461
|
export function isReadOnlyRpc(type) {
|
|
418
462
|
return (
|
|
419
463
|
type === "claude.history.request" ||
|
|
@@ -423,6 +467,10 @@ export function isReadOnlyRpc(type) {
|
|
|
423
467
|
type === "profile.list" ||
|
|
424
468
|
type === "codex.profile.list" ||
|
|
425
469
|
type === "chat.sidebar_snapshot.request" ||
|
|
470
|
+
// 登録先の走査とライブ集合の読み取りだけで pane・入力・bridge の状態を変えず、
|
|
471
|
+
// request_id で相関するため WS 受信順に依存しない。直列レーンでは前方の重い
|
|
472
|
+
// attach spawn 等によりサイドバー更新が数十秒遅れるため、即時実行する。
|
|
473
|
+
type === "chat.sessions.request" ||
|
|
426
474
|
// T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
|
|
427
475
|
// かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
|
|
428
476
|
// (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
|
|
@@ -1312,6 +1360,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
1312
1360
|
// fast-path (interrupt) / read-only RPC は従来どおり即時実行。接続 lifecycle は
|
|
1313
1361
|
// scheduler 自身が常に priority 扱いする。
|
|
1314
1362
|
shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
|
|
1363
|
+
// cwd 省略時にハンドラ/bridge が補う cwd を、レーン分類にも同じ結果で見せる
|
|
1364
|
+
// (規則と根拠は defaultLaneCwd 側にまとめてある)。scheduler には設定も path 解決も
|
|
1365
|
+
// 持たせず、解決済みの値だけを渡す。
|
|
1366
|
+
defaultCwd: (mode) => defaultLaneCwd(ctx.config, mode),
|
|
1315
1367
|
})
|
|
1316
1368
|
client.on("close", () => dispatchScheduler.advanceGeneration("ws_close"))
|
|
1317
1369
|
client.on("open", () => dispatchScheduler.advanceGeneration("ws_open"))
|
|
@@ -1390,6 +1442,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
1390
1442
|
claudeBridge,
|
|
1391
1443
|
codexBridge,
|
|
1392
1444
|
logger,
|
|
1445
|
+
starter: ({ cwd, cliKind }) => ensureChatSession({
|
|
1446
|
+
cwd, cliKind, bridges: { claudeBridge, codexBridge }, logger,
|
|
1447
|
+
resolveClaudeOptions: () => resolveClaudeAttachOptions(ctx.config),
|
|
1448
|
+
}),
|
|
1393
1449
|
onRestartRequested: () => restartCoordinator.request(),
|
|
1394
1450
|
isInputBlocked: () => lifecycle.restartPending || lifecycle.shutdownStarted,
|
|
1395
1451
|
})
|
|
@@ -4041,6 +4097,10 @@ export async function handleWorktreeCreate(msg, ctx) {
|
|
|
4041
4097
|
tmuxOptions.codexCmd = tmuxOptions.codexCmd ?? buildCodexCmd(tmuxOptions)
|
|
4042
4098
|
}
|
|
4043
4099
|
const { wtName, wtPath, createdBranch } = await worktreeCreate(dir, branch)
|
|
4100
|
+
// ⚠️ 宛先一覧の無効化を tmux 作成の成否に依存させない。この一覧の正本はローカルの
|
|
4101
|
+
// ディレクトリ実体なので、worktree ができた時点で既に古い。後続の tmux 作成が失敗しても
|
|
4102
|
+
// worktree dir は残るため、ここで捨てないと残り TTL のあいだ実在する宛先が一覧から欠ける。
|
|
4103
|
+
chatTargetsCache.clear()
|
|
4044
4104
|
// worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
|
|
4045
4105
|
// 解釈し、codex なら codex を pane 起動する(既定 claude)。
|
|
4046
4106
|
// T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
|
|
@@ -4072,6 +4132,73 @@ export async function handleWorktreeCreate(msg, ctx) {
|
|
|
4072
4132
|
}
|
|
4073
4133
|
}
|
|
4074
4134
|
|
|
4135
|
+
/**
|
|
4136
|
+
* 指定ディレクトリ配下を cwd とする会話 (claude / codex) すべてに対して停止 API を呼び、
|
|
4137
|
+
* **呼んだ本数**を返す。
|
|
4138
|
+
*
|
|
4139
|
+
* ⚠️ worktree 削除の前段専用。会話の実体が tmux から離れたので、削除前に止めるのは tmux の
|
|
4140
|
+
* セッションではなく会話そのものになる (ユーザー決定「会話を止めてから削除」)。
|
|
4141
|
+
*
|
|
4142
|
+
* ⚠️ **保証するのは「対象の停止 API を呼んだこと」だけ**で、下の 2 つは保証しない
|
|
4143
|
+
* (2026-09-16 判断)。どちらも現状の bridge では確かめる手段が無く、作るには client 管理の
|
|
4144
|
+
* 作り替えが要る:
|
|
4145
|
+
* 1. **停止が成功したこと。** bridge は内部の停止失敗を握り潰すので、呼び出し側からは失敗が
|
|
4146
|
+
* 見えない (claude-stream-bridge.mjs の `s.close()` を囲む `catch {}` /
|
|
4147
|
+
* codex-stream-bridge.mjs の `codex reap: stop threw` warn)。
|
|
4148
|
+
* 2. **プロセスが終了したこと。** 終了は待たない (根拠は tmux.mjs の removeWorktree 参照)。
|
|
4149
|
+
* したがって削除を中止するのは **呼び出しが外へ例外を投げたとき**だけで、実際にそうなるのは
|
|
4150
|
+
* bridge の不在・API 呼び出し不能といった構成上の失敗に限られる。
|
|
4151
|
+
*
|
|
4152
|
+
* ⚠️ 戻り値は「停止 API を呼んだ本数」であって「止まった本数」ではない。**成功件数として
|
|
4153
|
+
* 利用者へ見せないこと。**
|
|
4154
|
+
* ⚠️ 生存判定は `describeLiveSessionsUnion` に一本化する。ここで別実装を書くと、表示・配送と
|
|
4155
|
+
* 停止対象が無言でずれる (live-sessions.mjs の不変条件)。
|
|
4156
|
+
* ⚠️ 比較はパス境界を見る。`startsWith(base)` だけだと `<wt>` と `<wt>-backup` を取り違えて、
|
|
4157
|
+
* 消していない worktree の会話まで止めてしまう。
|
|
4158
|
+
*
|
|
4159
|
+
* @param {string} dir 削除対象の worktree パス
|
|
4160
|
+
* @param {{claudeBridge?: object, codexBridge?: object}} ctx
|
|
4161
|
+
* @param {string} [reason] exit に載る停止理由 (既定の "explicit-stop" 等と区別するため明示する)
|
|
4162
|
+
* @returns {Promise<number>} 停止 API を呼んだ本数 (止まった本数ではない)
|
|
4163
|
+
*/
|
|
4164
|
+
export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove") {
|
|
4165
|
+
const base = path.resolve(dir)
|
|
4166
|
+
const isUnder = (cwd) => {
|
|
4167
|
+
if (typeof cwd !== "string" || !cwd) return false
|
|
4168
|
+
const resolved = path.resolve(cwd)
|
|
4169
|
+
return resolved === base || resolved.startsWith(base + path.sep)
|
|
4170
|
+
}
|
|
4171
|
+
const live = describeLiveSessionsUnion({
|
|
4172
|
+
claudeBridge: ctx.claudeBridge,
|
|
4173
|
+
codexBridge: ctx.codexBridge,
|
|
4174
|
+
})
|
|
4175
|
+
const claudeCwds = new Set()
|
|
4176
|
+
const codexStreams = new Set()
|
|
4177
|
+
let stopped = 0
|
|
4178
|
+
// ⚠️ 例外を捕まえない。投げれば removeWorktree がその場で中断し、dir は残る (= 削除しない)。
|
|
4179
|
+
// 「失敗した分を集めて最後にまとめて投げる」形も書けるが、削除しないのであれば残りを止める
|
|
4180
|
+
// 必要はなく、「削除していないのに会話だけ止まっている」という意外な結果を作るだけだった。
|
|
4181
|
+
// ⚠️ ここで捕まるのは **呼び出しそのものが失敗したとき**だけ (bridge の不在・API 呼び出し
|
|
4182
|
+
// 不能といった構成上の失敗)。停止 API の中で起きた失敗は握り潰されて届かないので、
|
|
4183
|
+
// 「止まったこと」はここでは確かめられない。保証の範囲は JSDoc に書いたとおり。
|
|
4184
|
+
for (const session of live) {
|
|
4185
|
+
if (!isUnder(session?.cwd)) continue
|
|
4186
|
+
if (session.kind === "claude") {
|
|
4187
|
+
if (claudeCwds.has(session.cwd)) continue
|
|
4188
|
+
claudeCwds.add(session.cwd)
|
|
4189
|
+
stopped += await ctx.claudeBridge.stopSessionsFor({ cwd: session.cwd, reason })
|
|
4190
|
+
} else if (session.kind === "codex") {
|
|
4191
|
+
if (codexStreams.has(session.stream_id)) continue
|
|
4192
|
+
codexStreams.add(session.stream_id)
|
|
4193
|
+
stopped += await ctx.codexBridge.stopSessionForStream({
|
|
4194
|
+
stream_id: session.stream_id,
|
|
4195
|
+
reason,
|
|
4196
|
+
})
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
return stopped
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4075
4202
|
/**
|
|
4076
4203
|
* DispatchScheduler から呼ばれる本番メッセージ入口。
|
|
4077
4204
|
* export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
|
|
@@ -4146,6 +4273,25 @@ export async function dispatch(msg, ctx) {
|
|
|
4146
4273
|
// (将来 cockpit から worktree path を指定する用)。
|
|
4147
4274
|
// model / permissionMode は agent.json の値も使えるよう fallback。
|
|
4148
4275
|
const cwdArg = msg.cwd || ctx.config?.claude_cwd || undefined
|
|
4276
|
+
try {
|
|
4277
|
+
await prepareChatWorkspace({
|
|
4278
|
+
waitForSync: false,
|
|
4279
|
+
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
4280
|
+
cliKind: "claude",
|
|
4281
|
+
logger: ctx.logger,
|
|
4282
|
+
})
|
|
4283
|
+
} catch (err) {
|
|
4284
|
+
// Claude は attach で会話を登録するだけで、プロセス起動前に ready を返す。
|
|
4285
|
+
// cwd が無くても live・配送対象に載るため、起動時の失敗に頼らず登録前に止める。
|
|
4286
|
+
if (isCwdUnavailableError(err)) {
|
|
4287
|
+
ctx.client.send({ type: "claude.error", stream_id, error: `workspace_missing: ${err.message}` })
|
|
4288
|
+
return
|
|
4289
|
+
}
|
|
4290
|
+
ctx.logger?.warn?.(
|
|
4291
|
+
{ cwd: cwdArg, err: err?.message || String(err) },
|
|
4292
|
+
"claude.attach workspace preparation failed (attach continues)",
|
|
4293
|
+
)
|
|
4294
|
+
}
|
|
4149
4295
|
const resumeSessionId = msg.resume_session_id || null
|
|
4150
4296
|
// attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
|
|
4151
4297
|
// 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
|
|
@@ -4162,20 +4308,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4162
4308
|
const info = ctx.claudeBridge.attach({
|
|
4163
4309
|
stream_id,
|
|
4164
4310
|
cwd: cwdArg,
|
|
4165
|
-
|
|
4166
|
-
permissionMode:
|
|
4167
|
-
msg.permission_mode ||
|
|
4168
|
-
ctx.config?.claude_permission_mode ||
|
|
4169
|
-
null,
|
|
4170
|
-
// Phase B: チャット SDK に効くオプション (session 単位 override)。
|
|
4171
|
-
maxTurns: typeof msg.max_turns === "number" ? msg.max_turns : null,
|
|
4172
|
-
maxThinkingTokens:
|
|
4173
|
-
typeof msg.max_thinking_tokens === "number"
|
|
4174
|
-
? msg.max_thinking_tokens
|
|
4175
|
-
: null,
|
|
4176
|
-
// effort (Opus 4.6+): browser がチャット既定 (agent 設定由来) を送る。
|
|
4177
|
-
// 空文字/未指定なら null (SDK / Claude Code 既定 = Opus は high)。
|
|
4178
|
-
effort: msg.effort || ctx.config?.claude_effort || null,
|
|
4311
|
+
...resolveClaudeAttachOptions(ctx.config, msg),
|
|
4179
4312
|
resumeSessionId,
|
|
4180
4313
|
})
|
|
4181
4314
|
ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
@@ -4605,6 +4738,24 @@ export async function dispatch(msg, ctx) {
|
|
|
4605
4738
|
}
|
|
4606
4739
|
try {
|
|
4607
4740
|
const resolvedCwd = msg.cwd || ctx.config?.claude_cwd || undefined
|
|
4741
|
+
try {
|
|
4742
|
+
await prepareChatWorkspace({
|
|
4743
|
+
waitForSync: false,
|
|
4744
|
+
cwd: resolvedCwd || process.env.HOME || process.cwd(),
|
|
4745
|
+
cliKind: "codex",
|
|
4746
|
+
logger: ctx.logger,
|
|
4747
|
+
})
|
|
4748
|
+
} catch (err) {
|
|
4749
|
+
// Claude と同じく、cwd の利用不能が確定したら会話の登録前に止める。
|
|
4750
|
+
if (isCwdUnavailableError(err)) {
|
|
4751
|
+
ctx.client.send({ type: "codex.error", stream_id, error: `workspace_missing: ${err.message}` })
|
|
4752
|
+
return
|
|
4753
|
+
}
|
|
4754
|
+
ctx.logger?.warn?.(
|
|
4755
|
+
{ cwd: resolvedCwd, err: err?.message || String(err) },
|
|
4756
|
+
"codex.attach workspace preparation failed (attach continues)",
|
|
4757
|
+
)
|
|
4758
|
+
}
|
|
4608
4759
|
const resumeThreadId = msg.resume_thread_id || null
|
|
4609
4760
|
const info = await ctx.codexBridge.attach({
|
|
4610
4761
|
stream_id,
|
|
@@ -5430,6 +5581,82 @@ export async function dispatch(msg, ctx) {
|
|
|
5430
5581
|
}
|
|
5431
5582
|
return
|
|
5432
5583
|
}
|
|
5584
|
+
case "chat.session.stop": {
|
|
5585
|
+
const result = { type: "chat.session.stop.result", request_id: msg.request_id, stopped: 0 }
|
|
5586
|
+
try {
|
|
5587
|
+
if (msg.cli_kind === "claude") {
|
|
5588
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
5589
|
+
const session_id = typeof msg.session_id === "string" ? msg.session_id : ""
|
|
5590
|
+
if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
|
|
5591
|
+
else if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(session_id)) {
|
|
5592
|
+
result.error = "invalid session_id"
|
|
5593
|
+
} else {
|
|
5594
|
+
result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
|
|
5595
|
+
}
|
|
5596
|
+
} else if (msg.cli_kind === "codex") {
|
|
5597
|
+
if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
|
|
5598
|
+
else result.stopped = await ctx.codexBridge.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" })
|
|
5599
|
+
} else {
|
|
5600
|
+
result.error = "unsupported cli_kind"
|
|
5601
|
+
}
|
|
5602
|
+
} catch (err) {
|
|
5603
|
+
result.error = err?.message || String(err)
|
|
5604
|
+
}
|
|
5605
|
+
ctx.client.send(result)
|
|
5606
|
+
return
|
|
5607
|
+
}
|
|
5608
|
+
case "chat.stop_all": {
|
|
5609
|
+
// exit に載る停止理由。bridge 側の既定 ("tui-bind-stop" / "explicit-stop") は
|
|
5610
|
+
// それぞれ TUI 切替・個別停止の名前なので、全停止はここで明示して区別する。
|
|
5611
|
+
const STOP_ALL_REASON = "chat-stop-all"
|
|
5612
|
+
const result = { type: "chat.stop_all.result", request_id: msg.request_id, stopped: 0, failed: [] }
|
|
5613
|
+
try {
|
|
5614
|
+
const live = describeLiveSessionsUnion({ claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge })
|
|
5615
|
+
const claudeCwds = new Set()
|
|
5616
|
+
const codexStreams = new Set()
|
|
5617
|
+
for (const session of live) {
|
|
5618
|
+
let target
|
|
5619
|
+
try {
|
|
5620
|
+
if (session.kind === "claude") {
|
|
5621
|
+
const cwd = session.cwd
|
|
5622
|
+
if (claudeCwds.has(cwd)) continue
|
|
5623
|
+
claudeCwds.add(cwd)
|
|
5624
|
+
// ⚠️ reason を省くと stopSessionsFor の既定 "tui-bind-stop" が exit に載り、
|
|
5625
|
+
// 画面から止めた会話が「tmux へ切り替えたせい」で消えたように見える。
|
|
5626
|
+
target = { cwd, reason: STOP_ALL_REASON }
|
|
5627
|
+
result.stopped += await ctx.claudeBridge.stopSessionsFor(target)
|
|
5628
|
+
} else if (session.kind === "codex") {
|
|
5629
|
+
const stream_id = session.stream_id
|
|
5630
|
+
if (codexStreams.has(stream_id)) continue
|
|
5631
|
+
codexStreams.add(stream_id)
|
|
5632
|
+
target = { stream_id, reason: STOP_ALL_REASON }
|
|
5633
|
+
result.stopped += await ctx.codexBridge.stopSessionForStream(target)
|
|
5634
|
+
}
|
|
5635
|
+
} catch (err) {
|
|
5636
|
+
result.failed.push({ ...target, reason: err?.message || String(err) })
|
|
5637
|
+
}
|
|
5638
|
+
}
|
|
5639
|
+
} catch (err) {
|
|
5640
|
+
result.error = err?.message || String(err)
|
|
5641
|
+
}
|
|
5642
|
+
ctx.client.send(result)
|
|
5643
|
+
return
|
|
5644
|
+
}
|
|
5645
|
+
case "chat.sessions.request": {
|
|
5646
|
+
try {
|
|
5647
|
+
const snapshot = await buildChatSessionsSnapshot({
|
|
5648
|
+
bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
|
|
5649
|
+
logger: ctx.logger,
|
|
5650
|
+
})
|
|
5651
|
+
ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...snapshot })
|
|
5652
|
+
} catch (err) {
|
|
5653
|
+
ctx.client.send({
|
|
5654
|
+
type: "chat.sessions", request_id: msg.request_id, targets: [], live: [],
|
|
5655
|
+
error: err?.message || String(err),
|
|
5656
|
+
})
|
|
5657
|
+
}
|
|
5658
|
+
return
|
|
5659
|
+
}
|
|
5433
5660
|
case "tmux.list_sessions": {
|
|
5434
5661
|
try {
|
|
5435
5662
|
const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
|
|
@@ -5589,8 +5816,30 @@ export async function dispatch(msg, ctx) {
|
|
|
5589
5816
|
}
|
|
5590
5817
|
const force = msg.force === true
|
|
5591
5818
|
try {
|
|
5592
|
-
const
|
|
5593
|
-
|
|
5819
|
+
const removeWorktree = ctx._removeWorktreeDir || removeWorktreeDir
|
|
5820
|
+
let result
|
|
5821
|
+
let releaseRemoval
|
|
5822
|
+
try {
|
|
5823
|
+
result = await removeWorktree(name, {
|
|
5824
|
+
force,
|
|
5825
|
+
// ⚠️ 会話の停止は dir 削除の **前**に置く(removeWorktree 側の契約)。ここで渡さないと
|
|
5826
|
+
// 会話が生きたまま cwd が消える。dirty で中止した場合は呼ばれない(削除しないのに
|
|
5827
|
+
// 会話だけ止めない)。
|
|
5828
|
+
stopSessions: (wtPath) => {
|
|
5829
|
+
// ⚠️ 万一 2 回呼ばれても前の登録を漏らさない。beginRemoval は参照カウントなので、
|
|
5830
|
+
// 解除し損ねるとそのパスへ以後 attach できなくなる(プロセスを再起動するまで)。
|
|
5831
|
+
releaseRemoval?.()
|
|
5832
|
+
releaseRemoval = beginRemoval(wtPath)
|
|
5833
|
+
return stopChatSessionsUnder(wtPath, ctx)
|
|
5834
|
+
},
|
|
5835
|
+
})
|
|
5836
|
+
} finally {
|
|
5837
|
+
releaseRemoval?.()
|
|
5838
|
+
}
|
|
5839
|
+
if (result.dirty !== true) {
|
|
5840
|
+
tmuxSessionsSnapshotCache.clear()
|
|
5841
|
+
chatTargetsCache.clear()
|
|
5842
|
+
}
|
|
5594
5843
|
ctx.client.send({
|
|
5595
5844
|
type: "worktree.remove.result",
|
|
5596
5845
|
request_id: msg.request_id,
|
|
@@ -5600,6 +5849,11 @@ export async function dispatch(msg, ctx) {
|
|
|
5600
5849
|
dirty: result.dirty === true,
|
|
5601
5850
|
changes: result.changes || [],
|
|
5602
5851
|
unpushed_commits: result.unpushed_commits || 0,
|
|
5852
|
+
// ⚠️ 削除の前に **停止 API を呼んだ**会話の本数。「止まった本数」ではないので、
|
|
5853
|
+
// 画面で成功件数として見せないこと(bridge は内部の停止失敗を握り潰すため、
|
|
5854
|
+
// 止まったかどうかはこの経路では分からない。詳細は stopChatSessionsUnder の JSDoc)。
|
|
5855
|
+
// 呼び出しそのものが失敗した場合は削除せず ok:false になるので、この応答には来ない。
|
|
5856
|
+
stop_requested_sessions: result.stop_requested_sessions || 0,
|
|
5603
5857
|
})
|
|
5604
5858
|
} catch (err) {
|
|
5605
5859
|
ctx.client.send({
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -80,6 +80,7 @@ import path from "node:path"
|
|
|
80
80
|
import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
|
|
81
81
|
import { describeLiveSessionsUnion } from "./live-sessions.mjs"
|
|
82
82
|
import { ReviewEventStore, validReviewEvent } from "./review-event-store.mjs"
|
|
83
|
+
import { listChatTargets } from "./chat-targets.mjs"
|
|
83
84
|
|
|
84
85
|
export const SIBLING_REQUESTS_DIR =
|
|
85
86
|
process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
|
|
@@ -431,6 +432,7 @@ export class SiblingInjectBridge {
|
|
|
431
432
|
pidStartedAt = defaultPidStartedAt,
|
|
432
433
|
onRestartRequested,
|
|
433
434
|
isInputBlocked,
|
|
435
|
+
starter,
|
|
434
436
|
watchFn = watch,
|
|
435
437
|
} = {}) {
|
|
436
438
|
this.dir = dir
|
|
@@ -447,6 +449,7 @@ export class SiblingInjectBridge {
|
|
|
447
449
|
this._pidStartedAt = pidStartedAt
|
|
448
450
|
this._onRestartRequested = onRestartRequested
|
|
449
451
|
this._isInputBlocked = isInputBlocked
|
|
452
|
+
this._starter = starter
|
|
450
453
|
this._watchFn = watchFn
|
|
451
454
|
this._newTurnInFlight = 0
|
|
452
455
|
/** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
|
|
@@ -932,7 +935,10 @@ export class SiblingInjectBridge {
|
|
|
932
935
|
result = { ok: false, error: "invalid_token" }
|
|
933
936
|
} else {
|
|
934
937
|
// 判定は live-sessions.mjs に一本化する (tmux.list_sessions の warm と同じ関数)。
|
|
935
|
-
|
|
938
|
+
const live_sessions = describeLiveSessionsUnion(this._bridges())
|
|
939
|
+
const { targets, errors } = await listChatTargets({ logger: this.logger })
|
|
940
|
+
const registered_targets = targets.map(({ cwd, kind, name }) => ({ cwd, kind, name }))
|
|
941
|
+
result = { ok: true, live_sessions, registered_targets, errors }
|
|
936
942
|
}
|
|
937
943
|
} else if (!cwd || (!isDelegateEvent && badKind) || badBody) {
|
|
938
944
|
result = { ok: false, error: "invalid_request" }
|
|
@@ -1113,7 +1119,29 @@ export class SiblingInjectBridge {
|
|
|
1113
1119
|
// **実配送先 (r.session)** を返す。
|
|
1114
1120
|
const r = bridge?.sendToCwd
|
|
1115
1121
|
? await this._sendNewTurn(
|
|
1116
|
-
() =>
|
|
1122
|
+
async () => {
|
|
1123
|
+
const first = await bridge.sendToCwd(cwd, text, injectSource, request_id)
|
|
1124
|
+
if (first.status !== "not_found" || target_session_id || !this._starter) return first
|
|
1125
|
+
const started = await this._starter({ cwd, cliKind: target_cli_kind })
|
|
1126
|
+
// ⚠️ 起動側の失敗理由をここで捨てない。捨てると送信元には最後の
|
|
1127
|
+
// `no_live_session_for_cwd` しか届かず、「準備中でもう一度送れば届く」と
|
|
1128
|
+
// 「その cwd には恒久的に会話が無い」を区別できない。配送結果そのものは
|
|
1129
|
+
// not_found のままなので、診断情報として添えて下流の応答へ持ち上げる。
|
|
1130
|
+
if (!started?.ok) {
|
|
1131
|
+
return started?.error
|
|
1132
|
+
? { ...first, startup_error: started.error, startup_retryable: started.retryable === true }
|
|
1133
|
+
: first
|
|
1134
|
+
}
|
|
1135
|
+
// ⚠️ 起動側は cwd を展開・解決してから会話を作る (chat-session-starter の
|
|
1136
|
+
// expandTilde と prepareChatWorkspace)。ここで展開前の cwd のまま引き直すと、
|
|
1137
|
+
// 実際に作られた会話 (解決後 cwd) と一致せず、起こした直後に not_found へ落ちる。
|
|
1138
|
+
// 解決後の cwd は起動側だけが知っているので、その戻り値を使う。
|
|
1139
|
+
const startedCwd = typeof started.cwd === "string" && started.cwd ? started.cwd : cwd
|
|
1140
|
+
const retry = await bridge.sendToCwd(startedCwd, text, injectSource, request_id)
|
|
1141
|
+
// 起動後の曖昧さは候補案内へ渡し、不在のときだけ最初の診断へ戻す。
|
|
1142
|
+
if (retry.status === "not_found") return first
|
|
1143
|
+
return retry
|
|
1144
|
+
},
|
|
1117
1145
|
{ request_id, cwd, target_cli_kind, op: "normal" },
|
|
1118
1146
|
)
|
|
1119
1147
|
: { status: "not_found" }
|
|
@@ -1159,7 +1187,16 @@ export class SiblingInjectBridge {
|
|
|
1159
1187
|
...ambiguousTargetOutcome(target_cli_kind, undefined, r.candidates),
|
|
1160
1188
|
}
|
|
1161
1189
|
} else {
|
|
1162
|
-
result = {
|
|
1190
|
+
result = {
|
|
1191
|
+
ok: false,
|
|
1192
|
+
error: "no_live_session_for_cwd",
|
|
1193
|
+
...this._diagnose(cwd, target_cli_kind),
|
|
1194
|
+
// ⚠️ `retryable` を載せるのは自動起動が「配送前に」失敗した場合だけ。配送を試みた
|
|
1195
|
+
// 結果が不明な要求に再試行印を付けると、送信元の自動再送で二重配送になる。
|
|
1196
|
+
...(r.startup_error
|
|
1197
|
+
? { startup_error: r.startup_error, retryable: r.startup_retryable === true }
|
|
1198
|
+
: {}),
|
|
1199
|
+
}
|
|
1163
1200
|
}
|
|
1164
1201
|
}
|
|
1165
1202
|
}
|