@cocorograph/hub-agent 0.8.100 → 0.8.102
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/chat-sessions-snapshot.mjs +14 -2
- package/src/claude-stream-bridge.mjs +95 -12
- package/src/main.mjs +20 -1
- package/src/sibling-inject.mjs +8 -4
package/package.json
CHANGED
|
@@ -128,6 +128,15 @@ export async function buildChatSessionsSnapshot({
|
|
|
128
128
|
getChatSignal = defaultGetChatSignal,
|
|
129
129
|
}) {
|
|
130
130
|
const descriptors = describeLiveSessionsUnion(bridges)
|
|
131
|
+
// 行名で畳む前に、完全一致の cwd・CLI ごとに会話を数える。
|
|
132
|
+
// ID 未確定の記述子は同一会話と判断できないため、それぞれ 1 本とする。
|
|
133
|
+
const conversationsByCwd = new Map()
|
|
134
|
+
for (const descriptor of descriptors) {
|
|
135
|
+
if (!conversationsByCwd.has(descriptor.cwd)) conversationsByCwd.set(descriptor.cwd, new Map())
|
|
136
|
+
const byKind = conversationsByCwd.get(descriptor.cwd)
|
|
137
|
+
if (!byKind.has(descriptor.kind)) byKind.set(descriptor.kind, new Set())
|
|
138
|
+
byKind.get(descriptor.kind).add(conversationIdOf(descriptor) ?? Symbol())
|
|
139
|
+
}
|
|
131
140
|
const { targets, errors } = await listChatTargets({ logger })
|
|
132
141
|
const targetByCwd = chatTargetsByCwd(targets)
|
|
133
142
|
// 畳み込みでは同じ (cwd, kind) の信号を何度も引く (所有者判定 ×2 + 付与)。readRowChatState は
|
|
@@ -148,7 +157,10 @@ export async function buildChatSessionsSnapshot({
|
|
|
148
157
|
return hit.value
|
|
149
158
|
}
|
|
150
159
|
const named = descriptors.map((descriptor) => {
|
|
151
|
-
const item = {
|
|
160
|
+
const item = {
|
|
161
|
+
...descriptor,
|
|
162
|
+
live_count: conversationsByCwd.get(descriptor.cwd).get(descriptor.kind).size,
|
|
163
|
+
}
|
|
152
164
|
const cwd = typeof item.cwd === "string" ? item.cwd : ""
|
|
153
165
|
return { item, cwd, sessionName: chatRowSessionName(cwd, item.kind, targetByCwd) }
|
|
154
166
|
})
|
|
@@ -195,7 +207,7 @@ export async function buildChatSessionsSnapshot({
|
|
|
195
207
|
// 区別できず、2026-09-17 に実際にログだけでは判別できずに詰まった。attached は
|
|
196
208
|
// 「bridge から見て今ブラウザが繋がっているか」で、旧インスタンスの残骸は false に
|
|
197
209
|
// なりやすい (ブラウザ側の実際の生存を保証する値ではない)。
|
|
198
|
-
logger?.warn?.(
|
|
210
|
+
logger?.[winner.cwd === loser.cwd ? "debug" : "warn"]?.(
|
|
199
211
|
{
|
|
200
212
|
session_name: sessionName,
|
|
201
213
|
kept_cwd: replace ? cwd : kept.cwd,
|
|
@@ -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 キャッシュ名読み取り (テスト差し替え用。既定は実ファイル読み込み)。 */
|
|
@@ -3493,7 +3501,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3493
3501
|
*
|
|
3494
3502
|
* ⚠️ ここに出るのは SDK チャットセッションのみ (tmux の TUI claude は対象外)。
|
|
3495
3503
|
* @param {string} [cwd] 指定時はその cwd のみ
|
|
3496
|
-
* @returns {Array<{kind: string, cwd: string, session_id: string|null, idle_ms: number}>}
|
|
3504
|
+
* @returns {Array<{kind: string, cwd: string, session_id: string|null, busy: boolean, idle_ms: number}>}
|
|
3497
3505
|
*/
|
|
3498
3506
|
describeLiveSessions(cwd) {
|
|
3499
3507
|
return this._liveCandidatesForCwd(cwd, false).map(({ session, attached }) =>
|
|
@@ -3570,6 +3578,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3570
3578
|
kind: "claude",
|
|
3571
3579
|
cwd: session.cwd,
|
|
3572
3580
|
session_id: session.sessionId || null,
|
|
3581
|
+
busy: session._busy === true,
|
|
3573
3582
|
idle_ms: at ? now - at : -1,
|
|
3574
3583
|
attached,
|
|
3575
3584
|
}
|
|
@@ -3761,15 +3770,85 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3761
3770
|
return this._sendSiblingMessage(cwd, text, source, session, attached)
|
|
3762
3771
|
}
|
|
3763
3772
|
|
|
3764
|
-
/**
|
|
3773
|
+
/** 追記された完全な JSONL 行だけを確認する。パス変更時は新ファイルの先頭へ戻す。 */
|
|
3774
|
+
async _confirmSiblingTranscript(session, cwd, uuid, cursor, startedAt) {
|
|
3775
|
+
const deadline = startedAt + SIBLING_CONFIRM_WAIT_MS
|
|
3776
|
+
try {
|
|
3777
|
+
while (Date.now() < deadline) {
|
|
3778
|
+
const file = session.sessionId
|
|
3779
|
+
? jsonlPath({ cwd, session_id: session.sessionId, projectsRoot: this._projectsRoot })
|
|
3780
|
+
: null
|
|
3781
|
+
if (file !== cursor.file) Object.assign(cursor, { file, offset: 0, pending: Buffer.alloc(0), skipping: false })
|
|
3782
|
+
if (file) {
|
|
3783
|
+
let handle
|
|
3784
|
+
try {
|
|
3785
|
+
handle = await open(file, "r")
|
|
3786
|
+
const { size } = await handle.stat()
|
|
3787
|
+
while (cursor.offset < size && Date.now() < deadline) {
|
|
3788
|
+
const buffer = Buffer.alloc(Math.min(64 * 1024, size - cursor.offset))
|
|
3789
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, cursor.offset)
|
|
3790
|
+
if (!bytesRead) break
|
|
3791
|
+
cursor.offset += bytesRead
|
|
3792
|
+
const data = Buffer.concat([cursor.pending, buffer.subarray(0, bytesRead)])
|
|
3793
|
+
let start = 0
|
|
3794
|
+
if (cursor.skipping) {
|
|
3795
|
+
// 上限超えの行の続き。途中から JSON として読まず、次の改行の後から再開する。
|
|
3796
|
+
const newline = data.indexOf(10)
|
|
3797
|
+
if (newline === -1) {
|
|
3798
|
+
cursor.pending = Buffer.alloc(0)
|
|
3799
|
+
continue
|
|
3800
|
+
}
|
|
3801
|
+
start = newline + 1
|
|
3802
|
+
cursor.skipping = false
|
|
3803
|
+
}
|
|
3804
|
+
for (let end; (end = data.indexOf(10, start)) !== -1; start = end + 1) {
|
|
3805
|
+
try {
|
|
3806
|
+
const row = JSON.parse(data.subarray(start, end).toString("utf8"))
|
|
3807
|
+
if (row?.type === "user" && row.uuid === uuid) return true
|
|
3808
|
+
} catch { /* 壊れた行は確認の根拠にしない。 */ }
|
|
3809
|
+
}
|
|
3810
|
+
cursor.pending = data.subarray(start)
|
|
3811
|
+
if (cursor.pending.length > SIBLING_CONFIRM_MAX_LINE_BYTES) {
|
|
3812
|
+
cursor.pending = Buffer.alloc(0)
|
|
3813
|
+
cursor.skipping = true
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
} catch (err) {
|
|
3817
|
+
if (err.code !== "ENOENT") return false
|
|
3818
|
+
} finally {
|
|
3819
|
+
await handle?.close()
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
const remaining = deadline - Date.now()
|
|
3823
|
+
if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(SIBLING_CONFIRM_POLL_MS, remaining)))
|
|
3824
|
+
}
|
|
3825
|
+
this.logger?.info(
|
|
3826
|
+
{ cwd, session_id: session.sessionId, uuid, elapsed_ms: Date.now() - startedAt },
|
|
3827
|
+
"sibling transcript confirmation timed out; delivery remains unconfirmed",
|
|
3828
|
+
)
|
|
3829
|
+
} catch { /* 確認不能を配送失敗にしない。 */ }
|
|
3830
|
+
return false
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
/** 受理だけを1秒待ち、直接受理された入力は送信開始から2.5秒まで会話記録を確認する。 */
|
|
3765
3834
|
async _sendSiblingMessage(cwd, text, source, session, attached) {
|
|
3766
3835
|
const target = this._describeCandidate(session, attached)
|
|
3767
3836
|
const delivered = { status: "delivered", session: target }
|
|
3768
3837
|
const injectHandle = session._recordInjectSource?.(text, source)
|
|
3838
|
+
const message_uuid = randomUUID()
|
|
3839
|
+
const transcriptCwd = session.cwd || cwd
|
|
3840
|
+
const startedAt = Date.now()
|
|
3841
|
+
const cursor = { file: null, offset: 0, pending: Buffer.alloc(0), skipping: false }
|
|
3842
|
+
try {
|
|
3843
|
+
if (session.sessionId) {
|
|
3844
|
+
cursor.file = jsonlPath({ cwd: transcriptCwd, session_id: session.sessionId, projectsRoot: this._projectsRoot })
|
|
3845
|
+
cursor.offset = statSync(cursor.file).size
|
|
3846
|
+
}
|
|
3847
|
+
} catch { /* 新規ファイル・読み取り不能なら offset 0。 */ }
|
|
3769
3848
|
let timer
|
|
3770
3849
|
const outcome = (async () => {
|
|
3771
3850
|
try {
|
|
3772
|
-
const result = await session.sendMessage({ role: "user", content: text })
|
|
3851
|
+
const result = await session.sendMessage({ role: "user", content: text }, { message_uuid })
|
|
3773
3852
|
if (result?.accepted === false) {
|
|
3774
3853
|
session._removeInjectSource?.(injectHandle)
|
|
3775
3854
|
return { status: "delivery_failed", session: target, reason: result.reason }
|
|
@@ -3779,7 +3858,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3779
3858
|
if (result?.queued === true) {
|
|
3780
3859
|
return { status: "delivery_queued", session: target, reason: "queued_behind_active_turn" }
|
|
3781
3860
|
}
|
|
3782
|
-
return delivered
|
|
3861
|
+
return result?.accepted === true ? { ...delivered, confirm: true } : delivered
|
|
3783
3862
|
} catch (err) {
|
|
3784
3863
|
session._removeInjectSource?.(injectHandle)
|
|
3785
3864
|
this.logger?.error(
|
|
@@ -3789,8 +3868,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3789
3868
|
return delivered
|
|
3790
3869
|
}
|
|
3791
3870
|
})()
|
|
3871
|
+
let accepted
|
|
3792
3872
|
try {
|
|
3793
|
-
|
|
3873
|
+
accepted = await Promise.race([
|
|
3794
3874
|
outcome,
|
|
3795
3875
|
new Promise((resolve) => {
|
|
3796
3876
|
timer = setTimeout(() => {
|
|
@@ -3805,6 +3885,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3805
3885
|
} finally {
|
|
3806
3886
|
clearTimeout(timer)
|
|
3807
3887
|
}
|
|
3888
|
+
if (!accepted.confirm) return accepted
|
|
3889
|
+
const confirmed = await this._confirmSiblingTranscript(session, transcriptCwd, message_uuid, cursor, startedAt)
|
|
3890
|
+
return confirmed ? { ...delivered, confirmed_via: "transcript" } : delivered
|
|
3808
3891
|
}
|
|
3809
3892
|
|
|
3810
3893
|
/**
|
package/src/main.mjs
CHANGED
|
@@ -4191,13 +4191,32 @@ export async function dispatch(msg, ctx) {
|
|
|
4191
4191
|
projectsRoot,
|
|
4192
4192
|
logger: ctx.logger,
|
|
4193
4193
|
})
|
|
4194
|
+
// 履歴一覧に、同じ cwd の bridge が持つ現在の live / busy を併合する。
|
|
4195
|
+
// ⚠️ live の列挙に失敗しても取得済みの履歴は返す。その場合は live / busy を付けない
|
|
4196
|
+
// (= 不明。false にすると「常駐していない」と断定してしまう)。途中まで作った Map は使わない。
|
|
4197
|
+
let liveBySession = new Map()
|
|
4198
|
+
try {
|
|
4199
|
+
const next = new Map()
|
|
4200
|
+
for (const s of ctx.claudeBridge?.describeLiveSessions?.(cwd) ?? []) {
|
|
4201
|
+
if (s.session_id) next.set(s.session_id, { live: true, busy: !!s.busy })
|
|
4202
|
+
}
|
|
4203
|
+
liveBySession = next
|
|
4204
|
+
} catch (err) {
|
|
4205
|
+
liveBySession = null
|
|
4206
|
+
ctx.logger?.warn?.({ err: String(err?.message || err), cwd }, "claude.sessions: live 列挙に失敗 (live/busy を付けずに履歴を返す)")
|
|
4207
|
+
}
|
|
4208
|
+
const withLive = (result.sessions || []).map((s) => (liveBySession ? {
|
|
4209
|
+
...s,
|
|
4210
|
+
live: liveBySession.get(s.session_id)?.live === true,
|
|
4211
|
+
busy: liveBySession.get(s.session_id)?.busy === true,
|
|
4212
|
+
} : s))
|
|
4194
4213
|
ctx.client.send({
|
|
4195
4214
|
type: "claude.sessions.response",
|
|
4196
4215
|
stream_id,
|
|
4197
4216
|
cwd,
|
|
4198
4217
|
epoch,
|
|
4199
4218
|
profile_id: profileId,
|
|
4200
|
-
sessions:
|
|
4219
|
+
sessions: withLive,
|
|
4201
4220
|
error: result.error,
|
|
4202
4221
|
})
|
|
4203
4222
|
return
|
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,
|