@cocorograph/hub-agent 0.6.27 → 0.6.29
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 +3 -2
- package/scripts/check-publish-on-main.mjs +122 -0
- package/src/claude-stream-bridge.mjs +197 -25
- package/src/main.mjs +48 -3
- package/src/state.mjs +53 -2
- package/src/tmux.mjs +4 -17
- package/src/usage.mjs +138 -47
- package/src/ws-client.mjs +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.29",
|
|
4
4
|
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"start": "node bin/hub-agent.mjs start",
|
|
21
21
|
"test": "node --test test/*.test.mjs",
|
|
22
22
|
"postinstall": "node scripts/fix-node-pty-perms.mjs",
|
|
23
|
-
"
|
|
23
|
+
"check:publish-on-main": "node scripts/check-publish-on-main.mjs",
|
|
24
|
+
"prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
26
27
|
"bin/",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// パブリッシュ前ガード: いま publish しようとしている HEAD が origin/main に
|
|
3
|
+
// マージ済みであることを確認する。
|
|
4
|
+
//
|
|
5
|
+
// 背景 (恒久対策): hub-agent には CI/CD デプロイフローが無く、publish は手元から
|
|
6
|
+
// 手動で行う。過去に feature/ローカルブランチから直接 publish した結果、main の
|
|
7
|
+
// バージョンが publish 済みより古いまま取り残され (例: main=0.6.26 なのに 0.6.28 が
|
|
8
|
+
// publish 済み)、次にその古い main から派生したブランチで「バージョン巻き戻り」が
|
|
9
|
+
// 発生した。これを防ぐため「publish する前に必ず main へマージする」を機械的に強制する。
|
|
10
|
+
//
|
|
11
|
+
// ルール: npm publish (= prepublishOnly) 時に、HEAD のコミットが origin/main から
|
|
12
|
+
// 到達可能 (= main にマージ済み) かつ作業ツリーがクリーンでなければ exit 1 で止める。
|
|
13
|
+
//
|
|
14
|
+
// 緊急時の回避: どうしても main 未マージで publish する必要がある場合のみ
|
|
15
|
+
// ALLOW_PUBLISH_OFF_MAIN=1 npm publish
|
|
16
|
+
// で明示的にバイパスできる (記録が残るよう env を必須にしている)。
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process"
|
|
19
|
+
|
|
20
|
+
const GREEN = "\x1b[32m"
|
|
21
|
+
const RED = "\x1b[31m"
|
|
22
|
+
const YELLOW = "\x1b[33m"
|
|
23
|
+
const RESET = "\x1b[0m"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* git コマンドを同期実行して trim 済み stdout を返す。
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} args git に渡す引数
|
|
29
|
+
* @param {{ allowFail?: boolean }} [opts] allowFail=true なら失敗時に null を返す
|
|
30
|
+
* @returns {string | null} stdout (trim 済み)、失敗かつ allowFail なら null
|
|
31
|
+
*/
|
|
32
|
+
function git(args, opts = {}) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync("git", args, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
}).trim()
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (opts.allowFail) return null
|
|
40
|
+
throw err
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(message) {
|
|
45
|
+
console.error(`${RED}✖ publish ガード: ${message}${RESET}`)
|
|
46
|
+
console.error(
|
|
47
|
+
`${YELLOW} → 正しい手順: PR を origin/main にマージ → main を pull → ` +
|
|
48
|
+
`main 上で npm publish。${RESET}`,
|
|
49
|
+
)
|
|
50
|
+
console.error(
|
|
51
|
+
`${YELLOW} → 緊急回避 (記録が残ります): ` +
|
|
52
|
+
`ALLOW_PUBLISH_OFF_MAIN=1 npm publish${RESET}`,
|
|
53
|
+
)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function main() {
|
|
58
|
+
if (process.env.ALLOW_PUBLISH_OFF_MAIN === "1") {
|
|
59
|
+
console.warn(
|
|
60
|
+
`${YELLOW}⚠ ALLOW_PUBLISH_OFF_MAIN=1: main マージ確認をバイパスして ` +
|
|
61
|
+
`publish します (緊急回避)。${RESET}`,
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// git リポジトリ外なら判定不能 → 安全側で止める。
|
|
67
|
+
const inRepo = git(["rev-parse", "--is-inside-work-tree"], { allowFail: true })
|
|
68
|
+
if (inRepo !== "true") {
|
|
69
|
+
fail("git リポジトリ内で実行されていません (main マージ確認ができません)。")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 作業ツリーがクリーンか (未コミットの変更を含んだまま publish しない)。
|
|
73
|
+
const dirty = git(["status", "--porcelain"], { allowFail: true })
|
|
74
|
+
if (dirty) {
|
|
75
|
+
fail(
|
|
76
|
+
"作業ツリーに未コミットの変更があります。コミット & main マージしてから " +
|
|
77
|
+
"publish してください。",
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// origin/main を最新化する (オフライン時はローカルの追跡参照で判定)。
|
|
82
|
+
const fetched = git(["fetch", "origin", "main", "--quiet"], {
|
|
83
|
+
allowFail: true,
|
|
84
|
+
})
|
|
85
|
+
if (fetched === null) {
|
|
86
|
+
console.warn(
|
|
87
|
+
`${YELLOW}⚠ origin/main の fetch に失敗しました。ローカルの ` +
|
|
88
|
+
`origin/main 参照で判定します (古い可能性あり)。${RESET}`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const mainRef = git(["rev-parse", "--verify", "origin/main"], {
|
|
93
|
+
allowFail: true,
|
|
94
|
+
})
|
|
95
|
+
if (!mainRef) {
|
|
96
|
+
fail("origin/main が見つかりません (remote 設定を確認してください)。")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// HEAD が origin/main から到達可能か = main にマージ済みか。
|
|
100
|
+
const head = git(["rev-parse", "HEAD"])
|
|
101
|
+
const isMerged =
|
|
102
|
+
git(["merge-base", "--is-ancestor", "HEAD", "origin/main"], {
|
|
103
|
+
allowFail: true,
|
|
104
|
+
}) !== null
|
|
105
|
+
// merge-base --is-ancestor は exit code で結果を返す (0=ancestor / 1=not)。
|
|
106
|
+
// allowFail で 1 のとき null になるため、null=未マージと判定する。
|
|
107
|
+
|
|
108
|
+
if (!isMerged) {
|
|
109
|
+
const headShort = head.slice(0, 8)
|
|
110
|
+
const mainShort = mainRef.slice(0, 8)
|
|
111
|
+
fail(
|
|
112
|
+
`現在の HEAD (${headShort}) は origin/main (${mainShort}) に未マージです。` +
|
|
113
|
+
"publish は main にマージしてから行ってください。",
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(
|
|
118
|
+
`${GREEN}✔ publish ガード: HEAD は origin/main にマージ済み。publish を続行します。${RESET}`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main()
|
|
@@ -62,6 +62,14 @@ const CHAT_RESIDENT_RESUME_ENABLED =
|
|
|
62
62
|
* 単一 stream_id 挙動と完全に一致する (this.streamIds は未使用のまま)。 */
|
|
63
63
|
const CHAT_SHARED_ENABLED = process.env.HUB_AGENT_CHAT_SHARED === "1"
|
|
64
64
|
|
|
65
|
+
/** B11: 多端末共有時、一定期間 input/permission 等の活性が無い購読端末キーを死端末とみなして
|
|
66
|
+
* GC する閾値 (ミリ秒)。端末がクラッシュして claude.detach を送らず消えると streamIds /
|
|
67
|
+
* sessions に stream_id が永久残留し、最後の 1 台が外れないと idle softDetach が起動しない。
|
|
68
|
+
* 正常に開いている端末は input が無くても WS heartbeat 等で活性更新されないため、閾値は
|
|
69
|
+
* 長め (既定 1 時間) にして「明らかに死んだ」端末のみを掃除する。env で調整可能。 */
|
|
70
|
+
const DEAD_TERMINAL_TTL_MS =
|
|
71
|
+
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
|
|
72
|
+
|
|
65
73
|
/** 文字列を SDK streaming input の SDKUserMessage に包む。
|
|
66
74
|
* SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
|
|
67
75
|
* 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
|
|
@@ -153,6 +161,12 @@ class ClaudeStreamSession {
|
|
|
153
161
|
* 参照され、reattach で増え detach で減る。最後の 1 台が外れると idle softDetach に入る。
|
|
154
162
|
* this.stream_id は「最も新しく attach した端末」= legacy emit / primary 用に残す。 */
|
|
155
163
|
this.streamIds = new Set([stream_id])
|
|
164
|
+
/** B11: 購読端末ごとの最終アクティビティ時刻 (epoch ms)。attach / reattach / input /
|
|
165
|
+
* permission 応答で更新する。CHAT_SHARED_ENABLED 時に「死端末 GC」が参照し、一定期間
|
|
166
|
+
* 無活動の購読端末キーを掃除する。これが無いと端末がクラッシュして claude.detach を
|
|
167
|
+
* 送らず消えると stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと
|
|
168
|
+
* idle softDetach が起動しないため死端末がセッションを永久に生かしてしまう。 */
|
|
169
|
+
this.lastActivityByStream = new Map([[stream_id, Date.now()]])
|
|
156
170
|
this.cwd = cwd
|
|
157
171
|
this.model = model || null
|
|
158
172
|
this.permissionMode = permissionMode || null
|
|
@@ -209,6 +223,11 @@ class ClaudeStreamSession {
|
|
|
209
223
|
/** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
|
|
210
224
|
* 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
|
|
211
225
|
this._lastEmittedQueueSig = "0:"
|
|
226
|
+
/** ultracode (0.6.28): 常駐 query へ現在適用済みの ultracode 状態。ターン単位の
|
|
227
|
+
* ワンショット適用を applyFlagSettings で reconcile する際の差分判定に使う。
|
|
228
|
+
* query を (再)起動すると flag settings は既定に戻るため、_runResidentQuery 冒頭で
|
|
229
|
+
* false に戻す。 */
|
|
230
|
+
this._ultracodeCurrent = false
|
|
212
231
|
|
|
213
232
|
/** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
|
|
214
233
|
this._permissionResolvers = new Map()
|
|
@@ -290,6 +309,8 @@ class ClaudeStreamSession {
|
|
|
290
309
|
const r = this._permissionResolvers.get(request_id)
|
|
291
310
|
if (!r) return false
|
|
292
311
|
this._permissionResolvers.delete(request_id)
|
|
312
|
+
// B11: 現 primary 端末からの permission 応答を活性として記録する。
|
|
313
|
+
this.touch(this.stream_id)
|
|
293
314
|
r.resolve(decision)
|
|
294
315
|
return true
|
|
295
316
|
}
|
|
@@ -310,6 +331,7 @@ class ClaudeStreamSession {
|
|
|
310
331
|
// 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
|
|
311
332
|
// bridge.attach 側で sessions Map に残されるため、ここでは増やすだけでよい。
|
|
312
333
|
this.streamIds.add(stream_id)
|
|
334
|
+
this.touch(stream_id) // B11: 死端末 GC 用の最終アクティビティ更新
|
|
313
335
|
this._detached = false
|
|
314
336
|
if (this._idleTimer) {
|
|
315
337
|
clearTimeout(this._idleTimer)
|
|
@@ -318,6 +340,13 @@ class ClaudeStreamSession {
|
|
|
318
340
|
if (opts) this.applyRuntimeOptions(opts)
|
|
319
341
|
}
|
|
320
342
|
|
|
343
|
+
/** B11: 端末の最終アクティビティ時刻を更新する (死端末 GC の生存判定に使う)。 */
|
|
344
|
+
touch(stream_id) {
|
|
345
|
+
if (stream_id && this.streamIds.has(stream_id)) {
|
|
346
|
+
this.lastActivityByStream.set(stream_id, Date.now())
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
321
350
|
/** 改修5: モデル/権限/拡張思考をランタイムに切り替える。
|
|
322
351
|
*
|
|
323
352
|
* - 保持フィールド (this.model/permissionMode/maxThinkingTokens) を更新する。これは
|
|
@@ -445,20 +474,35 @@ class ClaudeStreamSession {
|
|
|
445
474
|
* 既存ターン実行中 (busy) は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
446
475
|
* (改修3)。常駐 query 対象 (新規セッション) は InputQueue へ積む (改修2)。
|
|
447
476
|
*/
|
|
448
|
-
async sendMessage(message) {
|
|
477
|
+
async sendMessage(message, opts = {}) {
|
|
449
478
|
if (this._closed) return
|
|
450
479
|
const prompt = extractPromptText(message)
|
|
451
480
|
if (!prompt) return
|
|
452
481
|
|
|
482
|
+
// ultracode ワンショット (0.6.28): このターンのみ xhigh effort + 常時 dynamic-workflow
|
|
483
|
+
// オーケストレーションを有効化する。セッション既定としては持たず (トークン消費が
|
|
484
|
+
// 桁違いになるため)、ターン単位でトグルし、完了後は通常状態へ戻す。
|
|
485
|
+
// per-message では options.settings に乗せ、resident では applyFlagSettings で
|
|
486
|
+
// ターン前に ON / 次ターン前に OFF へ reconcile する (詳細は _reconcileResidentUltracode)。
|
|
487
|
+
const ultracode = opts.ultracode === true
|
|
488
|
+
|
|
453
489
|
// 改修2+4: 常駐query対象セッション。
|
|
454
490
|
if (this._residentEligible) {
|
|
455
|
-
|
|
491
|
+
// B6: 死亡後の (再)起動経路。既に query を起動した実績があり (_residentStarted)、かつ
|
|
492
|
+
// 現在 query が無い (_residentQuery=null = 異常終了済み) 場合、既存 _inputQueue は前 query で
|
|
493
|
+
// consume し切った (generator return 済み) インスタンス。同じものを次の sdk.query に渡すと
|
|
494
|
+
// _q/_wake の残留が新 generator と競合し得るため、push 前に必ず作り直す。
|
|
495
|
+
if (this._residentStarted && !this._residentQuery) {
|
|
496
|
+
this._inputQueue = new InputQueue()
|
|
497
|
+
} else if (!this._inputQueue) {
|
|
498
|
+
this._inputQueue = new InputQueue()
|
|
499
|
+
}
|
|
456
500
|
// 改修4 (A): ターンのシリアライズ。実行中ターン (busy) は InputQueue へ即 push せず
|
|
457
501
|
// pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
|
|
458
502
|
// 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
|
|
459
503
|
// pending は queue_state で UI (送信待ちチップ) に出す (per-message と同じ体験)。
|
|
460
504
|
if (this._busy) {
|
|
461
|
-
this._enqueuePending(prompt)
|
|
505
|
+
this._enqueuePending(prompt, ultracode)
|
|
462
506
|
this.logger?.info(
|
|
463
507
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
464
508
|
"resident busy, message queued",
|
|
@@ -467,14 +511,18 @@ class ClaudeStreamSession {
|
|
|
467
511
|
return
|
|
468
512
|
}
|
|
469
513
|
this._busy = true
|
|
470
|
-
this._inputQueue.push(toSDKUserMessage(prompt))
|
|
471
514
|
// 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
|
|
472
515
|
// なら (再)起動する。_runResidentQuery は起動時 options.resume=this.sessionId で文脈を
|
|
473
516
|
// 復元するため、途中死からの復活でも過去コンテキストは失われない。
|
|
517
|
+
// ultracode のとき: query を先に起動 (空 InputQueue なので入力待ちでブロック) し、
|
|
518
|
+
// applyFlagSettings を await してから push することで、設定適用前にターンが
|
|
519
|
+
// 消費されるレースを防ぐ。
|
|
474
520
|
if (!this._residentQuery) {
|
|
475
521
|
this._residentStarted = true
|
|
476
522
|
this._startResidentQuery()
|
|
477
523
|
}
|
|
524
|
+
await this._reconcileResidentUltracode(ultracode)
|
|
525
|
+
this._inputQueue.push(toSDKUserMessage(prompt))
|
|
478
526
|
return
|
|
479
527
|
}
|
|
480
528
|
|
|
@@ -482,7 +530,7 @@ class ClaudeStreamSession {
|
|
|
482
530
|
// 改修3: busy 中の送信は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
483
531
|
// (ターミナル流の「積む→待機→順次実行」)。常駐 query 化はしないので暴走リスクは増えない。
|
|
484
532
|
if (this._busy) {
|
|
485
|
-
this._enqueuePending(prompt)
|
|
533
|
+
this._enqueuePending(prompt, ultracode)
|
|
486
534
|
this.logger?.info(
|
|
487
535
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
488
536
|
"claude busy, message queued",
|
|
@@ -490,12 +538,12 @@ class ClaudeStreamSession {
|
|
|
490
538
|
this._emitQueueState()
|
|
491
539
|
return
|
|
492
540
|
}
|
|
493
|
-
return this._runPerMessage(prompt)
|
|
541
|
+
return this._runPerMessage(prompt, { ultracode })
|
|
494
542
|
}
|
|
495
543
|
|
|
496
544
|
/** per-message 1 ターンを実行する (resume チェーン)。busy 中に届いた送信は sendMessage
|
|
497
545
|
* が pending キューへ退避し、本メソッドの finally で drain する。 */
|
|
498
|
-
async _runPerMessage(prompt) {
|
|
546
|
+
async _runPerMessage(prompt, opts = {}) {
|
|
499
547
|
this._busy = true
|
|
500
548
|
this._abortController = new AbortController()
|
|
501
549
|
let aborted = false
|
|
@@ -512,6 +560,16 @@ class ClaudeStreamSession {
|
|
|
512
560
|
if (this.maxTurns != null) options.maxTurns = this.maxTurns
|
|
513
561
|
// 思考オプション (effort / adaptive thinking / 旧 budget) はモデルに応じて切替。
|
|
514
562
|
this._applyThinkingOptions(options)
|
|
563
|
+
// ultracode ワンショット (0.6.28): per-message は 1 query = 1 ターンなので、
|
|
564
|
+
// このターンの options.settings (= --settings 相当 / flag settings 層) に乗せるだけで
|
|
565
|
+
// 自然に 1 ターン限定になる。次ターンは options を作り直すため通常状態に戻る。
|
|
566
|
+
if (opts.ultracode === true) {
|
|
567
|
+
options.settings = {
|
|
568
|
+
...(options.settings || {}),
|
|
569
|
+
ultracode: true,
|
|
570
|
+
enableWorkflows: true,
|
|
571
|
+
}
|
|
572
|
+
}
|
|
515
573
|
// 直前ターンまでの session_id があれば resume チェーン
|
|
516
574
|
if (this.sessionId) options.resume = this.sessionId
|
|
517
575
|
|
|
@@ -613,18 +671,21 @@ class ClaudeStreamSession {
|
|
|
613
671
|
}
|
|
614
672
|
const next = this._pendingMessages.shift()
|
|
615
673
|
this._emitQueueState([next.text])
|
|
616
|
-
this._runPerMessage(next.text).catch(
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
674
|
+
this._runPerMessage(next.text, { ultracode: next.ultracode === true }).catch(
|
|
675
|
+
(err) => {
|
|
676
|
+
this.logger?.error(
|
|
677
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
678
|
+
"drain runPerMessage threw",
|
|
679
|
+
)
|
|
680
|
+
},
|
|
681
|
+
)
|
|
622
682
|
}
|
|
623
683
|
|
|
624
|
-
/** キャンセル機能 (0.6.26): pending キューへ安定 id 付きで 1 件積む。
|
|
625
|
-
|
|
684
|
+
/** キャンセル機能 (0.6.26): pending キューへ安定 id 付きで 1 件積む。
|
|
685
|
+
* ultracode (0.6.28): ワンショット ultracode フラグもエントリに保持し、drain 時に伝播する。 */
|
|
686
|
+
_enqueuePending(prompt, ultracode = false) {
|
|
626
687
|
const id = `q${++this._queueSeq}`
|
|
627
|
-
this._pendingMessages.push({ id, text: prompt })
|
|
688
|
+
this._pendingMessages.push({ id, text: prompt, ultracode: ultracode === true })
|
|
628
689
|
return id
|
|
629
690
|
}
|
|
630
691
|
|
|
@@ -685,18 +746,62 @@ class ClaudeStreamSession {
|
|
|
685
746
|
})
|
|
686
747
|
}
|
|
687
748
|
|
|
749
|
+
/** ultracode (0.6.28): 常駐 query の ultracode 状態を目標値へ寄せる (差分時のみ
|
|
750
|
+
* applyFlagSettings を発行)。push の前に await して、設定適用前にターンが消費される
|
|
751
|
+
* レースを防ぐ。query 未起動 / applyFlagSettings 非対応 SDK では no-op。 */
|
|
752
|
+
async _reconcileResidentUltracode(desired) {
|
|
753
|
+
const want = desired === true
|
|
754
|
+
if (want === this._ultracodeCurrent) return
|
|
755
|
+
const q = this._residentQuery
|
|
756
|
+
if (!q || typeof q.applyFlagSettings !== "function") return
|
|
757
|
+
try {
|
|
758
|
+
await q.applyFlagSettings(
|
|
759
|
+
want
|
|
760
|
+
? { ultracode: true, enableWorkflows: true }
|
|
761
|
+
: { ultracode: false },
|
|
762
|
+
)
|
|
763
|
+
this._ultracodeCurrent = want
|
|
764
|
+
this.logger?.info(
|
|
765
|
+
{ stream_id: this.stream_id, ultracode: want },
|
|
766
|
+
"resident ultracode reconciled",
|
|
767
|
+
)
|
|
768
|
+
} catch (err) {
|
|
769
|
+
this.logger?.warn(
|
|
770
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
771
|
+
"applyFlagSettings ultracode failed",
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
688
776
|
/** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
|
|
689
|
-
* queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
|
|
690
|
-
|
|
777
|
+
* queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
|
|
778
|
+
* ultracode (0.6.28): 次ターンの目標 ultracode 状態へ reconcile してから push する
|
|
779
|
+
* (await するため async 化。result ハンドラからは fire-and-forget で呼ばれる)。 */
|
|
780
|
+
async _drainResidentPending() {
|
|
691
781
|
if (this._closed) return
|
|
692
782
|
if (this._pendingMessages.length === 0) {
|
|
693
783
|
this._emitQueueState()
|
|
694
784
|
return
|
|
695
785
|
}
|
|
696
786
|
const next = this._pendingMessages.shift()
|
|
787
|
+
// B5: busy をセットしてから reconcile / push / emit が例外を投げると
|
|
788
|
+
// _busy=true のまま残り、以降の drain が全て弾かれて pending が永久に積み上がる
|
|
789
|
+
// デッドロックになる。失敗時は取り出したメッセージを先頭へ戻し _busy を解除して、
|
|
790
|
+
// 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
|
|
697
791
|
this._busy = true
|
|
698
|
-
|
|
699
|
-
|
|
792
|
+
try {
|
|
793
|
+
// ultracode reconcile (0.6.28) も await で例外を投げうるので try の中に含める。
|
|
794
|
+
await this._reconcileResidentUltracode(next.ultracode === true)
|
|
795
|
+
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
796
|
+
this._emitQueueState([next.text])
|
|
797
|
+
} catch (err) {
|
|
798
|
+
this._busy = false
|
|
799
|
+
this._pendingMessages.unshift(next)
|
|
800
|
+
this.logger?.warn(
|
|
801
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
802
|
+
"drainResidentPending failed, recovered busy state",
|
|
803
|
+
)
|
|
804
|
+
}
|
|
700
805
|
}
|
|
701
806
|
|
|
702
807
|
/** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
|
|
@@ -718,6 +823,10 @@ class ClaudeStreamSession {
|
|
|
718
823
|
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
719
824
|
if (this.maxTurns != null) options.maxTurns = this.maxTurns
|
|
720
825
|
this._applyThinkingOptions(options)
|
|
826
|
+
// ultracode (0.6.28): 新規 query は flag settings 既定 (ultracode=off) で始まる。
|
|
827
|
+
// 適用済み状態の追跡を false にリセットし、次ターンの reconcile が正しく差分判定できる
|
|
828
|
+
// ようにする (異常終了→resume 再起動時にも確実にリセット)。
|
|
829
|
+
this._ultracodeCurrent = false
|
|
721
830
|
// 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
|
|
722
831
|
// query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
|
|
723
832
|
if (this.sessionId) options.resume = this.sessionId
|
|
@@ -748,7 +857,14 @@ class ClaudeStreamSession {
|
|
|
748
857
|
denyPending("turn ended")
|
|
749
858
|
this._busy = false
|
|
750
859
|
// 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
|
|
751
|
-
|
|
860
|
+
// ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
|
|
861
|
+
// async。result ハンドラ (for await ループ内) からは fire-and-forget で呼ぶ。
|
|
862
|
+
this._drainResidentPending().catch((err) =>
|
|
863
|
+
this.logger?.warn(
|
|
864
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
865
|
+
"drainResidentPending threw",
|
|
866
|
+
),
|
|
867
|
+
)
|
|
752
868
|
}
|
|
753
869
|
try {
|
|
754
870
|
this.onEvent?.(msg)
|
|
@@ -781,12 +897,25 @@ class ClaudeStreamSession {
|
|
|
781
897
|
} else if (!this._closed && this._pendingMessages.length > 0) {
|
|
782
898
|
// 改修4 (A): 異常終了 (close 以外) で pending が残っていれば resume 付きで再起動し
|
|
783
899
|
// 取りこぼしを防ぐ。_runResidentQuery が options.resume=this.sessionId で文脈を復元する。
|
|
900
|
+
// B6: 既存 _inputQueue の generator は既に return 済み (この query で消費し切った)。
|
|
901
|
+
// 同じインスタンスを次の sdk.query に再利用すると _q/_wake の残留状態が新しい
|
|
902
|
+
// generator と競合し得るため、再起動前に必ず作り直す。close() は this._inputQueue を
|
|
903
|
+
// close() するだけで参照を持ち越さないので、ここで差し替えても整合する。
|
|
904
|
+
this._inputQueue = new InputQueue()
|
|
784
905
|
const next = this._pendingMessages.shift()
|
|
785
906
|
this._busy = true
|
|
786
907
|
this._residentStarted = true
|
|
908
|
+
// ultracode (0.6.29): query を先に起動して _residentQuery を確定させてから
|
|
909
|
+
// reconcile → push する (sendMessage の通常経路と同順)。push してから起動すると
|
|
910
|
+
// applyFlagSettings を発行する先 (_residentQuery) がまだ無く、異常再起動の対象
|
|
911
|
+
// メッセージだけ ultracode フラグが落ちる (0.6.28 由来の取りこぼし) ため、ここで揃える。
|
|
912
|
+
// _startResidentQuery → _runResidentQuery は最初の await (for await) より前に
|
|
913
|
+
// 同期で _residentQuery と _ultracodeCurrent=false を確定するので、直後の reconcile が
|
|
914
|
+
// 正しく差分判定できる。_reconcileResidentUltracode は内部で例外を握るため throw しない。
|
|
915
|
+
this._startResidentQuery()
|
|
916
|
+
await this._reconcileResidentUltracode(next.ultracode === true)
|
|
787
917
|
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
788
918
|
this._emitQueueState([next.text])
|
|
789
|
-
this._startResidentQuery()
|
|
790
919
|
}
|
|
791
920
|
}
|
|
792
921
|
}
|
|
@@ -1030,14 +1159,16 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1030
1159
|
}
|
|
1031
1160
|
|
|
1032
1161
|
/** browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。 */
|
|
1033
|
-
input({ stream_id, message }) {
|
|
1162
|
+
input({ stream_id, message, ultracode }) {
|
|
1034
1163
|
const s = this.sessions.get(stream_id)
|
|
1035
1164
|
if (!s) {
|
|
1036
1165
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
1037
1166
|
return false
|
|
1038
1167
|
}
|
|
1168
|
+
s.touch(stream_id) // B11: この端末の最終アクティビティを更新 (死端末 GC 用)
|
|
1039
1169
|
// 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
|
|
1040
|
-
|
|
1170
|
+
// ultracode (0.6.28): このメッセージのみ ultracode ワンショットを適用するフラグ。
|
|
1171
|
+
s.sendMessage(message, { ultracode: ultracode === true }).catch((err) => {
|
|
1041
1172
|
this.logger?.error(
|
|
1042
1173
|
{ stream_id, err: err?.message },
|
|
1043
1174
|
"claude sendMessage threw unexpectedly",
|
|
@@ -1092,11 +1223,13 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1092
1223
|
// idle softDetach に進む。無効時は streamIds が常に 1 要素なので即 softDetach に落ちる。
|
|
1093
1224
|
if (CHAT_SHARED_ENABLED) {
|
|
1094
1225
|
s.streamIds.delete(stream_id)
|
|
1226
|
+
s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
|
|
1095
1227
|
if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
|
|
1096
1228
|
if (s.streamIds.size > 0) {
|
|
1097
1229
|
// primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
|
|
1230
|
+
// 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
|
|
1098
1231
|
if (s.stream_id === stream_id) {
|
|
1099
|
-
s.stream_id = Array.from(s.streamIds)
|
|
1232
|
+
s.stream_id = Array.from(s.streamIds).at(-1)
|
|
1100
1233
|
}
|
|
1101
1234
|
return true
|
|
1102
1235
|
}
|
|
@@ -1117,6 +1250,45 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1117
1250
|
return true
|
|
1118
1251
|
}
|
|
1119
1252
|
|
|
1253
|
+
/** B11: 多端末共有時に「死端末」(一定期間 input/permission 等の活性が無い購読端末) を
|
|
1254
|
+
* GC する。端末がクラッシュして claude.detach を送らず消えた stream_id を掃除し、最後の
|
|
1255
|
+
* 1 台が外れたら通常の idle softDetach (走行中は完走を待つ) に進ませる。state loop など
|
|
1256
|
+
* 既存の定期ループから呼ぶ想定。CHAT_SHARED_ENABLED 無効時は何もしない。
|
|
1257
|
+
* @param {number} [ttlMs] 死端末とみなす無活性閾値 (テスト用に上書き可)。
|
|
1258
|
+
* @returns {number} GC した端末キー数。 */
|
|
1259
|
+
gcDeadTerminals(ttlMs = DEAD_TERMINAL_TTL_MS) {
|
|
1260
|
+
if (!CHAT_SHARED_ENABLED) return 0
|
|
1261
|
+
const now = Date.now()
|
|
1262
|
+
let removed = 0
|
|
1263
|
+
for (const session of new Set(this.sessions.values())) {
|
|
1264
|
+
if (session._closed) continue
|
|
1265
|
+
// 複数端末が購読しているセッションのみ対象 (1 台しか居なければ idle softDetach に任せる)。
|
|
1266
|
+
if (session.streamIds.size <= 1) continue
|
|
1267
|
+
for (const sid of Array.from(session.streamIds)) {
|
|
1268
|
+
const last = session.lastActivityByStream.get(sid) ?? 0
|
|
1269
|
+
if (now - last < ttlMs) continue
|
|
1270
|
+
// 死端末: 購読集合 / 活性記録 / sessions Map から外す。
|
|
1271
|
+
session.streamIds.delete(sid)
|
|
1272
|
+
session.lastActivityByStream.delete(sid)
|
|
1273
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
1274
|
+
removed += 1
|
|
1275
|
+
// primary が死端末だったら生存端末へ寄せ替える (.at(-1) で末尾を明示選択)。
|
|
1276
|
+
if (session.stream_id === sid && session.streamIds.size > 0) {
|
|
1277
|
+
session.stream_id = Array.from(session.streamIds).at(-1)
|
|
1278
|
+
}
|
|
1279
|
+
this.logger?.info(
|
|
1280
|
+
{ stream_id: sid, session_id: session.sessionId },
|
|
1281
|
+
"claude dead terminal GC'd (no activity past TTL)",
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
// 全端末が死端末で空になったら、idle softDetach 経路に乗せる (走行中は完走を待つ)。
|
|
1285
|
+
if (session.streamIds.size === 0) {
|
|
1286
|
+
this.detach({ stream_id: session.stream_id })
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return removed
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1120
1292
|
/** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
|
|
1121
1293
|
shutdown() {
|
|
1122
1294
|
for (const stream_id of Array.from(this.sessions.keys())) {
|
package/src/main.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, watch as fsWatch } from "node:fs"
|
|
14
14
|
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
|
|
15
|
+
import { randomUUID } from "node:crypto"
|
|
15
16
|
import os from "node:os"
|
|
16
17
|
import path from "node:path"
|
|
17
18
|
|
|
@@ -132,6 +133,16 @@ async function loadClaudeSdk(logger) {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
|
|
138
|
+
* pty 出力データ (pty.data) と resize (pty.resize) のみ true。入力系 (claude.input)・制御系
|
|
139
|
+
* (tmux.exec / permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため false
|
|
140
|
+
* (= 直列キューに残す)。1 件の tmux.exec ハングで pty 入出力まで止まるのを防ぐ。
|
|
141
|
+
*/
|
|
142
|
+
export function isFastPathMessage(type) {
|
|
143
|
+
return type === "pty.data" || type === "pty.resize"
|
|
144
|
+
}
|
|
145
|
+
|
|
135
146
|
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
136
147
|
const config = await readConfig()
|
|
137
148
|
if (!config) {
|
|
@@ -262,6 +273,22 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
262
273
|
// (`ptyBridge.on("output")`) なのでこの直列化の影響を受けない。
|
|
263
274
|
let dispatchChain = Promise.resolve()
|
|
264
275
|
client.on("message", (msg) => {
|
|
276
|
+
// B7: pty 出力データ (pty.data) と resize (pty.resize) は順序保証が不要で高頻度な
|
|
277
|
+
// 低レイテンシ経路。これらを直列キュー (dispatchChain) に通すと、1 件の tmux.exec 等
|
|
278
|
+
// のハングで pty 入出力まで全停止してしまう。安全側に「pty 出力データと resize のみ」
|
|
279
|
+
// を直列キューからバイパスして即時処理する。入力系 (claude.input)・制御系 (tmux.exec /
|
|
280
|
+
// permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
|
|
281
|
+
if (isFastPathMessage(msg?.type)) {
|
|
282
|
+
Promise.resolve(
|
|
283
|
+
dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }),
|
|
284
|
+
).catch((err) => {
|
|
285
|
+
logger.error(
|
|
286
|
+
{ err: err.message, type: msg?.type },
|
|
287
|
+
"dispatch threw (bypassed pty fast-path)",
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
}
|
|
265
292
|
dispatchChain = dispatchChain
|
|
266
293
|
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }))
|
|
267
294
|
.catch((err) => {
|
|
@@ -286,7 +313,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
286
313
|
|
|
287
314
|
// 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
|
|
288
315
|
// browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
|
|
289
|
-
const stateLoop = startStateLoop({
|
|
316
|
+
const stateLoop = startStateLoop({
|
|
317
|
+
client,
|
|
318
|
+
plugins,
|
|
319
|
+
logger,
|
|
320
|
+
intervalMs: 5_000,
|
|
321
|
+
claudeBridge,
|
|
322
|
+
})
|
|
290
323
|
// bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
|
|
291
324
|
// に書き出す UserPromptSubmit / Stop の event を fs.watch で拾って WS push する。
|
|
292
325
|
// text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
|
|
@@ -381,7 +414,8 @@ export function contextPctFromUsage(u) {
|
|
|
381
414
|
async function writeSessionEventFile(sessionName, event, at) {
|
|
382
415
|
if (!sessionName || /[/\\]/.test(sessionName)) return
|
|
383
416
|
const fp = path.join(SESSION_EVENTS_DIR, `${sessionName}.json`)
|
|
384
|
-
|
|
417
|
+
// tmp 名を randomUUID でユニーク化 (pid 固定だと同一 session への並行書込で衝突する)。
|
|
418
|
+
const tmp = `${fp}.tmp.${randomUUID()}`
|
|
385
419
|
try {
|
|
386
420
|
await mkdir(SESSION_EVENTS_DIR, { recursive: true })
|
|
387
421
|
await writeFile(tmp, JSON.stringify({ event, at }))
|
|
@@ -509,7 +543,7 @@ async function startSessionEventWatcher({ client, logger }) {
|
|
|
509
543
|
* pty.exit 受信時に処理する)
|
|
510
544
|
* - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
|
|
511
545
|
*/
|
|
512
|
-
function startStateLoop({ client, plugins, logger, intervalMs }) {
|
|
546
|
+
function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
513
547
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
514
548
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
515
549
|
let stopped = false
|
|
@@ -517,6 +551,14 @@ function startStateLoop({ client, plugins, logger, intervalMs }) {
|
|
|
517
551
|
const tick = async () => {
|
|
518
552
|
if (stopped) return
|
|
519
553
|
try {
|
|
554
|
+
// B11: 多端末共有時、クラッシュして detach を送らず消えた死端末を GC する
|
|
555
|
+
// (活性なし TTL 超過の購読端末キーを掃除し、最後の 1 台が外れたら idle softDetach へ)。
|
|
556
|
+
// CHAT_SHARED_ENABLED 無効時は no-op。例外は state loop 全体の try/catch が拾う。
|
|
557
|
+
try {
|
|
558
|
+
claudeBridge?.gcDeadTerminals?.()
|
|
559
|
+
} catch (err) {
|
|
560
|
+
logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
|
|
561
|
+
}
|
|
520
562
|
// 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
|
|
521
563
|
// 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
|
|
522
564
|
await refreshContextWindow()
|
|
@@ -781,6 +823,9 @@ async function dispatch(msg, ctx) {
|
|
|
781
823
|
ctx.claudeBridge.input({
|
|
782
824
|
stream_id: msg.stream_id,
|
|
783
825
|
message: msg.message,
|
|
826
|
+
// ultracode (0.6.28): browser がこのメッセージ単位で送るワンショット指定。
|
|
827
|
+
// true のときだけそのターンを xhigh effort + dynamic-workflow で実行する。
|
|
828
|
+
ultracode: msg.ultracode === true,
|
|
784
829
|
})
|
|
785
830
|
return
|
|
786
831
|
case "claude.upload": {
|
package/src/state.mjs
CHANGED
|
@@ -20,6 +20,37 @@ const execFileP = promisify(execFile)
|
|
|
20
20
|
|
|
21
21
|
const STATUSES = Object.freeze(["processing", "waiting", "idle"])
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* P1: tmux プロセス fork 削減用の短期/長期キャッシュ。
|
|
25
|
+
*
|
|
26
|
+
* 5s 周期の state loop が N セッション毎に capture-pane (pane scrape) +
|
|
27
|
+
* display-message (cwd) を spawn し、list_sessions も同様に二重取得していた。
|
|
28
|
+
* N セッションで毎 5s に 2N+1 プロセスを fork する負荷を、以下のキャッシュで抑える:
|
|
29
|
+
* - capture-pane: 短期キャッシュ (CAPTURE_TTL_MS < state loop 周期)。state loop と
|
|
30
|
+
* 近接時刻の list_sessions が同じ pane scrape を共有する (状態検出の鮮度は維持)。
|
|
31
|
+
* - getSessionCwd: cwd は変化が稀なので長期キャッシュ (CWD_TTL_MS)。毎 tick の
|
|
32
|
+
* display-message spawn を排除し fork 数を半減させる。
|
|
33
|
+
* TTL は env で上書き可能 (テスト/チューニング用)。0 を渡すとキャッシュ無効。
|
|
34
|
+
*/
|
|
35
|
+
const CAPTURE_TTL_MS = Number(process.env.HUB_AGENT_CAPTURE_TTL_MS ?? 2500)
|
|
36
|
+
const CWD_TTL_MS = Number(process.env.HUB_AGENT_CWD_TTL_MS ?? 60000)
|
|
37
|
+
|
|
38
|
+
/** @type {Map<string, {at: number, value: string}>} session名 → capture-pane 結果 */
|
|
39
|
+
const _captureCache = new Map()
|
|
40
|
+
/** @type {Map<string, {at: number, value: string|null}>} session名 → cwd */
|
|
41
|
+
const _cwdCache = new Map()
|
|
42
|
+
|
|
43
|
+
/** キャッシュから無効化する (session 終了時等に呼べるよう export)。 */
|
|
44
|
+
export function invalidateSessionCache(sessionName) {
|
|
45
|
+
if (sessionName == null) {
|
|
46
|
+
_captureCache.clear()
|
|
47
|
+
_cwdCache.clear()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
_captureCache.delete(sessionName)
|
|
51
|
+
_cwdCache.delete(sessionName)
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
const CONTEXT_PATTERNS = [
|
|
24
55
|
/(\d{1,3})\s*%\s*context\s*left/i,
|
|
25
56
|
/context\s*[:\-]?\s*(\d{1,3})\s*%/i,
|
|
@@ -66,6 +97,12 @@ export async function listSessionNames(opts = {}) {
|
|
|
66
97
|
|
|
67
98
|
export async function capturePane(sessionName, opts = {}) {
|
|
68
99
|
const tmuxBin = opts.tmuxBin || "tmux"
|
|
100
|
+
// P1: 短期キャッシュ。state loop と近接時刻の list_sessions の二重 capture-pane を
|
|
101
|
+
// 同一結果で共有する。opts.noCache か TTL=0 でバイパス可能。
|
|
102
|
+
if (!opts.noCache && CAPTURE_TTL_MS > 0) {
|
|
103
|
+
const hit = _captureCache.get(sessionName)
|
|
104
|
+
if (hit && Date.now() - hit.at < CAPTURE_TTL_MS) return hit.value
|
|
105
|
+
}
|
|
69
106
|
try {
|
|
70
107
|
const { stdout } = await execFileP(tmuxBin, [
|
|
71
108
|
"capture-pane",
|
|
@@ -77,7 +114,11 @@ export async function capturePane(sessionName, opts = {}) {
|
|
|
77
114
|
"-E",
|
|
78
115
|
"-",
|
|
79
116
|
])
|
|
80
|
-
|
|
117
|
+
const value = stripAnsi(stdout)
|
|
118
|
+
if (!opts.noCache && CAPTURE_TTL_MS > 0) {
|
|
119
|
+
_captureCache.set(sessionName, { at: Date.now(), value })
|
|
120
|
+
}
|
|
121
|
+
return value
|
|
81
122
|
} catch {
|
|
82
123
|
return ""
|
|
83
124
|
}
|
|
@@ -89,6 +130,12 @@ export async function capturePane(sessionName, opts = {}) {
|
|
|
89
130
|
*/
|
|
90
131
|
export async function getSessionCwd(sessionName, opts = {}) {
|
|
91
132
|
const tmuxBin = opts.tmuxBin || "tmux"
|
|
133
|
+
// P1: cwd は変化が稀なので長期キャッシュ。state loop の毎 tick spawn を排除する。
|
|
134
|
+
// opts.noCache か TTL=0 でバイパス可能。cwd 変化検知は CWD_TTL_MS 経過後の再取得で吸収。
|
|
135
|
+
if (!opts.noCache && CWD_TTL_MS > 0) {
|
|
136
|
+
const hit = _cwdCache.get(sessionName)
|
|
137
|
+
if (hit && Date.now() - hit.at < CWD_TTL_MS) return hit.value
|
|
138
|
+
}
|
|
92
139
|
try {
|
|
93
140
|
const { stdout } = await execFileP(tmuxBin, [
|
|
94
141
|
"display-message",
|
|
@@ -99,7 +146,11 @@ export async function getSessionCwd(sessionName, opts = {}) {
|
|
|
99
146
|
"#{pane_current_path}",
|
|
100
147
|
])
|
|
101
148
|
const s = stdout.trim()
|
|
102
|
-
|
|
149
|
+
const value = s || null
|
|
150
|
+
if (!opts.noCache && CWD_TTL_MS > 0) {
|
|
151
|
+
_cwdCache.set(sessionName, { at: Date.now(), value })
|
|
152
|
+
}
|
|
153
|
+
return value
|
|
103
154
|
} catch {
|
|
104
155
|
return null
|
|
105
156
|
}
|
package/src/tmux.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import path from "node:path"
|
|
|
20
20
|
import { promisify } from "node:util"
|
|
21
21
|
|
|
22
22
|
import { ensureClaudeMd } from "./claude-md.mjs"
|
|
23
|
-
import { detectSessionState } from "./state.mjs"
|
|
23
|
+
import { detectSessionState, getSessionCwd } from "./state.mjs"
|
|
24
24
|
import { getSessionUsages } from "./usage.mjs"
|
|
25
25
|
|
|
26
26
|
const execFileP = promisify(execFile)
|
|
@@ -356,22 +356,9 @@ export async function execTmux(args, opts = {}) {
|
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
"display-message",
|
|
363
|
-
"-p",
|
|
364
|
-
"-t",
|
|
365
|
-
`${name}:`,
|
|
366
|
-
"-F",
|
|
367
|
-
"#{pane_current_path}",
|
|
368
|
-
])
|
|
369
|
-
const s = stdout.trim()
|
|
370
|
-
return s || null
|
|
371
|
-
} catch {
|
|
372
|
-
return null
|
|
373
|
-
}
|
|
374
|
-
}
|
|
359
|
+
// P1 (重複排除): cwd 取得は state.mjs の getSessionCwd (キャッシュ付き) を共用する。
|
|
360
|
+
// 旧 tmux.mjs 私有実装は display-message を毎回 spawn しており、state loop の
|
|
361
|
+
// listSessionStates と list_sessions が別キャッシュ無しで二重 fork していた。
|
|
375
362
|
|
|
376
363
|
const TMUX_LIST_FIELDS = [
|
|
377
364
|
"#{session_name}",
|
package/src/usage.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
18
18
|
*/
|
|
19
19
|
import { promises as fs } from "node:fs"
|
|
20
|
+
import { randomUUID } from "node:crypto"
|
|
20
21
|
import os from "node:os"
|
|
21
22
|
import path from "node:path"
|
|
22
23
|
|
|
@@ -107,6 +108,49 @@ async function readOrNull(p) {
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* P5(perf): ファイル末尾だけを読む (tail)。jsonl の末尾 assistant.usage 1 件だけが
|
|
113
|
+
* 欲しい latestJsonlContext 用。全文 readFile + split を避けて巨大 jsonl の再パース
|
|
114
|
+
* コストを削る。末尾 maxBytes を読み、最初の改行以降 (= 完全な行のみ) を返す。
|
|
115
|
+
* size <= maxBytes ならファイル全体を返す。失敗時は null。
|
|
116
|
+
*/
|
|
117
|
+
async function readTail(fp, maxBytes = 64 * 1024) {
|
|
118
|
+
let fh
|
|
119
|
+
try {
|
|
120
|
+
fh = await fs.open(fp, "r")
|
|
121
|
+
const st = await fh.stat()
|
|
122
|
+
const size = st.size
|
|
123
|
+
if (size === 0) return ""
|
|
124
|
+
const start = size > maxBytes ? size - maxBytes : 0
|
|
125
|
+
const len = size - start
|
|
126
|
+
const buf = Buffer.allocUnsafe(len)
|
|
127
|
+
await fh.read(buf, 0, len, start)
|
|
128
|
+
let text = buf.toString("utf-8")
|
|
129
|
+
// 途中から読んだ場合、先頭の不完全な行を捨てる (最初の改行まで)。
|
|
130
|
+
if (start > 0) {
|
|
131
|
+
const nl = text.indexOf("\n")
|
|
132
|
+
text = nl >= 0 ? text.slice(nl + 1) : text
|
|
133
|
+
}
|
|
134
|
+
return text
|
|
135
|
+
} catch {
|
|
136
|
+
return null
|
|
137
|
+
} finally {
|
|
138
|
+
try {
|
|
139
|
+
await fh?.close()
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** P5(perf): latestJsonlContext の結果を {mtimeMs,size} キーでメモ化し再パースを避ける。 */
|
|
147
|
+
const _jsonlCtxMemo = new Map() // fp → { mtimeMs, size, result }
|
|
148
|
+
|
|
149
|
+
/** P5(perf): readEstimate の per-file パース結果 (assistant.usage の {ts,tok} 配列) を
|
|
150
|
+
* {mtimeMs,size} キーでメモ化する。時間窓 (5h/7d) への振り分けは呼び出し毎に now で
|
|
151
|
+
* 再計算するが、jsonl 全文の再パースは mtime 不変なら省ける (集計の重い部分はパース)。 */
|
|
152
|
+
const _estimateFileMemo = new Map() // fp → { mtimeMs, size, records: [{ts,tok}] }
|
|
153
|
+
|
|
110
154
|
// ---------------------------------------------------------------------------
|
|
111
155
|
// チャット(SDK) の rate_limit_event から取得した最新 rate-limit (プロセス内共有)。
|
|
112
156
|
// statusLine cache はターミナルでしか更新されないため、チャットモードでは
|
|
@@ -171,7 +215,9 @@ async function persistChatRateLimitsToCache() {
|
|
|
171
215
|
} catch {
|
|
172
216
|
/* ignore */
|
|
173
217
|
}
|
|
174
|
-
|
|
218
|
+
// P5(bug): tmp 名を randomUUID でユニーク化する。pid 固定 (`${p}.tmp.${pid}`) だと
|
|
219
|
+
// 同一プロセス内で複数の persist が並行すると互いの tmp を上書き/unlink して破壊し合う。
|
|
220
|
+
const tmp = `${p}.tmp.${randomUUID()}`
|
|
175
221
|
try {
|
|
176
222
|
await fs.writeFile(tmp, JSON.stringify(base))
|
|
177
223
|
await fs.rename(tmp, p)
|
|
@@ -205,8 +251,15 @@ export function recordChatRateLimit(info) {
|
|
|
205
251
|
chatRateLimits[slot] = { percent, resetAtMs }
|
|
206
252
|
chatRateLimits.updatedAtMs = Date.now()
|
|
207
253
|
// webapp フッター (ファイルベース readOfficial) 用に latest.json へ書き戻す。
|
|
208
|
-
//
|
|
209
|
-
|
|
254
|
+
// P5(bug): 前の _persistInFlight を待ってから次を実行し write を直列化する。
|
|
255
|
+
// 直列化しないと複数の persist が同一 latest.json に read→write→rename を並行実行し、
|
|
256
|
+
// 後勝ちで中間状態を読んだり tmp を破壊し合う。chatRateLimits は最新値を共有参照する
|
|
257
|
+
// ので、待ち合わせ後に走る persist は常に最新スナップショットを書く (取りこぼし無し)。
|
|
258
|
+
// 失敗は persistChatRateLimitsToCache 内で握りつぶすので chain は切れない。
|
|
259
|
+
_persistInFlight = _persistInFlight.then(
|
|
260
|
+
() => persistChatRateLimitsToCache(),
|
|
261
|
+
() => persistChatRateLimitsToCache(),
|
|
262
|
+
)
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
/**
|
|
@@ -279,6 +332,38 @@ async function readEstimate(now) {
|
|
|
279
332
|
let oldest5h = null
|
|
280
333
|
let oldest7d = null
|
|
281
334
|
|
|
335
|
+
// P5(perf): per-file の assistant.usage レコード ({ts,tok}) を mtime+size でメモ化し、
|
|
336
|
+
// jsonl 全文の再パース (重い) を省く。時間窓への振り分けだけ now で都度再計算する。
|
|
337
|
+
const perFileRecords = async (fp, st) => {
|
|
338
|
+
const memo = _estimateFileMemo.get(fp)
|
|
339
|
+
if (memo && memo.mtimeMs === st.mtimeMs && memo.size === st.size) {
|
|
340
|
+
return memo.records
|
|
341
|
+
}
|
|
342
|
+
const text = await readOrNull(fp)
|
|
343
|
+
const records = []
|
|
344
|
+
if (text) {
|
|
345
|
+
for (const line of text.split("\n")) {
|
|
346
|
+
if (!line || line.length < 50) continue
|
|
347
|
+
if (!line.includes('"usage"')) continue
|
|
348
|
+
let d
|
|
349
|
+
try {
|
|
350
|
+
d = JSON.parse(line)
|
|
351
|
+
} catch {
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
if (d.type !== "assistant") continue
|
|
355
|
+
const ts = d.timestamp ? Date.parse(d.timestamp) : 0
|
|
356
|
+
if (!ts) continue
|
|
357
|
+
const u = d.message?.usage
|
|
358
|
+
if (!u) continue
|
|
359
|
+
const tok = (u.output_tokens || 0) + (u.input_tokens || 0)
|
|
360
|
+
records.push({ ts, tok })
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
_estimateFileMemo.set(fp, { mtimeMs: st.mtimeMs, size: st.size, records })
|
|
364
|
+
return records
|
|
365
|
+
}
|
|
366
|
+
|
|
282
367
|
await Promise.all(
|
|
283
368
|
projects.map(async (p) => {
|
|
284
369
|
const dir = path.join(projectsDir(), p)
|
|
@@ -286,29 +371,16 @@ async function readEstimate(now) {
|
|
|
286
371
|
for (const f of files) {
|
|
287
372
|
if (!f.endsWith(".jsonl")) continue
|
|
288
373
|
const fp = path.join(dir, f)
|
|
374
|
+
let st
|
|
289
375
|
try {
|
|
290
|
-
|
|
376
|
+
st = await fs.stat(fp)
|
|
291
377
|
if (st.mtimeMs < t7d) continue
|
|
292
378
|
} catch {
|
|
293
379
|
continue
|
|
294
380
|
}
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!line || line.length < 50) continue
|
|
299
|
-
if (!line.includes('"usage"')) continue
|
|
300
|
-
let d
|
|
301
|
-
try {
|
|
302
|
-
d = JSON.parse(line)
|
|
303
|
-
} catch {
|
|
304
|
-
continue
|
|
305
|
-
}
|
|
306
|
-
if (d.type !== "assistant") continue
|
|
307
|
-
const ts = d.timestamp ? Date.parse(d.timestamp) : 0
|
|
308
|
-
if (!ts || ts < t7d) continue
|
|
309
|
-
const u = d.message?.usage
|
|
310
|
-
if (!u) continue
|
|
311
|
-
const tok = (u.output_tokens || 0) + (u.input_tokens || 0)
|
|
381
|
+
const records = await perFileRecords(fp, st)
|
|
382
|
+
for (const { ts, tok } of records) {
|
|
383
|
+
if (ts < t7d) continue
|
|
312
384
|
tokens7d += tok
|
|
313
385
|
msgs7d += 1
|
|
314
386
|
if (oldest7d === null || ts < oldest7d) oldest7d = ts
|
|
@@ -390,7 +462,7 @@ async function latestJsonlContext(now) {
|
|
|
390
462
|
const projects = await fs.readdir(projectsDir()).catch(() => null)
|
|
391
463
|
if (!projects) return null
|
|
392
464
|
const recent = now - CONTEXT_JSONL_RECENT_MS
|
|
393
|
-
let best = null // { mtimeMs, fp }
|
|
465
|
+
let best = null // { mtimeMs, size, fp }
|
|
394
466
|
await Promise.all(
|
|
395
467
|
projects.map(async (p) => {
|
|
396
468
|
const dir = path.join(projectsDir(), p)
|
|
@@ -401,7 +473,9 @@ async function latestJsonlContext(now) {
|
|
|
401
473
|
try {
|
|
402
474
|
const st = await fs.stat(fp)
|
|
403
475
|
if (st.mtimeMs < recent) continue
|
|
404
|
-
if (!best || st.mtimeMs > best.mtimeMs)
|
|
476
|
+
if (!best || st.mtimeMs > best.mtimeMs) {
|
|
477
|
+
best = { mtimeMs: st.mtimeMs, size: st.size, fp }
|
|
478
|
+
}
|
|
405
479
|
} catch {
|
|
406
480
|
/* ignore */
|
|
407
481
|
}
|
|
@@ -409,33 +483,50 @@ async function latestJsonlContext(now) {
|
|
|
409
483
|
}),
|
|
410
484
|
)
|
|
411
485
|
if (!best) return null
|
|
486
|
+
// P5(perf): mtime+size が前回と同じなら再パースせずメモ結果を返す。
|
|
487
|
+
const memo = _jsonlCtxMemo.get(best.fp)
|
|
488
|
+
if (memo && memo.mtimeMs === best.mtimeMs && memo.size === best.size) {
|
|
489
|
+
return memo.result
|
|
490
|
+
}
|
|
412
491
|
const windowSize = await contextWindowSize()
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
492
|
+
// 末尾から最初に見つかった assistant.usage を tokens に変換する。見つからなければ null。
|
|
493
|
+
const scan = (text) => {
|
|
494
|
+
const lines = text.split("\n")
|
|
495
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
496
|
+
const line = lines[i]
|
|
497
|
+
if (!line || !line.includes('"usage"')) continue
|
|
498
|
+
let d
|
|
499
|
+
try {
|
|
500
|
+
d = JSON.parse(line)
|
|
501
|
+
} catch {
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
if (d.type !== "assistant") continue
|
|
505
|
+
const u = d.message?.usage
|
|
506
|
+
if (!u) continue
|
|
507
|
+
const tokens =
|
|
508
|
+
(u.input_tokens || 0) +
|
|
509
|
+
(u.cache_read_input_tokens || 0) +
|
|
510
|
+
(u.cache_creation_input_tokens || 0) +
|
|
511
|
+
(u.output_tokens || 0)
|
|
512
|
+
if (tokens <= 0) continue
|
|
513
|
+
return Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
|
|
425
514
|
}
|
|
426
|
-
|
|
427
|
-
const u = d.message?.usage
|
|
428
|
-
if (!u) continue
|
|
429
|
-
const tokens =
|
|
430
|
-
(u.input_tokens || 0) +
|
|
431
|
-
(u.cache_read_input_tokens || 0) +
|
|
432
|
-
(u.cache_creation_input_tokens || 0) +
|
|
433
|
-
(u.output_tokens || 0)
|
|
434
|
-
if (tokens <= 0) continue
|
|
435
|
-
const percent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
|
|
436
|
-
return { percent, mtimeMs: best.mtimeMs }
|
|
515
|
+
return null
|
|
437
516
|
}
|
|
438
|
-
|
|
517
|
+
// P5(perf): 末尾 assistant.usage 1 件だけ欲しいので tail 読みで全文 split を避ける。
|
|
518
|
+
// tail 内に見つからなかった稀なケースのみ全文 read にフォールバックする。
|
|
519
|
+
let percent = null
|
|
520
|
+
const tail = await readTail(best.fp)
|
|
521
|
+
if (tail != null) percent = scan(tail)
|
|
522
|
+
if (percent === null) {
|
|
523
|
+
const full = await readOrNull(best.fp)
|
|
524
|
+
if (full != null) percent = scan(full)
|
|
525
|
+
}
|
|
526
|
+
const result = percent === null ? null : { percent, mtimeMs: best.mtimeMs }
|
|
527
|
+
// mtime+size をキーにメモ化 (null 結果もキャッシュして再 tail/full read を避ける)。
|
|
528
|
+
_jsonlCtxMemo.set(best.fp, { mtimeMs: best.mtimeMs, size: best.size, result })
|
|
529
|
+
return result
|
|
439
530
|
}
|
|
440
531
|
|
|
441
532
|
/**
|
package/src/ws-client.mjs
CHANGED
|
@@ -202,21 +202,29 @@ export class WsClient extends EventEmitter {
|
|
|
202
202
|
_flushPtyBuffer() {
|
|
203
203
|
if (this.ptyOutboundBuffer.length === 0) return
|
|
204
204
|
const now = Date.now()
|
|
205
|
+
// B10: バッファを退避してから処理する。送信失敗 (_sendJson が false) で break
|
|
206
|
+
// した場合、未送信の残りフレームを ptyOutboundBuffer の先頭へ戻して順序を保持する
|
|
207
|
+
// (空配列で上書きしたまま break すると残りが欠落する)。
|
|
205
208
|
const buf = this.ptyOutboundBuffer
|
|
206
209
|
this.ptyOutboundBuffer = []
|
|
207
210
|
let sent = 0
|
|
208
211
|
let expired = 0
|
|
209
|
-
for (
|
|
212
|
+
for (let i = 0; i < buf.length; i++) {
|
|
213
|
+
const entry = buf[i]
|
|
210
214
|
if (now - entry.ts > PTY_BUFFER_MAX_AGE_MS) {
|
|
211
215
|
expired += 1
|
|
212
216
|
continue
|
|
213
217
|
}
|
|
214
218
|
const ok = this._sendJson(entry.obj)
|
|
215
|
-
if (!ok)
|
|
219
|
+
if (!ok) {
|
|
220
|
+
// 未送信分 (現エントリ含む i 以降) を先頭へ戻す。次回 open / flush で再送する。
|
|
221
|
+
this.ptyOutboundBuffer = buf.slice(i).concat(this.ptyOutboundBuffer)
|
|
222
|
+
break
|
|
223
|
+
}
|
|
216
224
|
sent += 1
|
|
217
225
|
}
|
|
218
226
|
this.logger?.info(
|
|
219
|
-
{ sent, expired, total: buf.length },
|
|
227
|
+
{ sent, expired, total: buf.length, requeued: this.ptyOutboundBuffer.length },
|
|
220
228
|
"pty outbound buffer flushed"
|
|
221
229
|
)
|
|
222
230
|
}
|