@cocorograph/hub-agent 0.8.111 → 0.8.113
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/codex-profiles.mjs +38 -4
- package/src/dispatch-scheduler.mjs +16 -7
- package/src/main.mjs +186 -115
- package/src/profiles.mjs +43 -5
- package/src/registry-cache.mjs +47 -0
- package/src/startup-metrics.mjs +68 -0
package/package.json
CHANGED
package/src/codex-profiles.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import path from "node:path"
|
|
|
29
29
|
import os from "node:os"
|
|
30
30
|
|
|
31
31
|
import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
|
|
32
|
+
import { fileSignature, keepRuntimeActive } from "./registry-cache.mjs"
|
|
32
33
|
|
|
33
34
|
/** 既定 (プライマリ) プロファイルの ID。 */
|
|
34
35
|
export const DEFAULT_CODEX_PROFILE_ID = "default"
|
|
@@ -94,6 +95,9 @@ export function registryPath() {
|
|
|
94
95
|
|
|
95
96
|
let _cache = null
|
|
96
97
|
|
|
98
|
+
/** 稼働中の active(env へ反映済みのもの)。keepRuntimeActive の docstring 参照。 */
|
|
99
|
+
let _runtimeActive = null
|
|
100
|
+
|
|
97
101
|
/** チルダ展開。 */
|
|
98
102
|
function resolveDir(dir) {
|
|
99
103
|
if (!dir) return defaultCodexDir()
|
|
@@ -106,13 +110,19 @@ function resolveDir(dir) {
|
|
|
106
110
|
* レジストリを読み込む。存在しなければ既定プロファイルのみの仮想レジストリを返す。
|
|
107
111
|
* @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
|
|
108
112
|
*/
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
async function readRegistry() {
|
|
114
|
+
// ⚠️ profiles.mjs と同じく、キャッシュはファイルの状態と組で持つ(手動編集を即時反映する)。
|
|
115
|
+
const signature = await fileSignature(registryPath())
|
|
116
|
+
if (_cache && _cache.signature === signature) return _cache.registry
|
|
111
117
|
let parsed = null
|
|
112
118
|
try {
|
|
113
119
|
const raw = await fs.readFile(registryPath(), "utf-8")
|
|
114
120
|
parsed = JSON.parse(raw)
|
|
115
121
|
} catch (err) {
|
|
122
|
+
// 手編集の途中(保存前の truncate・書きかけ)で壊れた JSON を読んだときは、直前に
|
|
123
|
+
// 正しく読めた内容を使い続ける(署名は更新しないので、次の呼び出しで読み直す)。
|
|
124
|
+
// 壊れたまま再起動した場合は、従来どおり起動時に失敗する。
|
|
125
|
+
if (err instanceof SyntaxError && _cache) return _cache.registry
|
|
116
126
|
if (err.code !== "ENOENT") throw err
|
|
117
127
|
}
|
|
118
128
|
|
|
@@ -143,8 +153,27 @@ export async function loadRegistry() {
|
|
|
143
153
|
}
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
const registry = { activeId, profiles }
|
|
157
|
+
_runtimeActive = keepRuntimeActive(registry, _runtimeActive)
|
|
158
|
+
_cache = { signature, registry }
|
|
159
|
+
return registry
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* レジストリを読み込む(readRegistry の docstring 参照)。
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ 読み込みが失敗した時点で稼働中の active がまだ決まっていなければ、既定に固定する。
|
|
166
|
+
* 起動時に失敗すると main.mjs は既定のディレクトリで env を組むので、あとでファイルが
|
|
167
|
+
* 直ったときにファイル上の activeId を「稼働中の active」と誤認しないため。stat・パース・
|
|
168
|
+
* 正規化のどこで失敗しても同じに扱う。固定済みなら(稼働後の失敗では)変えない。
|
|
169
|
+
*/
|
|
170
|
+
export async function loadRegistry() {
|
|
171
|
+
try {
|
|
172
|
+
return await readRegistry()
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (!_runtimeActive) _runtimeActive = makeDefaultCodexProfile()
|
|
175
|
+
throw err
|
|
176
|
+
}
|
|
148
177
|
}
|
|
149
178
|
|
|
150
179
|
/** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
|
|
@@ -237,6 +266,10 @@ export async function setActiveProfile(id) {
|
|
|
237
266
|
if (!target) throw new Error(`unknown codex profile: ${id}`)
|
|
238
267
|
reg.activeId = id
|
|
239
268
|
await saveRegistry(reg)
|
|
269
|
+
// 保存と並行して読み直した結果が旧 active のままキャッシュされないよう、ここで捨てる
|
|
270
|
+
// (同期で続けて書くので、以後に完了する読み直しは新しい active を使う)。
|
|
271
|
+
_runtimeActive = { ...target }
|
|
272
|
+
_cache = null
|
|
240
273
|
return target
|
|
241
274
|
}
|
|
242
275
|
|
|
@@ -406,4 +439,5 @@ export async function provisionProfile(configDir) {
|
|
|
406
439
|
/** テスト用: モジュールキャッシュを破棄する。 */
|
|
407
440
|
export function _clearCache() {
|
|
408
441
|
_cache = null
|
|
442
|
+
_runtimeActive = null
|
|
409
443
|
}
|
|
@@ -43,7 +43,7 @@ const SLOW_DISPATCH_MS = 1000
|
|
|
43
43
|
* 調査対象そのものなので、速かった場合も含めて毎回記録する
|
|
44
44
|
* (速いことの記録が無いと「詰まっていない」を示せない)。
|
|
45
45
|
*/
|
|
46
|
-
const ALWAYS_TIMED_TYPES = new Set(["chat.session.stop"])
|
|
46
|
+
const ALWAYS_TIMED_TYPES = new Set(["chat.session.stop", "claude.attach"])
|
|
47
47
|
|
|
48
48
|
export function isConnectionLifecycleMessage(type) {
|
|
49
49
|
return LIFECYCLE_TYPES.has(type)
|
|
@@ -101,6 +101,15 @@ export function attachLaneKey(mode, msg, defaultCwd = "") {
|
|
|
101
101
|
return streamId ? `${mode}:stream:${streamId}` : `${mode}:control`
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* ログに出すレーン名。内部キーは cwd・resume ID・stream ID を含むので、ログには種別だけを出す
|
|
106
|
+
* (直列化に使う内部キーそのものは変えない)。
|
|
107
|
+
*/
|
|
108
|
+
export function laneKindForLog(lane) {
|
|
109
|
+
const m = /^(claude|codex):(cwd|resume|stream):/.exec(typeof lane === "string" ? lane : "")
|
|
110
|
+
return m ? `${m[1]}:${m[2]}` : lane
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
/**
|
|
105
114
|
* @param {{
|
|
106
115
|
* run: (msg: object) => Promise<void>|void,
|
|
@@ -261,14 +270,14 @@ export class DispatchScheduler {
|
|
|
261
270
|
const runMs = Date.now() - startedAt
|
|
262
271
|
if (shouldLogDispatchTiming(msg?.type, 0, runMs)) {
|
|
263
272
|
this.logger?.info?.(
|
|
264
|
-
{ type: msg?.type, lane, wait_ms: 0, run_ms: runMs },
|
|
273
|
+
{ type: msg?.type, lane: laneKindForLog(lane), wait_ms: 0, run_ms: runMs },
|
|
265
274
|
"dispatch timing",
|
|
266
275
|
)
|
|
267
276
|
}
|
|
268
277
|
})
|
|
269
278
|
.catch((err) => {
|
|
270
279
|
this.logger?.error?.(
|
|
271
|
-
{ err: err?.message, type: msg?.type, lane },
|
|
280
|
+
{ err: err?.message, type: msg?.type, lane: laneKindForLog(lane) },
|
|
272
281
|
"dispatch threw",
|
|
273
282
|
)
|
|
274
283
|
})
|
|
@@ -317,7 +326,7 @@ export class DispatchScheduler {
|
|
|
317
326
|
.then(async () => {
|
|
318
327
|
if (generation !== this.generation) {
|
|
319
328
|
this.logger?.debug?.(
|
|
320
|
-
{ type, lane, queued_generation: generation, generation: this.generation },
|
|
329
|
+
{ type, lane: laneKindForLog(lane), queued_generation: generation, generation: this.generation },
|
|
321
330
|
"stale queued dispatch dropped after reconnect",
|
|
322
331
|
)
|
|
323
332
|
return
|
|
@@ -328,7 +337,7 @@ export class DispatchScheduler {
|
|
|
328
337
|
(this.detachedAtByStream.get(streamKey) ?? -1) > sequence
|
|
329
338
|
) {
|
|
330
339
|
this.logger?.debug?.(
|
|
331
|
-
{ type, lane, stream_id: streamId },
|
|
340
|
+
{ type, lane: laneKindForLog(lane), stream_id: streamId },
|
|
332
341
|
"detached queued attach dropped",
|
|
333
342
|
)
|
|
334
343
|
return
|
|
@@ -343,7 +352,7 @@ export class DispatchScheduler {
|
|
|
343
352
|
const runMs = Date.now() - startedAt
|
|
344
353
|
if (shouldLogDispatchTiming(type, waitMs, runMs)) {
|
|
345
354
|
this.logger?.info?.(
|
|
346
|
-
{ type, lane, wait_ms: waitMs, run_ms: runMs },
|
|
355
|
+
{ type, lane: laneKindForLog(lane), wait_ms: waitMs, run_ms: runMs },
|
|
347
356
|
"dispatch timing",
|
|
348
357
|
)
|
|
349
358
|
}
|
|
@@ -351,7 +360,7 @@ export class DispatchScheduler {
|
|
|
351
360
|
})
|
|
352
361
|
.catch((err) => {
|
|
353
362
|
this.logger?.error?.(
|
|
354
|
-
{ err: err?.message, type, lane },
|
|
363
|
+
{ err: err?.message, type, lane: laneKindForLog(lane) },
|
|
355
364
|
"dispatch threw in serial lane",
|
|
356
365
|
)
|
|
357
366
|
})
|
package/src/main.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks"
|
|
2
2
|
import { createHash } from "node:crypto"
|
|
3
|
-
import { logPhaseTiming, startEventLoopMetrics } from "./startup-metrics.mjs"
|
|
3
|
+
import { logPhaseTiming, startEventLoopMetrics, readAttachEventLoopMetrics } from "./startup-metrics.mjs"
|
|
4
4
|
import { startLogRotation } from "./log-rotation.mjs"
|
|
5
5
|
/**
|
|
6
6
|
* hub-agent daemon 本体。
|
|
@@ -912,27 +912,37 @@ export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
|
|
|
912
912
|
}
|
|
913
913
|
}
|
|
914
914
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
915
|
+
/**
|
|
916
|
+
* 起動時の env を組む(シェル補完 → Claude / Codex プロファイルの env 反映)。
|
|
917
|
+
*
|
|
918
|
+
* launchd / systemd 起動では .zshrc 等の対話シェル設定が読まれず、~/.claude.json の
|
|
919
|
+
* MCP 定義が参照する環境変数 (例: Authorization: Bearer ${WRI_MCP_TOKEN}) が解決
|
|
920
|
+
* できない。ユーザーのログインシェル env + dotenv 層を 1 回解決して process.env を
|
|
921
|
+
* 補完する。spawn される全 Claude query が継承し、MCP トークンを出どころ
|
|
922
|
+
* (Keychain / シェル変数 / .env) を問わず引き継げる。WHY: ナレッジ/インフラ/cockpit-mcp-reauth
|
|
923
|
+
*
|
|
924
|
+
* ⚠️ プロファイルの env はシェル補完の**後**に反映する。補完は「欠けているキー」を足すので、
|
|
925
|
+
* 既定プロファイルで CLAUDE_CONFIG_DIR / CODEX_HOME を消したあとに補完すると、シェルに
|
|
926
|
+
* 書かれた値が戻り、一覧(既定)と実行(シェルの値)が食い違う。
|
|
927
|
+
* ⚠️ プロファイルを読めなかったときも既定の env を反映する(継承した値を残すと、一覧は
|
|
928
|
+
* 既定・実行は継承先になる)。profiles.mjs / codex-profiles.mjs の loadRegistry は、この
|
|
929
|
+
* 失敗時に稼働中の active を既定として固定する。
|
|
930
|
+
*
|
|
931
|
+
* @param {object} p
|
|
932
|
+
* @param {import('pino').Logger} p.logger
|
|
933
|
+
* @param {object} [p.deps] テスト用の差し替え(hydrate / getClaudeProfile / getCodexProfile)
|
|
934
|
+
*/
|
|
935
|
+
export async function applyStartupEnv({ logger, deps = {} }) {
|
|
936
|
+
const hydrate = deps.hydrate || hydrateProcessEnvFromShell
|
|
937
|
+
const getClaudeProfile = deps.getClaudeProfile || getActiveProfile
|
|
938
|
+
const getCodexProfile = deps.getCodexProfile || codexProfiles.getActiveProfile
|
|
924
939
|
|
|
925
|
-
|
|
926
|
-
// ctx と dispatchCtx の shallow copy を跨いで共有する lifecycle 正本。
|
|
927
|
-
const lifecycle = { restartPending: false, shutdownStarted: false }
|
|
928
|
-
const ctx = { logger, config, plugins, lifecycle }
|
|
940
|
+
await hydrate({ logger })
|
|
929
941
|
|
|
930
|
-
// 起動時: 保存済み active プロファイルの CLAUDE_CONFIG_DIR を反映してから
|
|
931
|
-
// Claude SDK を初期化する。profiles.json が無ければ既定 (~/.claude) のまま。
|
|
932
942
|
try {
|
|
933
|
-
|
|
934
|
-
applyActiveProfileEnv(activeProfile, logger)
|
|
943
|
+
applyActiveProfileEnv(await getClaudeProfile(), logger)
|
|
935
944
|
} catch (err) {
|
|
945
|
+
applyActiveProfileEnv(null, logger)
|
|
936
946
|
logger.warn(
|
|
937
947
|
{ err: err.message },
|
|
938
948
|
"failed to apply active claude profile; falling back to default ~/.claude",
|
|
@@ -941,22 +951,32 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
941
951
|
|
|
942
952
|
// 同様に active Codex プロファイルの CODEX_HOME を反映する (独立切替)。
|
|
943
953
|
try {
|
|
944
|
-
|
|
945
|
-
applyActiveCodexEnv(activeCodex, logger)
|
|
954
|
+
applyActiveCodexEnv(await getCodexProfile(), logger)
|
|
946
955
|
} catch (err) {
|
|
956
|
+
applyActiveCodexEnv(null, logger)
|
|
947
957
|
logger.warn(
|
|
948
958
|
{ err: err.message },
|
|
949
959
|
"failed to apply active codex profile; falling back to default ~/.codex",
|
|
950
960
|
)
|
|
951
961
|
}
|
|
962
|
+
}
|
|
952
963
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
964
|
+
export async function startDaemon({ version, claudeSdk } = {}) {
|
|
965
|
+
const logRotationTimer = startLogRotation(logger, { isDeferred: hasInheritedOutputChild })
|
|
966
|
+
startEventLoopMetrics(logger)
|
|
967
|
+
const config = await readConfig()
|
|
968
|
+
if (!config) {
|
|
969
|
+
throw new Error(
|
|
970
|
+
"~/.hub/agent.json が見つかりません。`hub-agent enroll <token>` を先に実行してください。",
|
|
971
|
+
)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const plugins = await loadPlugins(logger)
|
|
975
|
+
// ctx と dispatchCtx の shallow copy を跨いで共有する lifecycle 正本。
|
|
976
|
+
const lifecycle = { restartPending: false, shutdownStarted: false }
|
|
977
|
+
const ctx = { logger, config, plugins, lifecycle }
|
|
978
|
+
|
|
979
|
+
await applyStartupEnv({ logger })
|
|
960
980
|
|
|
961
981
|
await runHookBroadcast(plugins, "onAgentStart", ctx)
|
|
962
982
|
|
|
@@ -3437,6 +3457,33 @@ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove"
|
|
|
3437
3457
|
return stopped
|
|
3438
3458
|
}
|
|
3439
3459
|
|
|
3460
|
+
// attach 一回につき一度だけ記録する。fields に本文・パス・セッション ID を持たせない。
|
|
3461
|
+
export function createClaudeAttachTiming(ctx, msg) {
|
|
3462
|
+
const now = () => performance.now()
|
|
3463
|
+
const started = now()
|
|
3464
|
+
let logged = false
|
|
3465
|
+
const fields = {
|
|
3466
|
+
stream_id: msg.stream_id,
|
|
3467
|
+
prepare_ms: null, attach_ms: null, to_ready_ms: null,
|
|
3468
|
+
send_ok: null, ws_buffered: null,
|
|
3469
|
+
resuming: false, has_resume_id: Boolean(msg.resume_session_id),
|
|
3470
|
+
}
|
|
3471
|
+
return {
|
|
3472
|
+
now, started, fields,
|
|
3473
|
+
log(outcome) {
|
|
3474
|
+
if (logged) return
|
|
3475
|
+
logged = true
|
|
3476
|
+
try {
|
|
3477
|
+
const loop = readAttachEventLoopMetrics()
|
|
3478
|
+
fields.ws_buffered = ctx.client.bufferedAmount ?? null
|
|
3479
|
+
ctx.logger?.info?.({ ...fields, ...loop, outcome }, "claude attach timing")
|
|
3480
|
+
} catch {
|
|
3481
|
+
// 計測失敗で attach / ready / error の配送を壊さない。
|
|
3482
|
+
}
|
|
3483
|
+
},
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3440
3487
|
/**
|
|
3441
3488
|
* DispatchScheduler から呼ばれる本番メッセージ入口。
|
|
3442
3489
|
* export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
|
|
@@ -3510,110 +3557,134 @@ export async function dispatch(msg, ctx) {
|
|
|
3510
3557
|
ctx.client.completeStreamsSync?.(msg)
|
|
3511
3558
|
return
|
|
3512
3559
|
case "claude.attach": {
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
const stream_id = msg.stream_id
|
|
3516
|
-
if (!ctx.claudeBridge) {
|
|
3517
|
-
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
3518
|
-
// 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
|
|
3519
|
-
ctx.client.send({
|
|
3520
|
-
type: "claude.error",
|
|
3521
|
-
stream_id,
|
|
3522
|
-
error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
|
|
3523
|
-
})
|
|
3524
|
-
return
|
|
3525
|
-
}
|
|
3560
|
+
const timing = createClaudeAttachTiming(ctx, msg)
|
|
3561
|
+
let outcome = "error"
|
|
3526
3562
|
try {
|
|
3527
|
-
//
|
|
3528
|
-
//
|
|
3529
|
-
|
|
3530
|
-
|
|
3563
|
+
// Sprint G: stream-json モードで Claude Code を起動。
|
|
3564
|
+
// stream_id は browser 採番のものをそのまま使う。
|
|
3565
|
+
const stream_id = msg.stream_id
|
|
3566
|
+
if (!ctx.claudeBridge) {
|
|
3567
|
+
outcome = "unavailable"
|
|
3568
|
+
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
3569
|
+
// 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
|
|
3570
|
+
ctx.client.send({
|
|
3571
|
+
type: "claude.error",
|
|
3572
|
+
stream_id,
|
|
3573
|
+
error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
|
|
3574
|
+
})
|
|
3575
|
+
return
|
|
3576
|
+
}
|
|
3531
3577
|
try {
|
|
3532
|
-
|
|
3533
|
-
|
|
3578
|
+
// cwd は明示指定が無ければ agent.json の cwd → HOME の順で fallback
|
|
3579
|
+
// (将来 cockpit から worktree path を指定する用)。
|
|
3580
|
+
// model / permissionMode は agent.json の値も使えるよう fallback。
|
|
3581
|
+
const cwdArg = msg.cwd || ctx.config?.claude_cwd || undefined
|
|
3582
|
+
const prepareStarted = timing.now()
|
|
3583
|
+
try {
|
|
3584
|
+
try {
|
|
3585
|
+
await prepareChatWorkspace({
|
|
3586
|
+
waitForSync: false,
|
|
3587
|
+
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3588
|
+
cliKind: "claude",
|
|
3589
|
+
logger: ctx.logger,
|
|
3590
|
+
})
|
|
3591
|
+
} finally {
|
|
3592
|
+
timing.fields.prepare_ms = Math.round(timing.now() - prepareStarted)
|
|
3593
|
+
}
|
|
3594
|
+
} catch (err) {
|
|
3595
|
+
// Claude は attach で会話を登録するだけで、プロセス起動前に ready を返す。
|
|
3596
|
+
// cwd が無くても live・配送対象に載るため、起動時の失敗に頼らず登録前に止める。
|
|
3597
|
+
if (isCwdUnavailableError(err)) {
|
|
3598
|
+
outcome = "workspace_missing"
|
|
3599
|
+
ctx.client.send({ type: "claude.error", stream_id, error: `workspace_missing: ${err.message}` })
|
|
3600
|
+
return
|
|
3601
|
+
}
|
|
3602
|
+
ctx.logger?.warn?.(
|
|
3603
|
+
{ cwd: cwdArg, err: err?.message || String(err) },
|
|
3604
|
+
"claude.attach workspace preparation failed (attach continues)",
|
|
3605
|
+
)
|
|
3606
|
+
}
|
|
3607
|
+
const resumeSessionId = msg.resume_session_id || null
|
|
3608
|
+
// attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
|
|
3609
|
+
// 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
|
|
3610
|
+
// requestしてもhydrateのuuid dedupで安全に収束する。
|
|
3611
|
+
// 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
|
|
3612
|
+
// resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
|
|
3613
|
+
// 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
|
|
3614
|
+
const historyTiming = createHistoryDeliveryTiming()
|
|
3615
|
+
const historyPromise = buildAttachHistoryPromise({
|
|
3616
|
+
timing: historyTiming,
|
|
3617
|
+
resumeSessionId,
|
|
3534
3618
|
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3535
|
-
cliKind: "claude",
|
|
3536
3619
|
logger: ctx.logger,
|
|
3620
|
+
profileId: msg.profile_id,
|
|
3537
3621
|
})
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3622
|
+
const attachStarted = timing.now()
|
|
3623
|
+
let info
|
|
3624
|
+
try {
|
|
3625
|
+
info = ctx.claudeBridge.attach({
|
|
3626
|
+
stream_id,
|
|
3627
|
+
cwd: cwdArg,
|
|
3628
|
+
...resolveClaudeAttachOptions(ctx.config, msg),
|
|
3629
|
+
resumeSessionId,
|
|
3630
|
+
})
|
|
3631
|
+
} finally {
|
|
3632
|
+
timing.fields.attach_ms = Math.round(timing.now() - attachStarted)
|
|
3633
|
+
}
|
|
3634
|
+
timing.fields.resuming = info?.resuming === true
|
|
3635
|
+
timing.fields.send_ok = false
|
|
3636
|
+
const sendOk = ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
3637
|
+
timing.fields.to_ready_ms = Math.round(timing.now() - timing.started)
|
|
3638
|
+
timing.fields.send_ok = sendOk == null ? null : Boolean(sendOk)
|
|
3639
|
+
timing.log("ready")
|
|
3640
|
+
try {
|
|
3641
|
+
if (resumeSessionId) {
|
|
3642
|
+
void ctx.siblingInjectBridge
|
|
3643
|
+
?.replayReviews?.("claude", cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
3644
|
+
?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
|
|
3645
|
+
}
|
|
3646
|
+
} catch (err) {
|
|
3647
|
+
ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
|
|
3544
3648
|
}
|
|
3545
|
-
ctx.logger?.warn?.(
|
|
3546
|
-
{ cwd: cwdArg, err: err?.message || String(err) },
|
|
3547
|
-
"claude.attach workspace preparation failed (attach continues)",
|
|
3548
|
-
)
|
|
3549
|
-
}
|
|
3550
|
-
const resumeSessionId = msg.resume_session_id || null
|
|
3551
|
-
// attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
|
|
3552
|
-
// 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
|
|
3553
|
-
// requestしてもhydrateのuuid dedupで安全に収束する。
|
|
3554
|
-
// 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
|
|
3555
|
-
// resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
|
|
3556
|
-
// 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
|
|
3557
|
-
const historyTiming = createHistoryDeliveryTiming()
|
|
3558
|
-
const historyPromise = buildAttachHistoryPromise({
|
|
3559
|
-
timing: historyTiming,
|
|
3560
|
-
resumeSessionId,
|
|
3561
|
-
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3562
|
-
logger: ctx.logger,
|
|
3563
|
-
profileId: msg.profile_id,
|
|
3564
|
-
})
|
|
3565
|
-
const info = ctx.claudeBridge.attach({
|
|
3566
|
-
stream_id,
|
|
3567
|
-
cwd: cwdArg,
|
|
3568
|
-
...resolveClaudeAttachOptions(ctx.config, msg),
|
|
3569
|
-
resumeSessionId,
|
|
3570
|
-
})
|
|
3571
|
-
ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
3572
|
-
try {
|
|
3573
3649
|
if (resumeSessionId) {
|
|
3574
3650
|
void ctx.siblingInjectBridge
|
|
3575
|
-
?.
|
|
3576
|
-
|
|
3651
|
+
?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
3652
|
+
.catch((err) => {
|
|
3653
|
+
ctx.logger?.warn?.(
|
|
3654
|
+
{ err: err?.message || String(err), stream_id, session_id: resumeSessionId },
|
|
3655
|
+
"failed to replay delegations after Claude attach",
|
|
3656
|
+
)
|
|
3657
|
+
})
|
|
3577
3658
|
}
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3659
|
+
// dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
|
|
3660
|
+
// 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
|
|
3661
|
+
void historyPromise
|
|
3662
|
+
.then((pushedHistory) => {
|
|
3663
|
+
sendClaudeAttachHistory(ctx, stream_id, pushedHistory, historyTiming)
|
|
3664
|
+
})
|
|
3584
3665
|
.catch((err) => {
|
|
3585
3666
|
ctx.logger?.warn?.(
|
|
3586
|
-
{ err: err?.message || String(err), stream_id
|
|
3587
|
-
"failed to
|
|
3667
|
+
{ err: err?.message || String(err), stream_id },
|
|
3668
|
+
"failed to push Claude attach history",
|
|
3588
3669
|
)
|
|
3589
3670
|
})
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
.
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
)
|
|
3671
|
+
const session = ctx.claudeBridge.sessions?.get(stream_id)
|
|
3672
|
+
const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
|
|
3673
|
+
if (session && resumeSessionId) {
|
|
3674
|
+
void loadResumeChatContext(session, "claude").catch(() => {})
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
} catch (err) {
|
|
3678
|
+
ctx.client.send({
|
|
3679
|
+
type: "claude.error",
|
|
3680
|
+
stream_id,
|
|
3681
|
+
error: err.message,
|
|
3602
3682
|
})
|
|
3603
|
-
const session = ctx.claudeBridge.sessions?.get(stream_id)
|
|
3604
|
-
const resolvedCwd = session?.cwd || cwdArg || process.env.HOME || process.cwd()
|
|
3605
|
-
if (session && resumeSessionId) {
|
|
3606
|
-
void loadResumeChatContext(session, "claude").catch(() => {})
|
|
3607
3683
|
}
|
|
3608
|
-
|
|
3609
|
-
}
|
|
3610
|
-
|
|
3611
|
-
type: "claude.error",
|
|
3612
|
-
stream_id,
|
|
3613
|
-
error: err.message,
|
|
3614
|
-
})
|
|
3684
|
+
return
|
|
3685
|
+
} finally {
|
|
3686
|
+
timing.log(outcome)
|
|
3615
3687
|
}
|
|
3616
|
-
return
|
|
3617
3688
|
}
|
|
3618
3689
|
case "claude.session.options.set": {
|
|
3619
3690
|
const result = ctx.claudeBridge?.setSessionOptions({
|
package/src/profiles.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import path from "node:path"
|
|
|
32
32
|
import os from "node:os"
|
|
33
33
|
|
|
34
34
|
import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
|
|
35
|
+
import { fileSignature, keepRuntimeActive } from "./registry-cache.mjs"
|
|
35
36
|
|
|
36
37
|
/** 既定 (プライマリ) プロファイルの ID。 */
|
|
37
38
|
export const DEFAULT_PROFILE_ID = "default"
|
|
@@ -56,9 +57,13 @@ export function registryPath() {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** モジュール内キャッシュ (頻繁に呼ばれる getActiveConfigDir のため)。
|
|
59
|
-
*
|
|
60
|
+
* `{ signature, registry }`。signature がファイルの現状と一致するときだけ使う
|
|
61
|
+
* (手動編集の即時反映)。setActiveProfile / 書き込み系でも念のため無効化する。 */
|
|
60
62
|
let _cache = null
|
|
61
63
|
|
|
64
|
+
/** 稼働中の active(env へ反映済みのもの)。keepRuntimeActive の docstring 参照。 */
|
|
65
|
+
let _runtimeActive = null
|
|
66
|
+
|
|
62
67
|
/** チルダ展開 (`~` / `~/...` を home 起点に解決)。それ以外は path.resolve。 */
|
|
63
68
|
function resolveDir(dir) {
|
|
64
69
|
if (!dir) return defaultConfigDir()
|
|
@@ -73,13 +78,22 @@ function resolveDir(dir) {
|
|
|
73
78
|
*
|
|
74
79
|
* @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
|
|
75
80
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
async function readRegistry() {
|
|
82
|
+
// ⚠️ キャッシュは「読んだ時点のファイルの状態」と組で持ち、毎回 stat して変わっていたら
|
|
83
|
+
// 読み直す。起動時に読んだ内容を使い続けると、稼働中に ~/.hub/profiles.json を手で
|
|
84
|
+
// 作成・編集しても agent を再起動するまで反映されなかった(初期登録で再起動が 2 回要った)。
|
|
85
|
+
// stat は極小ファイル 1 回分なので、頻繁に呼ばれる getActiveConfigDir でも負荷は無視できる。
|
|
86
|
+
const signature = await fileSignature(registryPath())
|
|
87
|
+
if (_cache && _cache.signature === signature) return _cache.registry
|
|
78
88
|
let parsed = null
|
|
79
89
|
try {
|
|
80
90
|
const raw = await fs.readFile(registryPath(), "utf-8")
|
|
81
91
|
parsed = JSON.parse(raw)
|
|
82
92
|
} catch (err) {
|
|
93
|
+
// 手編集の途中(保存前の truncate・書きかけ)で壊れた JSON を読んだときは、直前に
|
|
94
|
+
// 正しく読めた内容を使い続ける(署名は更新しないので、次の呼び出しで読み直す)。
|
|
95
|
+
// 壊れたまま再起動した場合は、従来どおり起動時に失敗する。
|
|
96
|
+
if (err instanceof SyntaxError && _cache) return _cache.registry
|
|
83
97
|
if (err.code !== "ENOENT") throw err
|
|
84
98
|
}
|
|
85
99
|
|
|
@@ -112,8 +126,27 @@ export async function loadRegistry() {
|
|
|
112
126
|
}
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
const registry = { activeId, profiles }
|
|
130
|
+
_runtimeActive = keepRuntimeActive(registry, _runtimeActive)
|
|
131
|
+
_cache = { signature, registry }
|
|
132
|
+
return registry
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* レジストリを読み込む(readRegistry の docstring 参照)。
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ 読み込みが失敗した時点で稼働中の active がまだ決まっていなければ、既定に固定する。
|
|
139
|
+
* 起動時に失敗すると main.mjs は既定のディレクトリで env を組むので、あとでファイルが
|
|
140
|
+
* 直ったときにファイル上の activeId を「稼働中の active」と誤認しないため。stat・パース・
|
|
141
|
+
* 正規化のどこで失敗しても同じに扱う。固定済みなら(稼働後の失敗では)変えない。
|
|
142
|
+
*/
|
|
143
|
+
export async function loadRegistry() {
|
|
144
|
+
try {
|
|
145
|
+
return await readRegistry()
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (!_runtimeActive) _runtimeActive = makeDefaultProfile()
|
|
148
|
+
throw err
|
|
149
|
+
}
|
|
117
150
|
}
|
|
118
151
|
|
|
119
152
|
/** レジストリを永続化する。default は profiles[] に含めず、追加分と activeId / defaultLabel だけ保存。 */
|
|
@@ -215,6 +248,10 @@ export async function setActiveProfile(id) {
|
|
|
215
248
|
if (!target) throw new Error(`unknown profile: ${id}`)
|
|
216
249
|
reg.activeId = id
|
|
217
250
|
await saveRegistry(reg)
|
|
251
|
+
// 保存と並行して読み直した結果が旧 active のままキャッシュされないよう、ここで捨てる
|
|
252
|
+
// (同期で続けて書くので、以後に完了する読み直しは新しい active を使う)。
|
|
253
|
+
_runtimeActive = { ...target }
|
|
254
|
+
_cache = null
|
|
218
255
|
return target
|
|
219
256
|
}
|
|
220
257
|
|
|
@@ -537,4 +574,5 @@ async function isBrokenSymlink(p) {
|
|
|
537
574
|
/** テスト用: モジュールキャッシュを破棄する。 */
|
|
538
575
|
export function _clearCache() {
|
|
539
576
|
_cache = null
|
|
577
|
+
_runtimeActive = null
|
|
540
578
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ファイルの現在の状態を表す文字列を返す(読み込み結果をキャッシュするときの照合キー)。
|
|
5
|
+
*
|
|
6
|
+
* パス・inode・サイズ・mtime・ctime の組で、どれかが変われば別物として扱う
|
|
7
|
+
* (同じミリ秒内の同サイズ書き換えも ctime / inode で拾う。エディタの「別名保存 → rename」も
|
|
8
|
+
* inode で拾う)。ファイルが無ければ `<path>:absent`。ENOENT 以外の失敗はそのまま投げる。
|
|
9
|
+
*
|
|
10
|
+
* @param {string} p 絶対パス
|
|
11
|
+
* @returns {Promise<string>}
|
|
12
|
+
*/
|
|
13
|
+
export async function fileSignature(p) {
|
|
14
|
+
try {
|
|
15
|
+
const st = await fs.stat(p)
|
|
16
|
+
return `${p}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code === "ENOENT") return `${p}:absent`
|
|
19
|
+
throw err
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 稼働中に使っている active プロファイルを、読み直したレジストリへ当て直す。
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ active は「env(CLAUDE_CONFIG_DIR / CODEX_HOME)に反映済みのもの」をプロセスが持ち続ける。
|
|
27
|
+
* ファイルを読み直すたびに activeId や active の configDir を手編集どおりに変えると、一覧は
|
|
28
|
+
* 新しい active を表示するのに、子プロセスは起動時の env を継承して古いアカウントで動く
|
|
29
|
+
* (表示と実行が食い違う)。env の反映と既存セッションの停止は UI の切替経路(main.mjs の
|
|
30
|
+
* profile.switch / codex.profile.switch)だけが行うので、稼働中の active の変更もその経路に限る。
|
|
31
|
+
* active への手編集(activeId の変更・active の configDir 変更・active の削除)は再起動で反映される。
|
|
32
|
+
*
|
|
33
|
+
* @param {{activeId:string, profiles:Array<{id:string,label:string,configDir:string}>}} registry 読み直した結果(破壊的に更新する)
|
|
34
|
+
* @param {{id:string,label:string,configDir:string}|null} pinned 稼働中の active(初回は null)
|
|
35
|
+
* @returns {{id:string,label:string,configDir:string}} 以後保持する active
|
|
36
|
+
*/
|
|
37
|
+
export function keepRuntimeActive(registry, pinned) {
|
|
38
|
+
if (!pinned) {
|
|
39
|
+
const active = registry.profiles.find((p) => p.id === registry.activeId) || registry.profiles[0]
|
|
40
|
+
return { ...active }
|
|
41
|
+
}
|
|
42
|
+
const hit = registry.profiles.find((p) => p.id === pinned.id)
|
|
43
|
+
if (!hit) registry.profiles.push({ ...pinned })
|
|
44
|
+
else if (hit.configDir !== pinned.configDir) hit.configDir = pinned.configDir
|
|
45
|
+
registry.activeId = pinned.id
|
|
46
|
+
return pinned
|
|
47
|
+
}
|
package/src/startup-metrics.mjs
CHANGED
|
@@ -4,6 +4,74 @@ import { monitorEventLoopDelay, performance } from "node:perf_hooks"
|
|
|
4
4
|
// performance.now() はプロセス起動を原点とする単調時計。テストでは _setNowForTest で差し替える。
|
|
5
5
|
let defaultNow = () => performance.now()
|
|
6
6
|
|
|
7
|
+
// attach 用のイベントループ遅延。一定間隔のタイマーが予定よりどれだけ遅れたかを直接測る。
|
|
8
|
+
// ⚠️ monitorEventLoopDelay は使わない: Node 24 では 1 回の長い停止を取りこぼす
|
|
9
|
+
// (150ms 塞いでも max 22ms。2026-10-14 実測)。知りたいのはまさにその単発の停止なので測れない。
|
|
10
|
+
const ATTACH_LOOP_TICK_MS = 20
|
|
11
|
+
const ATTACH_LOOP_STALL_MS = 100
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* タイマー遅延の集計(時計を差し込める状態機械)。read() ごとに区間を切る。
|
|
15
|
+
* - loop_delay_max_ms: 区間内に重なった遅延の最大。区間をまたぐ停止は区間内の部分だけを数える
|
|
16
|
+
* (読み取り直後の区間に、前区間で報告済みの遅延を持ち越さない)
|
|
17
|
+
* - loop_stalls_100ms: 停止(遅延が stallMs 以上)の回数。1 回の停止は、stallMs に達したと最初に
|
|
18
|
+
* 分かった区間で 1 回だけ数える(読み取り時の進行中遅延で数えたら、後のタイマー発火では数えない)
|
|
19
|
+
* - loop_window_ms: 区間長
|
|
20
|
+
*/
|
|
21
|
+
export function createLoopLagSampler({ now = () => performance.now(), tickMs = ATTACH_LOOP_TICK_MS, stallMs = ATTACH_LOOP_STALL_MS } = {}) {
|
|
22
|
+
let lastTick = now()
|
|
23
|
+
let windowStart = lastTick
|
|
24
|
+
let maxLag = 0
|
|
25
|
+
let stalls = 0
|
|
26
|
+
// lastTick 以降に続いている停止を、既に回数へ数えたか。
|
|
27
|
+
let ongoingCounted = false
|
|
28
|
+
return {
|
|
29
|
+
tick() {
|
|
30
|
+
const t = now()
|
|
31
|
+
const lag = Math.max(0, t - lastTick - tickMs)
|
|
32
|
+
const inWindow = Math.min(lag, Math.max(0, t - windowStart))
|
|
33
|
+
if (inWindow > maxLag) maxLag = inWindow
|
|
34
|
+
if (lag >= stallMs && !ongoingCounted) stalls++
|
|
35
|
+
ongoingCounted = false
|
|
36
|
+
lastTick = t
|
|
37
|
+
},
|
|
38
|
+
read() {
|
|
39
|
+
const t = now()
|
|
40
|
+
const pending = Math.max(0, t - lastTick - tickMs)
|
|
41
|
+
const pendingInWindow = Math.min(pending, Math.max(0, t - windowStart))
|
|
42
|
+
let windowStalls = stalls
|
|
43
|
+
if (pending >= stallMs && !ongoingCounted) {
|
|
44
|
+
windowStalls++
|
|
45
|
+
ongoingCounted = true
|
|
46
|
+
}
|
|
47
|
+
const fields = {
|
|
48
|
+
loop_delay_max_ms: Math.round(Math.max(maxLag, pendingInWindow)),
|
|
49
|
+
loop_stalls_100ms: windowStalls,
|
|
50
|
+
loop_window_ms: Math.round(t - windowStart),
|
|
51
|
+
}
|
|
52
|
+
maxLag = 0
|
|
53
|
+
stalls = 0
|
|
54
|
+
windowStart = t
|
|
55
|
+
return fields
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** サンプラーを作り、タイマーで tick() を回す。計測のためにプロセスの終了を引き留めない。 */
|
|
61
|
+
export function startLoopLagSampler({ now, tickMs = ATTACH_LOOP_TICK_MS, stallMs = ATTACH_LOOP_STALL_MS, setIntervalFn = setInterval } = {}) {
|
|
62
|
+
const sampler = createLoopLagSampler({ now, tickMs, stallMs })
|
|
63
|
+
const timer = setIntervalFn(() => sampler.tick(), tickMs)
|
|
64
|
+
timer?.unref?.()
|
|
65
|
+
return sampler
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const attachLoopSampler = startLoopLagSampler()
|
|
69
|
+
|
|
70
|
+
/** 前回読んでから今までのイベントループ遅延(最大・100ms 以上の停止回数・区間長)を返し、区間を切る。 */
|
|
71
|
+
export function readAttachEventLoopMetrics() {
|
|
72
|
+
return attachLoopSampler.read()
|
|
73
|
+
}
|
|
74
|
+
|
|
7
75
|
/** テスト専用: 既定の時計を差し替える (省略で元に戻す)。直前の時計を返す。 */
|
|
8
76
|
export function _setNowForTest(fn) {
|
|
9
77
|
const prev = defaultNow
|