@cocorograph/hub-agent 0.8.2 → 0.8.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/usage.mjs +77 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/usage.mjs CHANGED
@@ -20,6 +20,7 @@ import { promises as fs } from "node:fs"
20
20
  import { randomUUID } from "node:crypto"
21
21
  import os from "node:os"
22
22
  import path from "node:path"
23
+ import { StringDecoder } from "node:string_decoder"
23
24
 
24
25
  import { encodeCwdToDirName } from "./claude-history.mjs"
25
26
 
@@ -147,41 +148,6 @@ async function readTail(fp, maxBytes = 64 * 1024) {
147
148
  }
148
149
  }
149
150
 
150
- /**
151
- * ファイル先頭だけを読む (head)。readTail の対称。rollout の session_meta ヘッダー行
152
- * (先頭行) + 先頭近傍の最初の user_message (preview) を取り出すのに使う。maxBytes まで
153
- * 読み、末尾の不完全な行を捨てる (最後の改行以降を落とす)。size <= maxBytes なら全体。失敗時 null。
154
- */
155
- async function readHead(fp, maxBytes = 32 * 1024) {
156
- let fh
157
- try {
158
- fh = await fs.open(fp, "r")
159
- const st = await fh.stat()
160
- const size = st.size
161
- if (size === 0) return ""
162
- const len = size > maxBytes ? maxBytes : size
163
- const buf = Buffer.allocUnsafe(len)
164
- // ⚠️ bytesRead で切る (allocUnsafe は未初期化。並行 truncate 等で len 未満しか読めなかった
165
- // とき、残りのゴミ領域を文字列化しない。cross-review minor)。
166
- const { bytesRead } = await fh.read(buf, 0, len, 0)
167
- let text = buf.toString("utf-8", 0, bytesRead)
168
- // EOF まで読み切っていない (maxBytes で頭だけ / 途中 truncate) 場合、末尾の不完全な行を捨てる。
169
- if (bytesRead < size) {
170
- const nl = text.lastIndexOf("\n")
171
- text = nl >= 0 ? text.slice(0, nl) : text
172
- }
173
- return text
174
- } catch {
175
- return null
176
- } finally {
177
- try {
178
- await fh?.close()
179
- } catch {
180
- /* ignore */
181
- }
182
- }
183
- }
184
-
185
151
  /** P5(perf): latestJsonlContext の結果を {mtimeMs,size} キーでメモ化し再パースを避ける。 */
186
152
  const _jsonlCtxMemo = new Map() // fp → { mtimeMs, size, result }
187
153
 
@@ -1513,21 +1479,37 @@ function threadIdFromRolloutFilename(fp) {
1513
1479
  return m ? m[1] : ""
1514
1480
  }
1515
1481
 
1516
- /** rollout の先頭 (head) から一覧用メタ (thread_id / cwd / preview) を取り出す。
1517
- * header (session_meta) は先頭行、preview は先頭近傍の最初の user_message。null=読めない。 */
1518
- async function parseCodexRolloutHead(fp) {
1519
- const text = await readHead(fp, 32 * 1024)
1520
- if (text == null) return null
1482
+ const CODEX_ROLLOUT_HEAD_CHUNK_BYTES = 32 * 1024
1483
+ const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
1484
+
1485
+ /**
1486
+ * rollout の先頭から一覧用メタ (thread_id / cwd / preview) を取り出す。
1487
+ *
1488
+ * 現行 Codex は最初の user_message より前に system / developer / AGENTS コンテキストを
1489
+ * response_item として書くため、user_message が 200KB 以降に現れることがある。固定 32KB の
1490
+ * head 読みでは preview が常に空になったため、32KB 単位で最大 1MB まで段階的に読み進める。
1491
+ * ファイル全体は読まず、cwd が対象外と分かった時点、または preview が見つかった時点で止める。
1492
+ *
1493
+ * @param {string} fp
1494
+ * @param {string} expectedCwd cwd-scoped 一覧の対象。別 cwd は session_meta 読み取り後に早期終了。
1495
+ * @returns {Promise<{thread_id:string,cwd:string,preview:string}|null>}
1496
+ */
1497
+ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1521
1498
  let thread_id = threadIdFromRolloutFilename(fp)
1522
1499
  let cwd = ""
1523
1500
  let preview = ""
1524
- for (const line of text.split("\n")) {
1525
- if (!line) continue
1501
+ let fh
1502
+ const decoder = new StringDecoder("utf8")
1503
+ let pending = ""
1504
+ let offset = 0
1505
+
1506
+ const parseLine = (line) => {
1507
+ if (!line) return
1526
1508
  let o
1527
1509
  try {
1528
1510
  o = JSON.parse(line)
1529
1511
  } catch {
1530
- continue
1512
+ return
1531
1513
  }
1532
1514
  if (o.type === "session_meta" && o.payload && typeof o.payload === "object") {
1533
1515
  if (typeof o.payload.cwd === "string") cwd = o.payload.cwd
@@ -1543,8 +1525,58 @@ async function parseCodexRolloutHead(fp) {
1543
1525
  ) {
1544
1526
  preview = o.payload.message.slice(0, 80)
1545
1527
  }
1546
- if (cwd && preview) break // 両方揃ったら以降は読まない (先頭近傍で足りる)。
1547
1528
  }
1529
+
1530
+ try {
1531
+ fh = await fs.open(fp, "r")
1532
+ while (offset < CODEX_ROLLOUT_PREVIEW_MAX_BYTES) {
1533
+ const readSize = Math.min(
1534
+ CODEX_ROLLOUT_HEAD_CHUNK_BYTES,
1535
+ CODEX_ROLLOUT_PREVIEW_MAX_BYTES - offset,
1536
+ )
1537
+ const buf = Buffer.allocUnsafe(readSize)
1538
+ const { bytesRead } = await fh.read(buf, 0, readSize, offset)
1539
+ if (bytesRead === 0) {
1540
+ pending += decoder.end()
1541
+ if (pending) parseLine(pending)
1542
+ break
1543
+ }
1544
+ offset += bytesRead
1545
+ pending += decoder.write(buf.subarray(0, bytesRead))
1546
+
1547
+ let newline
1548
+ while ((newline = pending.indexOf("\n")) >= 0) {
1549
+ const line = pending.slice(0, newline)
1550
+ pending = pending.slice(newline + 1)
1551
+ parseLine(line)
1552
+ // session_meta は先頭行にある。別 workspace の候補に対して巨大な初期コンテキストを
1553
+ // 読み進めないことで、maxScan が大きい場合も一覧走査コストを抑える。
1554
+ if (cwd && expectedCwd && cwd !== expectedCwd) {
1555
+ return thread_id ? { thread_id, cwd, preview } : null
1556
+ }
1557
+ if (cwd && preview) {
1558
+ return thread_id ? { thread_id, cwd, preview } : null
1559
+ }
1560
+ }
1561
+
1562
+ // 通常ファイルの EOF。decoder.end() で分割された UTF-8 の末尾を確定し、改行なしの
1563
+ // 最終行も一度だけ解析する。
1564
+ if (bytesRead < readSize) {
1565
+ pending += decoder.end()
1566
+ if (pending) parseLine(pending)
1567
+ break
1568
+ }
1569
+ }
1570
+ } catch {
1571
+ return null
1572
+ } finally {
1573
+ try {
1574
+ await fh?.close()
1575
+ } catch {
1576
+ /* ignore */
1577
+ }
1578
+ }
1579
+
1548
1580
  if (!thread_id) return null
1549
1581
  return { thread_id, cwd, preview }
1550
1582
  }
@@ -1610,7 +1642,7 @@ export async function listCodexThreadsFromDisk({
1610
1642
  if (limit && out.length >= limit) break
1611
1643
  if (opened >= maxScan) break
1612
1644
  opened++
1613
- const meta = await parseCodexRolloutHead(fp)
1645
+ const meta = await parseCodexRolloutHead(fp, cwd)
1614
1646
  if (!meta) continue
1615
1647
  // header の cwd が一致するものだけ (cwd 未読/不一致は除外 = 無関係スレッドの混入防止)。
1616
1648
  if (meta.cwd !== cwd) continue