@cocorograph/hub-agent 0.6.28 → 0.6.30
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 +121 -9
- package/src/main.mjs +45 -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.30",
|
|
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
|
|
@@ -295,6 +309,8 @@ class ClaudeStreamSession {
|
|
|
295
309
|
const r = this._permissionResolvers.get(request_id)
|
|
296
310
|
if (!r) return false
|
|
297
311
|
this._permissionResolvers.delete(request_id)
|
|
312
|
+
// B11: 現 primary 端末からの permission 応答を活性として記録する。
|
|
313
|
+
this.touch(this.stream_id)
|
|
298
314
|
r.resolve(decision)
|
|
299
315
|
return true
|
|
300
316
|
}
|
|
@@ -315,12 +331,26 @@ class ClaudeStreamSession {
|
|
|
315
331
|
// 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
|
|
316
332
|
// bridge.attach 側で sessions Map に残されるため、ここでは増やすだけでよい。
|
|
317
333
|
this.streamIds.add(stream_id)
|
|
334
|
+
this.touch(stream_id) // B11: 死端末 GC 用の最終アクティビティ更新
|
|
318
335
|
this._detached = false
|
|
319
336
|
if (this._idleTimer) {
|
|
320
337
|
clearTimeout(this._idleTimer)
|
|
321
338
|
this._idleTimer = null
|
|
322
339
|
}
|
|
323
340
|
if (opts) this.applyRuntimeOptions(opts)
|
|
341
|
+
// キュー再表示バグ修正 (0.6.30): 再アタッチした端末は queue_state のライブ配信を
|
|
342
|
+
// 取りこぼしている (jsonl hydrate にはキュー状態が含まれない)。現在の pending を
|
|
343
|
+
// force で再 emit し、再表示端末・後から接続した端末の送信待ちチップを復元する。
|
|
344
|
+
// started=[] なのでバブル昇格は起きずチップ更新のみ (冪等)。onEvent は新しい
|
|
345
|
+
// stream_id 宛の stream_group relay で確実に届き、session_group fanout で他端末にも届く。
|
|
346
|
+
this._emitQueueState([], { force: true })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** B11: 端末の最終アクティビティ時刻を更新する (死端末 GC の生存判定に使う)。 */
|
|
350
|
+
touch(stream_id) {
|
|
351
|
+
if (stream_id && this.streamIds.has(stream_id)) {
|
|
352
|
+
this.lastActivityByStream.set(stream_id, Date.now())
|
|
353
|
+
}
|
|
324
354
|
}
|
|
325
355
|
|
|
326
356
|
/** 改修5: モデル/権限/拡張思考をランタイムに切り替える。
|
|
@@ -464,7 +494,15 @@ class ClaudeStreamSession {
|
|
|
464
494
|
|
|
465
495
|
// 改修2+4: 常駐query対象セッション。
|
|
466
496
|
if (this._residentEligible) {
|
|
467
|
-
|
|
497
|
+
// B6: 死亡後の (再)起動経路。既に query を起動した実績があり (_residentStarted)、かつ
|
|
498
|
+
// 現在 query が無い (_residentQuery=null = 異常終了済み) 場合、既存 _inputQueue は前 query で
|
|
499
|
+
// consume し切った (generator return 済み) インスタンス。同じものを次の sdk.query に渡すと
|
|
500
|
+
// _q/_wake の残留が新 generator と競合し得るため、push 前に必ず作り直す。
|
|
501
|
+
if (this._residentStarted && !this._residentQuery) {
|
|
502
|
+
this._inputQueue = new InputQueue()
|
|
503
|
+
} else if (!this._inputQueue) {
|
|
504
|
+
this._inputQueue = new InputQueue()
|
|
505
|
+
}
|
|
468
506
|
// 改修4 (A): ターンのシリアライズ。実行中ターン (busy) は InputQueue へ即 push せず
|
|
469
507
|
// pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
|
|
470
508
|
// 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
|
|
@@ -679,13 +717,18 @@ class ClaudeStreamSession {
|
|
|
679
717
|
* @param {string[]} [started] このタイミングで pending から取り出して実行開始した
|
|
680
718
|
* メッセージ本文。drain 由来の emit でのみ渡す。frontend はこれを user バブルへ
|
|
681
719
|
* 昇格させる。キャンセル / 追加由来の emit では空 (昇格させない)。これにより
|
|
682
|
-
* 「先頭の pending をキャンセルした」のを「実行開始した」と誤認しなくなる (0.6.26)。
|
|
683
|
-
|
|
720
|
+
* 「先頭の pending をキャンセルした」のを「実行開始した」と誤認しなくなる (0.6.26)。
|
|
721
|
+
* @param {{force?: boolean}} [opts] force=true のとき署名重複チェックを無視して必ず
|
|
722
|
+
* emit する。再アタッチ時のキュー snapshot 再送 (キュー再表示バグ修正, 0.6.30) に使う。
|
|
723
|
+
* 署名が前回と同一でも browser がライブ配信を取りこぼしている可能性があるため。 */
|
|
724
|
+
_emitQueueState(started = [], opts = undefined) {
|
|
725
|
+
const force = opts?.force === true
|
|
684
726
|
const count = this._pendingMessages.length
|
|
685
727
|
// 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
|
|
686
728
|
const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
|
|
687
729
|
// started があるときは drain なので、sig 変化が無くても (理論上起きないが) 通知する。
|
|
688
|
-
|
|
730
|
+
// force のときは再アタッチ snapshot なので重複チェックを完全にバイパスする。
|
|
731
|
+
if (!force && started.length === 0 && sig === this._lastEmittedQueueSig) return
|
|
689
732
|
this._lastEmittedQueueSig = sig
|
|
690
733
|
try {
|
|
691
734
|
// messages は全文を載せる。frontend は実行開始 (drain) 時にこれを user バブルへ
|
|
@@ -752,10 +795,24 @@ class ClaudeStreamSession {
|
|
|
752
795
|
return
|
|
753
796
|
}
|
|
754
797
|
const next = this._pendingMessages.shift()
|
|
798
|
+
// B5: busy をセットしてから reconcile / push / emit が例外を投げると
|
|
799
|
+
// _busy=true のまま残り、以降の drain が全て弾かれて pending が永久に積み上がる
|
|
800
|
+
// デッドロックになる。失敗時は取り出したメッセージを先頭へ戻し _busy を解除して、
|
|
801
|
+
// 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
|
|
755
802
|
this._busy = true
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
803
|
+
try {
|
|
804
|
+
// ultracode reconcile (0.6.28) も await で例外を投げうるので try の中に含める。
|
|
805
|
+
await this._reconcileResidentUltracode(next.ultracode === true)
|
|
806
|
+
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
807
|
+
this._emitQueueState([next.text])
|
|
808
|
+
} catch (err) {
|
|
809
|
+
this._busy = false
|
|
810
|
+
this._pendingMessages.unshift(next)
|
|
811
|
+
this.logger?.warn(
|
|
812
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
813
|
+
"drainResidentPending failed, recovered busy state",
|
|
814
|
+
)
|
|
815
|
+
}
|
|
759
816
|
}
|
|
760
817
|
|
|
761
818
|
/** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
|
|
@@ -851,12 +908,25 @@ class ClaudeStreamSession {
|
|
|
851
908
|
} else if (!this._closed && this._pendingMessages.length > 0) {
|
|
852
909
|
// 改修4 (A): 異常終了 (close 以外) で pending が残っていれば resume 付きで再起動し
|
|
853
910
|
// 取りこぼしを防ぐ。_runResidentQuery が options.resume=this.sessionId で文脈を復元する。
|
|
911
|
+
// B6: 既存 _inputQueue の generator は既に return 済み (この query で消費し切った)。
|
|
912
|
+
// 同じインスタンスを次の sdk.query に再利用すると _q/_wake の残留状態が新しい
|
|
913
|
+
// generator と競合し得るため、再起動前に必ず作り直す。close() は this._inputQueue を
|
|
914
|
+
// close() するだけで参照を持ち越さないので、ここで差し替えても整合する。
|
|
915
|
+
this._inputQueue = new InputQueue()
|
|
854
916
|
const next = this._pendingMessages.shift()
|
|
855
917
|
this._busy = true
|
|
856
918
|
this._residentStarted = true
|
|
919
|
+
// ultracode (0.6.29): query を先に起動して _residentQuery を確定させてから
|
|
920
|
+
// reconcile → push する (sendMessage の通常経路と同順)。push してから起動すると
|
|
921
|
+
// applyFlagSettings を発行する先 (_residentQuery) がまだ無く、異常再起動の対象
|
|
922
|
+
// メッセージだけ ultracode フラグが落ちる (0.6.28 由来の取りこぼし) ため、ここで揃える。
|
|
923
|
+
// _startResidentQuery → _runResidentQuery は最初の await (for await) より前に
|
|
924
|
+
// 同期で _residentQuery と _ultracodeCurrent=false を確定するので、直後の reconcile が
|
|
925
|
+
// 正しく差分判定できる。_reconcileResidentUltracode は内部で例外を握るため throw しない。
|
|
926
|
+
this._startResidentQuery()
|
|
927
|
+
await this._reconcileResidentUltracode(next.ultracode === true)
|
|
857
928
|
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
858
929
|
this._emitQueueState([next.text])
|
|
859
|
-
this._startResidentQuery()
|
|
860
930
|
}
|
|
861
931
|
}
|
|
862
932
|
}
|
|
@@ -1106,6 +1176,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1106
1176
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
1107
1177
|
return false
|
|
1108
1178
|
}
|
|
1179
|
+
s.touch(stream_id) // B11: この端末の最終アクティビティを更新 (死端末 GC 用)
|
|
1109
1180
|
// 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
|
|
1110
1181
|
// ultracode (0.6.28): このメッセージのみ ultracode ワンショットを適用するフラグ。
|
|
1111
1182
|
s.sendMessage(message, { ultracode: ultracode === true }).catch((err) => {
|
|
@@ -1163,11 +1234,13 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1163
1234
|
// idle softDetach に進む。無効時は streamIds が常に 1 要素なので即 softDetach に落ちる。
|
|
1164
1235
|
if (CHAT_SHARED_ENABLED) {
|
|
1165
1236
|
s.streamIds.delete(stream_id)
|
|
1237
|
+
s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
|
|
1166
1238
|
if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
|
|
1167
1239
|
if (s.streamIds.size > 0) {
|
|
1168
1240
|
// primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
|
|
1241
|
+
// 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
|
|
1169
1242
|
if (s.stream_id === stream_id) {
|
|
1170
|
-
s.stream_id = Array.from(s.streamIds)
|
|
1243
|
+
s.stream_id = Array.from(s.streamIds).at(-1)
|
|
1171
1244
|
}
|
|
1172
1245
|
return true
|
|
1173
1246
|
}
|
|
@@ -1188,6 +1261,45 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1188
1261
|
return true
|
|
1189
1262
|
}
|
|
1190
1263
|
|
|
1264
|
+
/** B11: 多端末共有時に「死端末」(一定期間 input/permission 等の活性が無い購読端末) を
|
|
1265
|
+
* GC する。端末がクラッシュして claude.detach を送らず消えた stream_id を掃除し、最後の
|
|
1266
|
+
* 1 台が外れたら通常の idle softDetach (走行中は完走を待つ) に進ませる。state loop など
|
|
1267
|
+
* 既存の定期ループから呼ぶ想定。CHAT_SHARED_ENABLED 無効時は何もしない。
|
|
1268
|
+
* @param {number} [ttlMs] 死端末とみなす無活性閾値 (テスト用に上書き可)。
|
|
1269
|
+
* @returns {number} GC した端末キー数。 */
|
|
1270
|
+
gcDeadTerminals(ttlMs = DEAD_TERMINAL_TTL_MS) {
|
|
1271
|
+
if (!CHAT_SHARED_ENABLED) return 0
|
|
1272
|
+
const now = Date.now()
|
|
1273
|
+
let removed = 0
|
|
1274
|
+
for (const session of new Set(this.sessions.values())) {
|
|
1275
|
+
if (session._closed) continue
|
|
1276
|
+
// 複数端末が購読しているセッションのみ対象 (1 台しか居なければ idle softDetach に任せる)。
|
|
1277
|
+
if (session.streamIds.size <= 1) continue
|
|
1278
|
+
for (const sid of Array.from(session.streamIds)) {
|
|
1279
|
+
const last = session.lastActivityByStream.get(sid) ?? 0
|
|
1280
|
+
if (now - last < ttlMs) continue
|
|
1281
|
+
// 死端末: 購読集合 / 活性記録 / sessions Map から外す。
|
|
1282
|
+
session.streamIds.delete(sid)
|
|
1283
|
+
session.lastActivityByStream.delete(sid)
|
|
1284
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
1285
|
+
removed += 1
|
|
1286
|
+
// primary が死端末だったら生存端末へ寄せ替える (.at(-1) で末尾を明示選択)。
|
|
1287
|
+
if (session.stream_id === sid && session.streamIds.size > 0) {
|
|
1288
|
+
session.stream_id = Array.from(session.streamIds).at(-1)
|
|
1289
|
+
}
|
|
1290
|
+
this.logger?.info(
|
|
1291
|
+
{ stream_id: sid, session_id: session.sessionId },
|
|
1292
|
+
"claude dead terminal GC'd (no activity past TTL)",
|
|
1293
|
+
)
|
|
1294
|
+
}
|
|
1295
|
+
// 全端末が死端末で空になったら、idle softDetach 経路に乗せる (走行中は完走を待つ)。
|
|
1296
|
+
if (session.streamIds.size === 0) {
|
|
1297
|
+
this.detach({ stream_id: session.stream_id })
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return removed
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1191
1303
|
/** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
|
|
1192
1304
|
shutdown() {
|
|
1193
1305
|
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()
|
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
|
}
|