@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.27",
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
- "prepublishOnly": "npm test"
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
- if (!this._inputQueue) this._inputQueue = new InputQueue()
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((err) => {
617
- this.logger?.error(
618
- { stream_id: this.stream_id, err: err?.message },
619
- "drain runPerMessage threw",
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
- _enqueuePending(prompt) {
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
- _drainResidentPending() {
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
- this._inputQueue.push(toSDKUserMessage(next.text))
699
- this._emitQueueState([next.text])
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
- this._drainResidentPending()
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
- s.sendMessage(message).catch((err) => {
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)[s.streamIds.size - 1]
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({ client, plugins, logger, intervalMs: 5_000 })
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
- const tmp = `${fp}.tmp.${process.pid}`
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
- return stripAnsi(stdout)
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
- return s || null
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
- async function getSessionCwd(name, opts = {}) {
360
- try {
361
- const { stdout } = await execFileP(tmuxBin(opts), [
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
- const tmp = `${p}.tmp.${process.pid}`
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
- // fire-and-forget だが in-flight promise は保持 (flush 可能にする)。
209
- _persistInFlight = persistChatRateLimitsToCache()
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
- const st = await fs.stat(fp)
376
+ st = await fs.stat(fp)
291
377
  if (st.mtimeMs < t7d) continue
292
378
  } catch {
293
379
  continue
294
380
  }
295
- const text = await readOrNull(fp)
296
- if (!text) continue
297
- for (const line of text.split("\n")) {
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) best = { mtimeMs: st.mtimeMs, fp }
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
- const text = await readOrNull(best.fp)
414
- if (!text) return null
415
- const lines = text.split("\n")
416
- // 末尾から最初に見つかった assistant.usage を採用 (= 現在の文脈サイズ)
417
- for (let i = lines.length - 1; i >= 0; i--) {
418
- const line = lines[i]
419
- if (!line || !line.includes('"usage"')) continue
420
- let d
421
- try {
422
- d = JSON.parse(line)
423
- } catch {
424
- continue
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
- if (d.type !== "assistant") continue
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
- return null
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 (const entry of buf) {
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) break
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
  }