@cocorograph/hub-agent 0.8.3 → 0.8.5

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.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -19,6 +19,7 @@ import path from "node:path"
19
19
 
20
20
  import {
21
21
  MASK_ENABLED,
22
+ SECRET_PLACEHOLDER,
22
23
  collectToolUseNames,
23
24
  maskMessageObject,
24
25
  redactSecrets,
@@ -541,6 +542,203 @@ async function extractLastTurnAtMs(filePath, fallbackMtimeMs = null) {
541
542
  }
542
543
  }
543
544
 
545
+ /** サイドバー 1 行に載せる依頼 / 応答テキストの最大長。1 行 clamp 前提で少し余裕を持たせる。 */
546
+ export const LAST_TURN_TEXT_MAX = 120
547
+
548
+ /**
549
+ * message.content (string | block[]) から表示用テキストを連結する。
550
+ *
551
+ * tool_use / tool_result / thinking は「会話の主題」ではないので落とす。user 行の content が
552
+ * tool_result だけのケース (= ツール実行の戻り。ユーザーの発言ではない) は空文字になり、
553
+ * 呼び出し側がスキップして更に遡る。
554
+ */
555
+ function contentToPlainText(content) {
556
+ if (typeof content === "string") return content
557
+ if (!Array.isArray(content)) return ""
558
+ const parts = []
559
+ for (const b of content) {
560
+ if (b && b.type === "text" && typeof b.text === "string") parts.push(b.text)
561
+ }
562
+ return parts.join("\n")
563
+ }
564
+
565
+ /**
566
+ * jsonl の末尾から「最後の実ユーザー依頼」と「最後のアシスタント応答」を 1 本ずつ抜く。
567
+ *
568
+ * ## なぜ必要か (2026-07-28)
569
+ * Cockpit サイドバーは各行に「何を依頼したか」を出す。しかし frontend は選択中 1 セッション
570
+ * 分のメッセージしか保持せず、backend にも全セッションの最新メッセージを返す API が無い。
571
+ * 既存の `ClaudeSessionSummary.preview` は **最初の** user 発言なので用途が違う。そこで
572
+ * agent 側で jsonl 末尾を読み、tmux.list_sessions の行に同梱して配信する。
573
+ *
574
+ * 実装は固定チャンクで**後方へ改行境界を探索**する tail scanner。1 行が
575
+ * `LAST_TURN_MAX_LINE` を超える巨大行 (tool_result / base64) は decode せず読み飛ばし、
576
+ * その手前の正常なターンまで遡る。総走査量は `LAST_TURN_MAX_SCAN`、読み飛ばせる行数は
577
+ * `LAST_TURN_MAX_SKIPPED_LINES` で頭打ちにして、poll ごとの I/O 集中を防ぐ。
578
+ * (旧実装の「窓を倍々に広げて巨大行ごと取り込む」方式は、単一行が上限を超えると窓内に
579
+ * 改行が 1 つも現れず、手前のターンごと取りこぼすため廃止した。)
580
+ *
581
+ * user 側は `normalizePreviewText` を通し、合成メッセージ / `/clear`・`/compact` の痕跡を
582
+ * 飛ばして「次の実発言」を拾う (= 一覧が全部 /clear で始まる問題を避ける)。
583
+ *
584
+ * @param {string} filePath
585
+ * @returns {Promise<{user: string, assistant: string}>} 見つからない側は ""
586
+ */
587
+ /** 1 行の上限。これを超える行 (巨大 tool_result / base64) は decode せず読み飛ばす。 */
588
+ export const LAST_TURN_MAX_LINE = 1024 * 1024
589
+ /** 1 回の呼び出しで読む総バイト数の上限 (巨大行の読み飛ばしを含む I/O 予算)。 */
590
+ export const LAST_TURN_MAX_SCAN = 8 * 1024 * 1024
591
+ /** 読み飛ばしてよい巨大行の本数。超えたら諦める (poll ごとの I/O 集中を抑える)。 */
592
+ export const LAST_TURN_MAX_SKIPPED_LINES = 8
593
+
594
+ const NEWLINE_BYTE = 0x0a
595
+
596
+ /** Buffer を改行 (0x0A) で分割して Buffer 配列にする。UTF-8 の継続バイトに 0x0A は現れない。 */
597
+ function splitLinesBuf(buf) {
598
+ const out = []
599
+ let start = 0
600
+ for (let i = 0; i < buf.length; i++) {
601
+ if (buf[i] === NEWLINE_BYTE) {
602
+ out.push(buf.subarray(start, i))
603
+ start = i + 1
604
+ }
605
+ }
606
+ out.push(buf.subarray(start))
607
+ return out
608
+ }
609
+
610
+ /**
611
+ * preview テキストの秘匿情報を伏せる。**必ず切り詰めより前に、完全な本文へ適用する。**
612
+ *
613
+ * ⚠️ 順序が肝 (2026-07-28 のクロスレビューで実証された事故): 先に 80/120 文字へ切ると
614
+ * PEM の `-----END ... PRIVATE KEY-----` が落ち、`redactSecrets` の非貪欲マッチが不成立に
615
+ * なって **鍵の先頭断片がそのまま preview に出る**。マスク → 正規化 → 切り詰めの順にする。
616
+ *
617
+ * サイドバー配信は新設の露出面なので `MASK_ENABLED` に関係なく常にマスクする
618
+ * (120 文字のプレビューに秘匿情報が必要な場面は無い)。閉じていない PEM opener が残る
619
+ * ケースは、そこから先を保守的に全て伏せる。
620
+ */
621
+ function maskPreviewText(raw) {
622
+ if (!raw) return ""
623
+ let out = redactSecrets(raw)
624
+ const openerAt = out.indexOf("-----BEGIN")
625
+ if (openerAt >= 0) out = out.slice(0, openerAt) + SECRET_PLACEHOLDER
626
+ return out
627
+ }
628
+
629
+ /** assistant テキスト用: マスク → 空白圧縮 → 切り詰め。 */
630
+ function sanitizeAssistantText(raw) {
631
+ const masked = maskPreviewText(raw).trim()
632
+ if (!masked) return ""
633
+ return masked.replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
634
+ }
635
+
636
+ /**
637
+ * 1 行 (JSON) から user / assistant テキストを取り出し、未取得の側だけを埋める。
638
+ * 既に埋まっている側は触らない (末尾に近い方を優先するため)。
639
+ */
640
+ function takeTextsFromLine(line, user, assistant) {
641
+ if (!line) return { user, assistant }
642
+ let obj
643
+ try {
644
+ obj = JSON.parse(line)
645
+ } catch {
646
+ return { user, assistant }
647
+ }
648
+ if (!obj || !obj.message) return { user, assistant }
649
+ if (obj.type === "assistant" && !assistant) {
650
+ const t = sanitizeAssistantText(contentToPlainText(obj.message.content))
651
+ if (t) assistant = t
652
+ return { user, assistant }
653
+ }
654
+ if (obj.type === "user" && !user) {
655
+ // isMeta / isSidechain はハーネス注入や sub-agent の発言でユーザーの依頼ではない。
656
+ if (obj.isMeta === true || obj.isSidechain === true) return { user, assistant }
657
+ // マスクは正規化・切り詰めより前 (maskPreviewText の docstring 参照)。
658
+ const masked = maskPreviewText(contentToPlainText(obj.message.content))
659
+ // normalizePreviewText が null を返す行 (合成 / /clear / local-command) はスキップし、
660
+ // 更に過去へ遡って「実際に打った依頼」を探す。
661
+ const norm = normalizePreviewText(masked)
662
+ if (norm) user = norm.slice(0, LAST_TURN_TEXT_MAX)
663
+ }
664
+ return { user, assistant }
665
+ }
666
+
667
+ export async function extractLastTurnTexts(filePath) {
668
+ let handle
669
+ try {
670
+ handle = await open(filePath, "r")
671
+ const st = await handle.stat()
672
+ if (st.size <= 0) return { user: "", assistant: "" }
673
+
674
+ let pos = st.size // まだ読んでいない領域の右端 (exclusive)
675
+ let carry = null // 左端が未確定の行断片 (次のチャンクと連結して確定させる)
676
+ let skippingHugeLine = false // 上限超の行の途中を読み飛ばし中
677
+ let skipped = 0
678
+ let scanned = 0
679
+ let user = ""
680
+ let assistant = ""
681
+
682
+ // ⚠️ 「窓を倍々に広げて巨大行ごと取り込む」旧方式は、単一行が上限を超えると窓内に
683
+ // 改行が 1 つも現れず、その手前にある正常なターンまで丸ごと取りこぼした
684
+ // (4MB+1KB の tool_result で両方空になる事故)。固定チャンクで後方へ改行境界を
685
+ // 探索し、上限超の行は decode せず読み飛ばす方式に変える。
686
+ while (pos > 0 && scanned < LAST_TURN_MAX_SCAN) {
687
+ const len = Math.min(LAST_TURN_TAIL_CHUNK, pos)
688
+ pos -= len
689
+ const buf = Buffer.allocUnsafe(len)
690
+ const { bytesRead } = await handle.read(buf, 0, len, pos)
691
+ scanned += bytesRead
692
+ const chunk = buf.subarray(0, bytesRead)
693
+ const region = carry && carry.length ? Buffer.concat([chunk, carry]) : chunk
694
+ carry = null
695
+
696
+ const segs = splitLinesBuf(region)
697
+ let endIdx = segs.length // exclusive
698
+ if (skippingHugeLine) {
699
+ // 末尾 seg は読み飛ばし中の巨大行の続き → 捨てる。
700
+ endIdx -= 1
701
+ // 改行が 1 つでもあれば巨大行の左端に到達したので読み飛ばしを終える。
702
+ if (segs.length > 1) skippingHugeLine = false
703
+ }
704
+ let startIdx = 0
705
+ if (pos > 0) {
706
+ // 先頭 seg は左端未確定 → 次チャンクへ持ち越す。pos===0 なら完全な行。
707
+ carry = segs[0]
708
+ startIdx = 1
709
+ }
710
+
711
+ for (let i = endIdx - 1; i >= startIdx; i--) {
712
+ const lineBuf = segs[i]
713
+ if (lineBuf.length === 0) continue
714
+ if (lineBuf.length > LAST_TURN_MAX_LINE) {
715
+ skipped++
716
+ continue
717
+ }
718
+ const res = takeTextsFromLine(lineBuf.toString("utf-8"), user, assistant)
719
+ user = res.user
720
+ assistant = res.assistant
721
+ if (user && assistant) break
722
+ }
723
+ if (user && assistant) break
724
+ if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
725
+
726
+ if (carry && carry.length > LAST_TURN_MAX_LINE) {
727
+ // 単一行が上限超 → その行は諦め、更に左の改行まで decode せず読み飛ばす。
728
+ carry = null
729
+ skippingHugeLine = true
730
+ skipped++
731
+ if (skipped > LAST_TURN_MAX_SKIPPED_LINES) break
732
+ }
733
+ }
734
+ return { user, assistant }
735
+ } catch {
736
+ return { user: "", assistant: "" }
737
+ } finally {
738
+ await handle?.close()
739
+ }
740
+ }
741
+
544
742
  /**
545
743
  * cwd 配下の全 jsonl ファイルから session 一覧を返す。
546
744
  * 各ファイルから session_id / 最終更新時刻 / サイズ / 最初の user message preview を抽出。
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Hub → agent の inbound dispatch を、接続世代と機能レーン単位で直列化する。
3
+ *
4
+ * 旧実装は全 message を 1 本の Promise chain に載せていたため、ある cwd の重い
5
+ * tmux/codex 処理が、無関係な cwd の claude.attach や reconnect の ready/sync まで
6
+ * 数分止める head-of-line blocking を起こしていた。
7
+ *
8
+ * 不変条件:
9
+ * - 同一 cwd・同一 CLI の chat 操作は受信順を維持する。
10
+ * - tmux/control と Claude/Codex、別 cwd 同士は互いに待たない。
11
+ * - ready / streams sync 等の接続ライフサイクルはキューを待たない。
12
+ * - WS 世代が変わったら、旧世代で未実行の操作を適用しない。
13
+ * - detach 済み stream の未実行 attach を適用しない。
14
+ */
15
+
16
+ const LIFECYCLE_TYPES = new Set([
17
+ "ready",
18
+ "error",
19
+ "agent.streams.sync.response",
20
+ "bundle.sync.request",
21
+ "agent.shutdown",
22
+ ])
23
+
24
+ const MAX_STREAM_LANE_KEYS = 4096
25
+
26
+ export function isConnectionLifecycleMessage(type) {
27
+ return LIFECYCLE_TYPES.has(type)
28
+ }
29
+
30
+ function chatMode(type) {
31
+ if (typeof type !== "string") return null
32
+ if (type === "claude.attach" || type.startsWith("claude.")) return "claude"
33
+ if (type === "codex.attach" || type.startsWith("codex.")) return "codex"
34
+ return null
35
+ }
36
+
37
+ function globalMutationMode(type) {
38
+ if (type === "profile.switch" || type === "profile.add" || type === "profile.rename") {
39
+ return "claude"
40
+ }
41
+ if (
42
+ type === "codex.profile.switch" ||
43
+ type === "codex.profile.add" ||
44
+ type === "codex.profile.rename"
45
+ ) {
46
+ return "codex"
47
+ }
48
+ return null
49
+ }
50
+
51
+ function attachLaneKey(mode, msg) {
52
+ const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
53
+ if (cwd) return `${mode}:cwd:${cwd}`
54
+ const resumeId =
55
+ mode === "claude"
56
+ ? msg?.resume_session_id
57
+ : msg?.resume_thread_id || msg?.thread_id
58
+ if (typeof resumeId === "string" && resumeId) return `${mode}:resume:${resumeId}`
59
+ const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
60
+ return streamId ? `${mode}:stream:${streamId}` : `${mode}:control`
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * run: (msg: object) => Promise<void>|void,
66
+ * logger?: {debug?: Function, warn?: Function, error?: Function},
67
+ * shouldBypass?: (type: string|undefined) => boolean,
68
+ * }} options
69
+ */
70
+ export class DispatchScheduler {
71
+ constructor({ run, logger, shouldBypass }) {
72
+ if (typeof run !== "function") throw new TypeError("DispatchScheduler run is required")
73
+ this.run = run
74
+ this.logger = logger
75
+ this.shouldBypass = typeof shouldBypass === "function" ? shouldBypass : () => false
76
+ this.generation = 0
77
+ this.sequence = 0
78
+ this.laneTails = new Map()
79
+ this.streamLaneKeys = new Map()
80
+ this.detachedAtByStream = new Map()
81
+ }
82
+
83
+ /**
84
+ * WS close/open で世代を進める。既に走り始めた処理は止められないが、旧 tail を
85
+ * 新接続へ継承せず、まだ始まっていない旧世代 task は実行直前に drop する。
86
+ */
87
+ advanceGeneration(reason = "connection_changed") {
88
+ this.generation += 1
89
+ this.laneTails.clear()
90
+ this.streamLaneKeys.clear()
91
+ this.detachedAtByStream.clear()
92
+ this.logger?.debug?.(
93
+ { generation: this.generation, reason },
94
+ "dispatch generation advanced",
95
+ )
96
+ return this.generation
97
+ }
98
+
99
+ _rememberStreamLane(streamId, laneKey) {
100
+ const mode = laneKey.startsWith("codex:") ? "codex" : "claude"
101
+ const streamKey = streamId ? `${mode}:${streamId}` : ""
102
+ if (!streamKey) return
103
+ this.streamLaneKeys.set(streamKey, laneKey)
104
+ while (this.streamLaneKeys.size > MAX_STREAM_LANE_KEYS) {
105
+ const oldest = this.streamLaneKeys.keys().next().value
106
+ if (oldest == null) break
107
+ this.streamLaneKeys.delete(oldest)
108
+ this.detachedAtByStream.delete(oldest)
109
+ }
110
+ }
111
+
112
+ _rememberDetach(streamKey, sequence) {
113
+ if (!streamKey) return
114
+ this.detachedAtByStream.set(streamKey, sequence)
115
+ while (this.detachedAtByStream.size > MAX_STREAM_LANE_KEYS) {
116
+ const oldest = this.detachedAtByStream.keys().next().value
117
+ if (oldest == null) break
118
+ this.detachedAtByStream.delete(oldest)
119
+ }
120
+ }
121
+
122
+ _laneFor(msg) {
123
+ const type = msg?.type
124
+ const mutationMode = globalMutationMode(type)
125
+ if (mutationMode) return `${mutationMode}:global`
126
+
127
+ const mode = chatMode(type)
128
+ if (!mode) return "control"
129
+
130
+ const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
131
+ const streamKey = streamId ? `${mode}:${streamId}` : ""
132
+ if (type === `${mode}.attach`) {
133
+ const laneKey = attachLaneKey(mode, msg)
134
+ this._rememberStreamLane(streamId, laneKey)
135
+ return laneKey
136
+ }
137
+ if (streamKey && this.streamLaneKeys.has(streamKey)) {
138
+ return this.streamLaneKeys.get(streamKey)
139
+ }
140
+ const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
141
+ if (cwd) return `${mode}:cwd:${cwd}`
142
+ if (streamId) return `${mode}:stream:${streamId}`
143
+ // codex.profile.list 等の stream 非依存 read は main 側の read-only bypass
144
+ // に分類する。未知の stream 非依存メッセージは mode-wide control として扱う。
145
+ return `${mode}:global`
146
+ }
147
+
148
+ _previousFor(lane) {
149
+ const own = this.laneTails.get(lane)
150
+ const match = /^(claude|codex):(global|cwd:|resume:|stream:)/.exec(lane)
151
+ if (!match) return own || Promise.resolve()
152
+
153
+ const mode = match[1]
154
+ if (lane === `${mode}:global`) {
155
+ // プロファイル切替等の mode-wide mutation は、既に走っている同 CLI の
156
+ // attach/input が終わってから適用する。
157
+ const dependencies = []
158
+ for (const [key, tail] of this.laneTails) {
159
+ if (key.startsWith(`${mode}:`)) dependencies.push(tail)
160
+ }
161
+ return dependencies.length > 0 ? Promise.all(dependencies) : Promise.resolve()
162
+ }
163
+
164
+ // cwd/stream lane は、先行する profile mutation があればその完了も待つ。
165
+ const globalTail = this.laneTails.get(`${mode}:global`)
166
+ if (own && globalTail && own !== globalTail) return Promise.all([own, globalTail])
167
+ return own || globalTail || Promise.resolve()
168
+ }
169
+
170
+ _runNow(msg, lane = "bypass") {
171
+ return Promise.resolve().then(() => this.run(msg)).catch((err) => {
172
+ this.logger?.error?.(
173
+ { err: err?.message, type: msg?.type, lane },
174
+ "dispatch threw",
175
+ )
176
+ })
177
+ }
178
+
179
+ enqueue(msg) {
180
+ const type = msg?.type
181
+ const sequence = ++this.sequence
182
+ if (this.shouldBypass(type) || isConnectionLifecycleMessage(type)) {
183
+ return this._runNow(msg)
184
+ }
185
+
186
+ const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
187
+ const mode = chatMode(type)
188
+ const streamKey = mode && streamId ? `${mode}:${streamId}` : ""
189
+ const isDetach = type === "claude.detach" || type === "codex.detach"
190
+ if (isDetach && streamKey) {
191
+ // attach がまだ lane 内で待機中でも、その実行前に stale と判定できるよう
192
+ // detach の dispatch 完了を待たず受信時点の順序を記録する。真偽値だけだと
193
+ // detach より後に来た正規の再 attach まで落とすため、sequence で前後を区別する。
194
+ this._rememberDetach(streamKey, sequence)
195
+ }
196
+
197
+ const lane = this._laneFor(msg)
198
+ const generation = this.generation
199
+ const previous = this._previousFor(lane)
200
+ const task = previous
201
+ .then(async () => {
202
+ if (generation !== this.generation) {
203
+ this.logger?.debug?.(
204
+ { type, lane, queued_generation: generation, generation: this.generation },
205
+ "stale queued dispatch dropped after reconnect",
206
+ )
207
+ return
208
+ }
209
+ if (
210
+ (type === "claude.attach" || type === "codex.attach") &&
211
+ streamKey &&
212
+ (this.detachedAtByStream.get(streamKey) ?? -1) > sequence
213
+ ) {
214
+ this.logger?.debug?.(
215
+ { type, lane, stream_id: streamId },
216
+ "detached queued attach dropped",
217
+ )
218
+ return
219
+ }
220
+ await this.run(msg)
221
+ })
222
+ .catch((err) => {
223
+ this.logger?.error?.(
224
+ { err: err?.message, type, lane },
225
+ "dispatch threw in serial lane",
226
+ )
227
+ })
228
+ .finally(() => {
229
+ if (this.laneTails.get(lane) === task) this.laneTails.delete(lane)
230
+ if (isDetach && streamKey && generation === this.generation) {
231
+ this.streamLaneKeys.delete(streamKey)
232
+ }
233
+ })
234
+
235
+ this.laneTails.set(lane, task)
236
+ return task
237
+ }
238
+ }
package/src/main.mjs CHANGED
@@ -12,7 +12,15 @@
12
12
  */
13
13
  import { spawn as _childSpawn } from "node:child_process"
14
14
  import { existsSync, readFileSync, watch as fsWatch } from "node:fs"
15
- import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
15
+ import {
16
+ mkdir,
17
+ readFile,
18
+ readdir,
19
+ rename,
20
+ stat,
21
+ unlink,
22
+ writeFile,
23
+ } from "node:fs/promises"
16
24
  import { randomUUID } from "node:crypto"
17
25
  import os from "node:os"
18
26
  import path from "node:path"
@@ -39,6 +47,7 @@ import {
39
47
  } from "./hub-bundle.mjs"
40
48
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
41
49
  import { WsClient } from "./ws-client.mjs"
50
+ import { DispatchScheduler } from "./dispatch-scheduler.mjs"
42
51
  import { PtyBridge } from "./pty-bridge.mjs"
43
52
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
44
53
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
@@ -46,6 +55,7 @@ import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridg
46
55
  import { UploadManager } from "./claude-upload.mjs"
47
56
  import { requestSelfUninstall } from "./service-install.mjs"
48
57
  import {
58
+ extractLastTurnTexts,
49
59
  fetchSessionHistory,
50
60
  jsonlPath,
51
61
  listSessions,
@@ -328,6 +338,8 @@ export function isReadOnlyRpc(type) {
328
338
  type === "claude.history.request" ||
329
339
  type === "claude.sessions.request" ||
330
340
  type === "codex.sessions.request" ||
341
+ type === "profile.list" ||
342
+ type === "codex.profile.list" ||
331
343
  // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
332
344
  // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
333
345
  // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
@@ -1079,7 +1091,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1079
1091
  })
1080
1092
  }
1081
1093
 
1082
- // Hub からのメッセージ dispatch は **直列実行** する。
1094
+ // Hub からのメッセージ dispatch は、接続世代 + 機能レーン単位で直列実行する。
1083
1095
  //
1084
1096
  // `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
1085
1097
  // await dispatch(msg, ...)` の形だと複数 WS メッセージが連続して届いたとき
@@ -1094,50 +1106,29 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1094
1106
  // 結果として paste が tmux 内部状態 (copy mode 等) を抜ける前に届き、
1095
1107
  // claude TUI に正しく入らない事象が散発する。
1096
1108
  //
1097
- // Promise chain で前の dispatch 完了を待ってから次を始めることで、WS
1098
- // 受信順 = pane 反映順を保証する。pty 出力 (agent → browser) は別経路
1099
- // (`ptyBridge.on("output")`) なのでこの直列化の影響を受けない。
1100
- let dispatchChain = Promise.resolve()
1101
- client.on("message", (msg) => {
1102
- // B7: pty 出力データ (pty.data) と resize (pty.resize) は順序保証が不要で高頻度な
1103
- // 低レイテンシ経路。これらを直列キュー (dispatchChain) に通すと、1 件の tmux.exec 等
1104
- // のハングで pty 入出力まで全停止してしまう。安全側に「pty 出力データと resize のみ」
1105
- // を直列キューからバイパスして即時処理する。入力系 (claude.input)・制御系 (tmux.exec /
1106
- // permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
1107
- if (isFastPathMessage(msg?.type)) {
1108
- Promise.resolve(
1109
- dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
1110
- ).catch((err) => {
1111
- logger.error(
1112
- { err: err.message, type: msg?.type },
1113
- "dispatch threw (bypassed pty fast-path)",
1114
- )
1115
- })
1116
- return
1117
- }
1118
- // Phase 3 (Read-plane): 読み取り専用 RPC (history/sessions) も直列キューから外して並行処理する。
1119
- // 直列キュー前方の重い処理 (codex app-server spawn 等) が claude.attach→ready を HoL blocking
1120
- // するのを防ぐ (view/spawn 分離)。純粋読み取りで順序依存が無いことは isReadOnlyRpc の説明参照。
1121
- if (isReadOnlyRpc(msg?.type)) {
1122
- Promise.resolve(
1123
- dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
1124
- ).catch((err) => {
1125
- logger.error(
1126
- { err: err.message, type: msg?.type },
1127
- "dispatch threw (bypassed read-only RPC lane)",
1128
- )
1129
- })
1130
- return
1131
- }
1132
- dispatchChain = dispatchChain
1133
- .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }))
1134
- .catch((err) => {
1135
- logger.error(
1136
- { err: err.message, type: msg?.type },
1137
- "dispatch threw in serial queue",
1138
- )
1139
- })
1109
+ // 旧来の global Promise chain は、ある cwd の tmux/codex 処理が別 cwd の
1110
+ // claude.attach と reconnect ready/sync まで 60〜203 秒止める HoL blocking を
1111
+ // 本番で起こした。DispatchScheduler は同一 cwd・同一 CLI の順序だけを維持し、
1112
+ // tmux/control・Claude・Codex・別 cwd を独立レーンへ分離する。
1113
+ const dispatchCtx = {
1114
+ ...ctx,
1115
+ client,
1116
+ ptyBridge,
1117
+ claudeBridge,
1118
+ codexBridge,
1119
+ codexMcpControl,
1120
+ uploadManager,
1121
+ }
1122
+ const dispatchScheduler = new DispatchScheduler({
1123
+ logger,
1124
+ run: (msg) => dispatch(msg, dispatchCtx),
1125
+ // pty fast-path / read-only RPC は従来どおり即時実行。接続 lifecycle は
1126
+ // scheduler 自身が常に priority 扱いする。
1127
+ shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
1140
1128
  })
1129
+ client.on("close", () => dispatchScheduler.advanceGeneration("ws_close"))
1130
+ client.on("open", () => dispatchScheduler.advanceGeneration("ws_open"))
1131
+ client.on("message", (msg) => dispatchScheduler.enqueue(msg))
1141
1132
 
1142
1133
  client.connect()
1143
1134
 
@@ -1708,6 +1699,171 @@ async function readAllSessionEvents() {
1708
1699
  return out
1709
1700
  }
1710
1701
 
1702
+ /**
1703
+ * jsonl 末尾テキストのキャッシュ。key = jsonl path、value = `{version, texts}`。
1704
+ * version の構成は `lastTurnTextsVersion()` が正本 (ここに具体値を書くとドリフトする)。
1705
+ *
1706
+ * tmux.list_sessions は数秒間隔で回るため、毎回全セッションの jsonl 末尾を読むと I/O が
1707
+ * 積み上がる。会話が進めば必ず version が変わるので、version 一致なら前回値を使い回す。
1708
+ */
1709
+ const lastTurnTextsCache = new Map()
1710
+
1711
+ /**
1712
+ * path 単位の in-flight 共有。複数ブラウザが同時に poll しても同じ jsonl を重複抽出しない。
1713
+ * key = jsonl path、value = `{version, promise}`。
1714
+ *
1715
+ * ⚠️ version を必ず持たせる。「どの版の抽出か」が分からないと、合流した caller が自分の
1716
+ * 観測版で結果を保存してしまい、古いテキストが恒久的に cache hit する (major#1 参照)。
1717
+ */
1718
+ const lastTurnTextsInFlight = new Map()
1719
+
1720
+ /** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
1721
+ const LAST_TEXTS_CONCURRENCY = 4
1722
+
1723
+ /** テスト用: module-global なキャッシュのテスト間汚染を防ぐ。 */
1724
+ export function clearLastTurnTextsCacheForTest() {
1725
+ lastTurnTextsCache.clear()
1726
+ lastTurnTextsInFlight.clear()
1727
+ }
1728
+
1729
+ /** GC: tmux から消えたセッションの jsonl は追跡しない (Map の線形増加を防ぐ)。 */
1730
+ function pruneLastTurnTextsCache(livePaths) {
1731
+ if (lastTurnTextsCache.size <= livePaths.size) return
1732
+ for (const k of lastTurnTextsCache.keys()) {
1733
+ if (!livePaths.has(k)) lastTurnTextsCache.delete(k)
1734
+ }
1735
+ }
1736
+
1737
+ /**
1738
+ * jsonl の版 (キャッシュ無効化キー)。
1739
+ *
1740
+ * mtime だけだと、分解能の粗い FS や「同一 ms 内の追記」で古いテキストを再利用しうる
1741
+ * (同リポの hydrate / last-turn キャッシュも mtime+size を使っている)。size と ctime も
1742
+ * 混ぜて、append / atomic replace の双方を検出できるようにする。
1743
+ */
1744
+ function lastTurnTextsVersion(st) {
1745
+ return `${st.mtimeMs}:${st.size}:${st.ctimeMs}`
1746
+ }
1747
+
1748
+ /**
1749
+ * `context_session_id` を jsonl のパス要素として使ってよいか検証する。
1750
+ *
1751
+ * この値は SDK resume 経路では browser → backend → agent と外部を通ってくるため、
1752
+ * `../` を含むと cwd の project dir の外を指しうる。新設の自動読み取り面なので、
1753
+ * パス要素として安全な形だけを通す (実在の Claude session id は UUID だが、将来の
1754
+ * 形式変更で壊れないよう「危険な形の排除」に留める)。
1755
+ */
1756
+ function isSafeSessionIdForPath(id) {
1757
+ if (!id || typeof id !== "string") return false
1758
+ if (id.length > 200) return false
1759
+ if (id === "." || id === "..") return false
1760
+ // パス区切り / NUL / 制御文字 / ドット始まりを排除。
1761
+ if (/[/\\\0]/.test(id)) return false
1762
+ // eslint-disable-next-line no-control-regex
1763
+ if (/[\x00-\x1f]/.test(id)) return false
1764
+ if (id.startsWith(".")) return false
1765
+ // 未 bind のプレースホルダ (jsonl がまだ存在しない)。
1766
+ if (id.startsWith("fresh:") || id.startsWith("unbound:")) return false
1767
+ return true
1768
+ }
1769
+
1770
+ /**
1771
+ * 各 tmux session の「最後の依頼 / 最後の応答」テキストを jsonl 末尾から読む。
1772
+ *
1773
+ * Cockpit サイドバーが行ごとに「何を依頼したか」を出すためのデータ源 (2026-07-28)。
1774
+ * frontend は選択中 1 セッションしかメッセージを保持しないため、一覧用は agent 側で
1775
+ * 供給するしかない。会話 ID は state loop の snapshot (`context_session_id`) を使う。
1776
+ *
1777
+ * 対象は **claude 行のみ**。codex はスレッド保存形式が異なるため対象外 (将来対応)。
1778
+ * `fresh:<request_id>` は jsonl 未生成のプレースホルダなのでスキップする。
1779
+ *
1780
+ * @param {Array} sessions listTmuxSessions の素の行 (name / cwd を使う)
1781
+ * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
1782
+ * @returns {Promise<Map<string, {user: string, assistant: string}>>}
1783
+ */
1784
+ export async function readAllSessionLastTexts(
1785
+ sessions,
1786
+ getSnap,
1787
+ { extract = extractLastTurnTexts, statFn = stat, resolvePath = jsonlPath } = {},
1788
+ ) {
1789
+ const out = new Map()
1790
+ if (!getSnap) return out
1791
+ const livePaths = new Set()
1792
+
1793
+ const readOne = async (s) => {
1794
+ try {
1795
+ const snap = getSnap(s.name)
1796
+ if (!snap || snap.cli_kind !== "claude") return
1797
+ const sessionId = snap.context_session_id
1798
+ // パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
1799
+ if (!isSafeSessionIdForPath(sessionId)) return
1800
+ if (!s.cwd) return
1801
+ const fp = resolvePath({ cwd: s.cwd, session_id: sessionId })
1802
+ if (!fp) return
1803
+ livePaths.add(fp)
1804
+ const st = await statFn(fp)
1805
+ const version = lastTurnTextsVersion(st)
1806
+ const cached = lastTurnTextsCache.get(fp)
1807
+ if (cached && cached.version === version) {
1808
+ out.set(s.name, cached.texts)
1809
+ return
1810
+ }
1811
+ // 同一 path への同時抽出は 1 本に畳む (複数ブラウザの同時 poll 対策)。
1812
+ //
1813
+ // ⚠️ in-flight には「どの版の抽出か」を必ず持たせる (2026-07-28 クロスレビュー major#1)。
1814
+ // path だけで共有し、合流した caller が自分の観測版で結果を保存すると、
1815
+ // 「V1 の抽出中に V2 へ追記 → V2 を観測した caller が V1 の結果を {version: V2} で
1816
+ // 保存」となり、以降ファイルが動かない限り古いテキストが恒久的に cache hit する。
1817
+ let pending = lastTurnTextsInFlight.get(fp)
1818
+ if (!pending) {
1819
+ const entry = { version, promise: null }
1820
+ entry.promise = extract(fp).finally(() => {
1821
+ // 世代交代後に古い finally が新しい in-flight を消さないよう、
1822
+ // Map の現在値が自分自身のときだけ削除する。
1823
+ if (lastTurnTextsInFlight.get(fp) === entry) lastTurnTextsInFlight.delete(fp)
1824
+ })
1825
+ lastTurnTextsInFlight.set(fp, entry)
1826
+ pending = entry
1827
+ }
1828
+ const texts = await pending.promise
1829
+ out.set(s.name, texts)
1830
+ // cache に載せてよいのは「その抽出を開始した版」の caller だけ。別版を観測して
1831
+ // 合流した caller は表示にのみ使い、cache は汚さない (次回 poll で必ず再抽出)。
1832
+ if (pending.version === version) {
1833
+ // 抽出中に追記されていた場合、読んだ内容は既に古い可能性がある。開始時と同じ版の
1834
+ // ままだったときだけ cache する (追記中の結果を恒久 hit させないための回帰ガード)。
1835
+ let unchanged = false
1836
+ try {
1837
+ unchanged = lastTurnTextsVersion(await statFn(fp)) === version
1838
+ } catch {
1839
+ unchanged = false
1840
+ }
1841
+ if (unchanged) lastTurnTextsCache.set(fp, { version, texts })
1842
+ }
1843
+ } catch {
1844
+ // jsonl 不在 / 権限 / parse 失敗は「テキスト無し」に落とすだけ (一覧は出す)
1845
+ }
1846
+ }
1847
+
1848
+ // cold cache で数十セッションが一斉に上限走査に入ると I/O が集中するため、
1849
+ // 素の Promise.all ではなく固定並列度で流す。
1850
+ const queue = sessions.slice()
1851
+ const workers = Array.from(
1852
+ { length: Math.min(LAST_TEXTS_CONCURRENCY, queue.length) },
1853
+ async () => {
1854
+ for (;;) {
1855
+ const s = queue.shift()
1856
+ if (!s) return
1857
+ await readOne(s)
1858
+ }
1859
+ },
1860
+ )
1861
+ await Promise.all(workers)
1862
+
1863
+ pruneLastTurnTextsCache(livePaths)
1864
+ return out
1865
+ }
1866
+
1711
1867
  /**
1712
1868
  * 単一 session の最新 session-event をファイル正本から読む。無ければ null。
1713
1869
  * claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
@@ -1977,17 +2133,26 @@ const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
1977
2133
  * @param {Map<string, object>} lastEventByName session_name → 最新 hook event
1978
2134
  * @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
1979
2135
  * accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
2136
+ * @param {Map<string, {user: string, assistant: string}> | undefined} lastTextsByName
2137
+ * session_name → jsonl 末尾の依頼/応答テキスト (サイドバー行の内容表示用)。
2138
+ * undefined / 未収録なら該当フィールドを載せない(古い cockpit は無視するので互換)
1980
2139
  */
1981
2140
  export function enrichTmuxSessionsForListResponse(
1982
2141
  sessions,
1983
2142
  lastEventByName,
1984
2143
  getSnap,
2144
+ lastTextsByName,
1985
2145
  ) {
1986
2146
  return sessions.map((s) => {
1987
2147
  const base = {
1988
2148
  ...s,
1989
2149
  last_event: lastEventByName.get(s.name) || null,
1990
2150
  }
2151
+ // サイドバー行に出す「何を依頼したか / 何が返ったか」。空文字は載せない
2152
+ // (frontend 側で「未取得」と「本当に空」を区別する必要が無いため)。
2153
+ const texts = lastTextsByName?.get(s.name)
2154
+ if (texts?.user) base.last_user_text = texts.user
2155
+ if (texts?.assistant) base.last_assistant_text = texts.assistant
1991
2156
  if (!getSnap) return base
1992
2157
  const snap = getSnap(s.name)
1993
2158
  if (!snap) return base
@@ -4556,10 +4721,17 @@ async function dispatch(msg, ctx) {
4556
4721
  // fs.watch 由来の session.event push は揮発性のため、frontend が新規
4557
4722
  // マウントすると過去 event を取れず全グレー表示になる事象を解消する。
4558
4723
  const lastEventByName = await readAllSessionEvents()
4724
+ // サイドバー行に「何を依頼したか」を出すため、各セッションの jsonl 末尾から
4725
+ // 最新の依頼 / 応答テキストを読む (mtime キャッシュ付き)。失敗は空に落ちる。
4726
+ const lastTextsByName = await readAllSessionLastTexts(
4727
+ sessions,
4728
+ ctx.getSessionStateSnapshot,
4729
+ )
4559
4730
  const enriched = enrichTmuxSessionsForListResponse(
4560
4731
  sessions,
4561
4732
  lastEventByName,
4562
4733
  ctx.getSessionStateSnapshot,
4734
+ lastTextsByName,
4563
4735
  )
4564
4736
  // cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
4565
4737
  // filesystem 上は存在するが tmux session が無い worktree dir のリストを