@cocorograph/hub-agent 0.8.99 → 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.99",
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
 
@@ -427,6 +429,14 @@ function isSessionRunningEvent(msg) {
427
429
  * 正しく終端として扱う (ガードが固着して _busy が永久 true になる = T06052 の再発を防ぐ安全弁)。 */
428
430
  const STALE_IDLE_GUARD_MS = 5000
429
431
 
432
+ // sibling CLI の既定待ち 10 秒より短く受理状態を待つ。超過は失敗と断定しない。
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
439
+
430
440
  /** per-message の title control request を待って InputQueue を閉じる上限。 */
431
441
  const SESSION_TITLE_CLOSE_WAIT_MS = 15_000
432
442
 
@@ -1408,7 +1418,7 @@ export class ClaudeStreamSession {
1408
1418
  "resident busy, message queued",
1409
1419
  )
1410
1420
  this._emitQueueState()
1411
- return { accepted: true }
1421
+ return { accepted: true, queued: true }
1412
1422
  }
1413
1423
  if (this._closed) return { accepted: false, reason: "queue_dead" }
1414
1424
  this._beginResidentTurn(prompt)
@@ -1425,7 +1435,7 @@ export class ClaudeStreamSession {
1425
1435
  // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
1426
1436
  this._maybeEmitInjectStarted(prompt)
1427
1437
  this._pendingTitleSeed = prompt
1428
- this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
1438
+ this._startResidentTurnWithInput(toSDKUserMessage(prompt, opts.message_uuid), ultracode)
1429
1439
  return { accepted: true }
1430
1440
  }
1431
1441
  // 起動済みの常駐 query へ次ターンを流す。init 済みで空窓が無いため、ultracode は走行中
@@ -1433,7 +1443,7 @@ export class ClaudeStreamSession {
1433
1443
  // 旧 query 終了 × 後着 pending の ABA/FIFO 逆転は _reconcileThenDeliver が直列化する
1434
1444
  // (drain 経路 _drainResidentPending と共通)。
1435
1445
  const outcome = await this._reconcileThenDeliver(
1436
- toSDKUserMessage(prompt),
1446
+ toSDKUserMessage(prompt, opts.message_uuid),
1437
1447
  ultracode,
1438
1448
  )
1439
1449
  if (outcome === "closed") return { accepted: false, reason: "queue_dead" }
@@ -1460,7 +1470,7 @@ export class ClaudeStreamSession {
1460
1470
  "claude busy, message queued",
1461
1471
  )
1462
1472
  this._emitQueueState()
1463
- return { accepted: true }
1473
+ return { accepted: true, queued: true }
1464
1474
  }
1465
1475
  // Phase 2 (major#1): per-message idle 直接実行も started を出さないため、注入なら source 付きで
1466
1476
  // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
@@ -1469,7 +1479,7 @@ export class ClaudeStreamSession {
1469
1479
  // ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
1470
1480
  // 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
1471
1481
  // 握っているため通常は reject しないが、念のため warn だけ残す。
1472
- this._runPerMessage(prompt, { ultracode, injectSource }).catch((err) => {
1482
+ this._runPerMessage(prompt, { ultracode, injectSource, message_uuid: opts.message_uuid }).catch((err) => {
1473
1483
  this.logger?.error(
1474
1484
  { stream_id: this.stream_id, err: err?.message },
1475
1485
  "per-message run threw unexpectedly",
@@ -1538,7 +1548,7 @@ export class ClaudeStreamSession {
1538
1548
  // per-message でも単発 InputQueue (1 件 push → 即 close) を prompt に渡し、1 query=1 ターンの
1539
1549
  // resume チェーン挙動は維持しつつ、文字列 prompt + canUseTool の SDK 制約リスクを回避する。
1540
1550
  const inputQueue = new InputQueue()
1541
- inputQueue.push(toSDKUserMessage(prompt))
1551
+ inputQueue.push(toSDKUserMessage(prompt, opts.message_uuid))
1542
1552
  const q = this.sdk.query({ prompt: inputQueue, options })
1543
1553
  const shouldWaitForTitle =
1544
1554
  isFreshStart && this._canGenerateSessionTitle(q, prompt)
@@ -2705,11 +2715,12 @@ export class ClaudeStreamBridge extends EventEmitter {
2705
2715
  * (テストでは `{ query: stubQuery }` を渡す)
2706
2716
  * @param {import('pino').Logger} [opts.logger]
2707
2717
  */
2708
- constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth } = {}) {
2718
+ constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth, projectsRoot } = {}) {
2709
2719
  super()
2710
2720
  if (!sdk || typeof sdk.query !== "function") {
2711
2721
  throw new TypeError("ClaudeStreamBridge requires { sdk: { query } }")
2712
2722
  }
2723
+ this._projectsRoot = projectsRoot
2713
2724
  this.sdk = sdk
2714
2725
  this.logger = logger
2715
2726
  /** needs-auth キャッシュ名読み取り (テスト差し替え用。既定は実ファイル読み込み)。 */
@@ -3695,7 +3706,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3695
3706
  *
3696
3707
  * @param {string} cwd
3697
3708
  * @param {string} text
3698
- * @returns {{status:"delivered", session:object} | {status:"not_found"} | {status:"ambiguous", candidates:object[]}}
3709
+ * @returns {object | Promise<object>} 配送先なし/曖昧は同期、配送先確定後は受理結果を待つ。
3699
3710
  */
3700
3711
  sendToCwd(cwd, text, source) {
3701
3712
  if (!cwd || !text) return { status: "not_found" }
@@ -3712,32 +3723,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3712
3723
  }
3713
3724
  }
3714
3725
  const { session, attached: att } = tier[0]
3715
- // 連携カード種別 (Phase 2): 配送先が確定した後に記録する (ambiguous/not_found では記録しない)。
3716
- // handle を控え、失敗時にその entry だけを除去する (round2: text 除去は並行同一本文で誤消費)。
3717
- const injectHandle = session._recordInjectSource?.(text, source)
3718
- session
3719
- .sendMessage({ role: "user", content: text })
3720
- .then((r) => {
3721
- // Phase 2 (major#3): sendMessage は throw せず {accepted:false} で失敗を返す経路がある
3722
- // (queue_dead / empty_message / pre-canceled)。この場合は echo/started は来ないので
3723
- // stale な live source entry を掃除し、**永続化もしない** (persist round1 major#1: 失敗を
3724
- // 永続化すると 30 日 stale が残り後続の同文 human を誤カード化する)。
3725
- if (r && r.accepted === false) {
3726
- session._removeInjectSource?.(injectHandle)
3727
- return
3728
- }
3729
- // 受理された (queued/started) ときだけ hydrate 用に永続化する (best-effort、live と独立)。
3730
- recordInjectSourcePersistent(cwd, text, source).catch(() => {})
3731
- })
3732
- .catch((err) => {
3733
- // 配送失敗 (throw) でも echo は来ないので stale な live entry を掃除する (永続化はしない)。
3734
- session._removeInjectSource?.(injectHandle)
3735
- this.logger?.error(
3736
- { cwd, err: err?.message },
3737
- "sendToCwd: sendMessage threw unexpectedly",
3738
- )
3739
- })
3740
- return { status: "delivered", session: this._describeCandidate(session, att) }
3726
+ return this._sendSiblingMessage(cwd, text, source, session, att)
3741
3727
  }
3742
3728
 
3743
3729
  /**
@@ -3761,7 +3747,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3761
3747
  * @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
3762
3748
  * @param {string} sessionId 宛先 session_id (describeLiveSessions の session_id)
3763
3749
  * @param {string} text
3764
- * @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
3750
+ * @returns {object | Promise<object>} 配送先なし/曖昧は同期、配送先確定後は受理結果を待つ。
3765
3751
  */
3766
3752
  sendToStream(cwd, sessionId, text, source) {
3767
3753
  if (!cwd || !sessionId || !text) return { status: "not_found" }
@@ -3780,28 +3766,127 @@ export class ClaudeStreamBridge extends EventEmitter {
3780
3766
  }
3781
3767
  }
3782
3768
  const { session, attached } = matches[0]
3783
- // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found/ambiguous では記録しない)。
3769
+ return this._sendSiblingMessage(cwd, text, source, session, attached)
3770
+ }
3771
+
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秒まで会話記録を確認する。 */
3833
+ async _sendSiblingMessage(cwd, text, source, session, attached) {
3834
+ const target = this._describeCandidate(session, attached)
3835
+ const delivered = { status: "delivered", session: target }
3784
3836
  const injectHandle = session._recordInjectSource?.(text, source)
3785
- session
3786
- .sendMessage({ role: "user", content: text })
3787
- .then((r) => {
3788
- // Phase 2 (major#3 / persist major#1): {accepted:false} は echo/started 来ないので live を
3789
- // 掃除し永続化もしない (失敗を永続化すると 30 日 stale で後続同文 human を誤カード化)。
3790
- if (r && r.accepted === false) {
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。 */ }
3847
+ let timer
3848
+ const outcome = (async () => {
3849
+ try {
3850
+ const result = await session.sendMessage({ role: "user", content: text }, { message_uuid })
3851
+ if (result?.accepted === false) {
3791
3852
  session._removeInjectSource?.(injectHandle)
3792
- return
3853
+ return { status: "delivery_failed", session: target, reason: result.reason }
3793
3854
  }
3794
- // 受理時のみ hydrate 用に永続化 (best-effort)。
3855
+ // 受理時のみ永続化する。timeout 後に受理が確定した場合も維持する。
3795
3856
  recordInjectSourcePersistent(cwd, text, source).catch(() => {})
3796
- })
3797
- .catch((err) => {
3857
+ if (result?.queued === true) {
3858
+ return { status: "delivery_queued", session: target, reason: "queued_behind_active_turn" }
3859
+ }
3860
+ return result?.accepted === true ? { ...delivered, confirm: true } : delivered
3861
+ } catch (err) {
3798
3862
  session._removeInjectSource?.(injectHandle)
3799
3863
  this.logger?.error(
3800
- { session_id: sessionId, err: err?.message },
3801
- "sendToStream: sendMessage threw unexpectedly",
3864
+ { cwd, session_id: session.sessionId, err: err?.message },
3865
+ "sibling sendMessage threw unexpectedly; delivery remains unconfirmed",
3802
3866
  )
3803
- })
3804
- return { status: "delivered", session: this._describeCandidate(session, attached) }
3867
+ return delivered
3868
+ }
3869
+ })()
3870
+ let accepted
3871
+ try {
3872
+ accepted = await Promise.race([
3873
+ outcome,
3874
+ new Promise((resolve) => {
3875
+ timer = setTimeout(() => {
3876
+ this.logger?.warn(
3877
+ { cwd, session_id: session.sessionId, timeout_ms: SIBLING_ACCEPT_WAIT_MS },
3878
+ "sibling sendMessage acceptance timed out; delivery remains unconfirmed",
3879
+ )
3880
+ resolve(delivered)
3881
+ }, SIBLING_ACCEPT_WAIT_MS)
3882
+ }),
3883
+ ])
3884
+ } finally {
3885
+ clearTimeout(timer)
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
3805
3890
  }
3806
3891
 
3807
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,