@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 +1 -1
- package/src/claude-stream-bridge.mjs +93 -11
- package/src/sibling-inject.mjs +8 -4
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
/**
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -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"
|
|
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,
|