@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 +1 -1
- package/src/claude-stream-bridge.mjs +137 -52
- 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
|
|
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
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
|
-
//
|
|
3855
|
+
// 受理時のみ永続化する。timeout 後に受理が確定した場合も維持する。
|
|
3795
3856
|
recordInjectSourcePersistent(cwd, text, source).catch(() => {})
|
|
3796
|
-
|
|
3797
|
-
|
|
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
|
-
"
|
|
3864
|
+
{ cwd, session_id: session.sessionId, err: err?.message },
|
|
3865
|
+
"sibling sendMessage threw unexpectedly; delivery remains unconfirmed",
|
|
3802
3866
|
)
|
|
3803
|
-
|
|
3804
|
-
|
|
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
|
/**
|
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,
|