@cocorograph/hub-agent 0.8.100 → 0.8.101

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.100",
3
+ "version": "0.8.101",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -20,7 +20,8 @@ import { recordConversationFocus } from "./chat-signals.mjs"
20
20
  */
21
21
  import { EventEmitter } from "node:events"
22
22
  import { randomUUID } from "node:crypto"
23
- import { readFileSync } from "node:fs"
23
+ import { readFileSync, statSync } from "node:fs"
24
+ import { open } from "node:fs/promises"
24
25
  import os from "node:os"
25
26
  import path from "node:path"
26
27
  import { isUnderRemoval } from "./chat-removal-guard.mjs"
@@ -323,11 +324,12 @@ export function _resetModelCapabilities() {
323
324
  * SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
324
325
  * 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
325
326
  * なので親 tool_use は無く null。 */
326
- function toSDKUserMessage(text) {
327
+ function toSDKUserMessage(text, uuid) {
327
328
  return {
328
329
  type: "user",
329
330
  message: { role: "user", content: text },
330
331
  parent_tool_use_id: null,
332
+ ...(uuid === undefined ? {} : { uuid }),
331
333
  }
332
334
  }
333
335
 
@@ -429,6 +431,11 @@ const STALE_IDLE_GUARD_MS = 5000
429
431
 
430
432
  // sibling CLI の既定待ち 10 秒より短く受理状態を待つ。超過は失敗と断定しない。
431
433
  export const SIBLING_ACCEPT_WAIT_MS = 1000
434
+ export const SIBLING_CONFIRM_WAIT_MS = 2500
435
+ const SIBLING_CONFIRM_POLL_MS = 50
436
+ // 注入本文の user 行はこれより十分小さい。改行の無い巨大行 (大きな tool result 等) を
437
+ // 抱え込んで再コピー・再検索し続けないよう、超えた行は次の改行まで読み飛ばす。
438
+ const SIBLING_CONFIRM_MAX_LINE_BYTES = 1024 * 1024
432
439
 
433
440
  /** per-message の title control request を待って InputQueue を閉じる上限。 */
434
441
  const SESSION_TITLE_CLOSE_WAIT_MS = 15_000
@@ -1428,7 +1435,7 @@ export class ClaudeStreamSession {
1428
1435
  // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
1429
1436
  this._maybeEmitInjectStarted(prompt)
1430
1437
  this._pendingTitleSeed = prompt
1431
- this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
1438
+ this._startResidentTurnWithInput(toSDKUserMessage(prompt, opts.message_uuid), ultracode)
1432
1439
  return { accepted: true }
1433
1440
  }
1434
1441
  // 起動済みの常駐 query へ次ターンを流す。init 済みで空窓が無いため、ultracode は走行中
@@ -1436,7 +1443,7 @@ export class ClaudeStreamSession {
1436
1443
  // 旧 query 終了 × 後着 pending の ABA/FIFO 逆転は _reconcileThenDeliver が直列化する
1437
1444
  // (drain 経路 _drainResidentPending と共通)。
1438
1445
  const outcome = await this._reconcileThenDeliver(
1439
- toSDKUserMessage(prompt),
1446
+ toSDKUserMessage(prompt, opts.message_uuid),
1440
1447
  ultracode,
1441
1448
  )
1442
1449
  if (outcome === "closed") return { accepted: false, reason: "queue_dead" }
@@ -1472,7 +1479,7 @@ export class ClaudeStreamSession {
1472
1479
  // ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
1473
1480
  // 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
1474
1481
  // 握っているため通常は reject しないが、念のため warn だけ残す。
1475
- this._runPerMessage(prompt, { ultracode, injectSource }).catch((err) => {
1482
+ this._runPerMessage(prompt, { ultracode, injectSource, message_uuid: opts.message_uuid }).catch((err) => {
1476
1483
  this.logger?.error(
1477
1484
  { stream_id: this.stream_id, err: err?.message },
1478
1485
  "per-message run threw unexpectedly",
@@ -1541,7 +1548,7 @@ export class ClaudeStreamSession {
1541
1548
  // per-message でも単発 InputQueue (1 件 push → 即 close) を prompt に渡し、1 query=1 ターンの
1542
1549
  // resume チェーン挙動は維持しつつ、文字列 prompt + canUseTool の SDK 制約リスクを回避する。
1543
1550
  const inputQueue = new InputQueue()
1544
- inputQueue.push(toSDKUserMessage(prompt))
1551
+ inputQueue.push(toSDKUserMessage(prompt, opts.message_uuid))
1545
1552
  const q = this.sdk.query({ prompt: inputQueue, options })
1546
1553
  const shouldWaitForTitle =
1547
1554
  isFreshStart && this._canGenerateSessionTitle(q, prompt)
@@ -2708,11 +2715,12 @@ export class ClaudeStreamBridge extends EventEmitter {
2708
2715
  * (テストでは `{ query: stubQuery }` を渡す)
2709
2716
  * @param {import('pino').Logger} [opts.logger]
2710
2717
  */
2711
- constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth } = {}) {
2718
+ constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth, projectsRoot } = {}) {
2712
2719
  super()
2713
2720
  if (!sdk || typeof sdk.query !== "function") {
2714
2721
  throw new TypeError("ClaudeStreamBridge requires { sdk: { query } }")
2715
2722
  }
2723
+ this._projectsRoot = projectsRoot
2716
2724
  this.sdk = sdk
2717
2725
  this.logger = logger
2718
2726
  /** needs-auth キャッシュ名読み取り (テスト差し替え用。既定は実ファイル読み込み)。 */
@@ -3761,15 +3769,85 @@ export class ClaudeStreamBridge extends EventEmitter {
3761
3769
  return this._sendSiblingMessage(cwd, text, source, session, attached)
3762
3770
  }
3763
3771
 
3764
- /** 受理結果だけを有限時間待つ。遅れて確定した結果も source の後始末には使う。 */
3772
+ /** 追記された完全な JSONL 行だけを確認する。パス変更時は新ファイルの先頭へ戻す。 */
3773
+ async _confirmSiblingTranscript(session, cwd, uuid, cursor, startedAt) {
3774
+ const deadline = startedAt + SIBLING_CONFIRM_WAIT_MS
3775
+ try {
3776
+ while (Date.now() < deadline) {
3777
+ const file = session.sessionId
3778
+ ? jsonlPath({ cwd, session_id: session.sessionId, projectsRoot: this._projectsRoot })
3779
+ : null
3780
+ if (file !== cursor.file) Object.assign(cursor, { file, offset: 0, pending: Buffer.alloc(0), skipping: false })
3781
+ if (file) {
3782
+ let handle
3783
+ try {
3784
+ handle = await open(file, "r")
3785
+ const { size } = await handle.stat()
3786
+ while (cursor.offset < size && Date.now() < deadline) {
3787
+ const buffer = Buffer.alloc(Math.min(64 * 1024, size - cursor.offset))
3788
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, cursor.offset)
3789
+ if (!bytesRead) break
3790
+ cursor.offset += bytesRead
3791
+ const data = Buffer.concat([cursor.pending, buffer.subarray(0, bytesRead)])
3792
+ let start = 0
3793
+ if (cursor.skipping) {
3794
+ // 上限超えの行の続き。途中から JSON として読まず、次の改行の後から再開する。
3795
+ const newline = data.indexOf(10)
3796
+ if (newline === -1) {
3797
+ cursor.pending = Buffer.alloc(0)
3798
+ continue
3799
+ }
3800
+ start = newline + 1
3801
+ cursor.skipping = false
3802
+ }
3803
+ for (let end; (end = data.indexOf(10, start)) !== -1; start = end + 1) {
3804
+ try {
3805
+ const row = JSON.parse(data.subarray(start, end).toString("utf8"))
3806
+ if (row?.type === "user" && row.uuid === uuid) return true
3807
+ } catch { /* 壊れた行は確認の根拠にしない。 */ }
3808
+ }
3809
+ cursor.pending = data.subarray(start)
3810
+ if (cursor.pending.length > SIBLING_CONFIRM_MAX_LINE_BYTES) {
3811
+ cursor.pending = Buffer.alloc(0)
3812
+ cursor.skipping = true
3813
+ }
3814
+ }
3815
+ } catch (err) {
3816
+ if (err.code !== "ENOENT") return false
3817
+ } finally {
3818
+ await handle?.close()
3819
+ }
3820
+ }
3821
+ const remaining = deadline - Date.now()
3822
+ if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(SIBLING_CONFIRM_POLL_MS, remaining)))
3823
+ }
3824
+ this.logger?.info(
3825
+ { cwd, session_id: session.sessionId, uuid, elapsed_ms: Date.now() - startedAt },
3826
+ "sibling transcript confirmation timed out; delivery remains unconfirmed",
3827
+ )
3828
+ } catch { /* 確認不能を配送失敗にしない。 */ }
3829
+ return false
3830
+ }
3831
+
3832
+ /** 受理だけを1秒待ち、直接受理された入力は送信開始から2.5秒まで会話記録を確認する。 */
3765
3833
  async _sendSiblingMessage(cwd, text, source, session, attached) {
3766
3834
  const target = this._describeCandidate(session, attached)
3767
3835
  const delivered = { status: "delivered", session: target }
3768
3836
  const injectHandle = session._recordInjectSource?.(text, source)
3837
+ const message_uuid = randomUUID()
3838
+ const transcriptCwd = session.cwd || cwd
3839
+ const startedAt = Date.now()
3840
+ const cursor = { file: null, offset: 0, pending: Buffer.alloc(0), skipping: false }
3841
+ try {
3842
+ if (session.sessionId) {
3843
+ cursor.file = jsonlPath({ cwd: transcriptCwd, session_id: session.sessionId, projectsRoot: this._projectsRoot })
3844
+ cursor.offset = statSync(cursor.file).size
3845
+ }
3846
+ } catch { /* 新規ファイル・読み取り不能なら offset 0。 */ }
3769
3847
  let timer
3770
3848
  const outcome = (async () => {
3771
3849
  try {
3772
- const result = await session.sendMessage({ role: "user", content: text })
3850
+ const result = await session.sendMessage({ role: "user", content: text }, { message_uuid })
3773
3851
  if (result?.accepted === false) {
3774
3852
  session._removeInjectSource?.(injectHandle)
3775
3853
  return { status: "delivery_failed", session: target, reason: result.reason }
@@ -3779,7 +3857,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3779
3857
  if (result?.queued === true) {
3780
3858
  return { status: "delivery_queued", session: target, reason: "queued_behind_active_turn" }
3781
3859
  }
3782
- return delivered
3860
+ return result?.accepted === true ? { ...delivered, confirm: true } : delivered
3783
3861
  } catch (err) {
3784
3862
  session._removeInjectSource?.(injectHandle)
3785
3863
  this.logger?.error(
@@ -3789,8 +3867,9 @@ export class ClaudeStreamBridge extends EventEmitter {
3789
3867
  return delivered
3790
3868
  }
3791
3869
  })()
3870
+ let accepted
3792
3871
  try {
3793
- return await Promise.race([
3872
+ accepted = await Promise.race([
3794
3873
  outcome,
3795
3874
  new Promise((resolve) => {
3796
3875
  timer = setTimeout(() => {
@@ -3805,6 +3884,9 @@ export class ClaudeStreamBridge extends EventEmitter {
3805
3884
  } finally {
3806
3885
  clearTimeout(timer)
3807
3886
  }
3887
+ if (!accepted.confirm) return accepted
3888
+ const confirmed = await this._confirmSiblingTranscript(session, transcriptCwd, message_uuid, cursor, startedAt)
3889
+ return confirmed ? { ...delivered, confirmed_via: "transcript" } : delivered
3808
3890
  }
3809
3891
 
3810
3892
  /**
@@ -49,6 +49,7 @@
49
49
  * 書き、不正値でも property があれば明示エラーを書く。未指定の旧 producer は fire-and-forget の
50
50
  * ままなので、新旧 bundle / hub-agent のローリング互換を保つ。
51
51
  *
52
+ * Claude の `delivery_state: "delivered"` は会話記録の user 行の uuid で確認した場合だけ。
52
53
  * ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
53
54
  * - `delivered` … userMessage echo まで確認済み (ok:true)
54
55
  * - `queued` … **相手が応答生成中**。次ターンのキューに受理済みで、echo は次の
@@ -397,8 +398,9 @@ function ambiguousTargetOutcome(cliKind, targetSessionId, candidates) {
397
398
  }
398
399
  }
399
400
 
400
- function deliveredStateFor(kind) {
401
- return kind === "codex" ? "delivered" : "dispatched"
401
+ function deliveredStateFor(kind, result) {
402
+ return kind === "codex" || (kind === "claude" && result.confirmed_via === "transcript")
403
+ ? "delivered" : "dispatched"
402
404
  }
403
405
 
404
406
  /**
@@ -1085,7 +1087,8 @@ export class SiblingInjectBridge {
1085
1087
  ok: true,
1086
1088
  delivered_to: r.session,
1087
1089
  delivery_id: r.delivery_id,
1088
- delivery_state: deliveredStateFor(target_cli_kind),
1090
+ delivery_state: deliveredStateFor(target_cli_kind, r),
1091
+ ...(r.confirmed_via ? { confirmed_via: r.confirmed_via } : {}),
1089
1092
  durable_state: r.durable_state,
1090
1093
  accepted_via: r.accepted_via,
1091
1094
  item_id: r.item_id,
@@ -1180,7 +1183,8 @@ export class SiblingInjectBridge {
1180
1183
  ok: true,
1181
1184
  delivered_to: r.session ?? null,
1182
1185
  delivery_id: r.delivery_id,
1183
- delivery_state: deliveredStateFor(target_cli_kind),
1186
+ delivery_state: deliveredStateFor(target_cli_kind, r),
1187
+ ...(r.confirmed_via ? { confirmed_via: r.confirmed_via } : {}),
1184
1188
  durable_state: r.durable_state,
1185
1189
  accepted_via: r.accepted_via,
1186
1190
  item_id: r.item_id,