@cocorograph/hub-agent 0.8.117 → 0.8.119
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-history.mjs +77 -24
- package/src/grok-stream-bridge.mjs +4 -0
- package/src/main.mjs +36 -6
- package/src/session-restore.mjs +9 -7
- package/src/ws-client.mjs +18 -4
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -781,7 +781,7 @@ async function attachBackgroundSubagentHistory(filePath, { result, requests }, t
|
|
|
781
781
|
*
|
|
782
782
|
* 末尾 maxLines 行に絞ってから JSON.parse + DISPLAY_TYPES でフィルタ。
|
|
783
783
|
* 大きいファイルでも先頭から全行 parse する必要はないが、line buffer を最小化
|
|
784
|
-
*
|
|
784
|
+
* するため前方チャンク走査で末尾窓だけ保持する。
|
|
785
785
|
*
|
|
786
786
|
* @param {string} filePath
|
|
787
787
|
* @param {{maxLines?: number, logger?: import('pino').Logger}} [opts]
|
|
@@ -807,9 +807,78 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
807
807
|
}
|
|
808
808
|
}
|
|
809
809
|
|
|
810
|
+
// 前方に一度だけ走査し、末尾窓と全体の tool_use 索引を同時に作る。
|
|
811
|
+
// byteLimit は呼び出し側が stat したサイズ。走査中の追記を追い続けないよう、そこで止める
|
|
812
|
+
// (キャッシュも同じ stat の mtime/size で登録するので、読んだ範囲と照合キーが一致する)。
|
|
813
|
+
async function scanParentHistory(filePath, maxLines, byteLimit) {
|
|
814
|
+
const chunkSize = 1024 * 1024
|
|
815
|
+
// slice(-maxLines) の従来の挙動を 0・負数・小数等でも維持する。
|
|
816
|
+
const windowSize = Math.trunc(Number(maxLines))
|
|
817
|
+
const capacity = windowSize > 0 ? windowSize : Infinity
|
|
818
|
+
const lines = []
|
|
819
|
+
const toolNames = new Map()
|
|
820
|
+
let total_lines = 0
|
|
821
|
+
let next = 0
|
|
822
|
+
let fragments = []
|
|
823
|
+
let fragmentBytes = 0
|
|
824
|
+
const acceptLine = (bytes) => {
|
|
825
|
+
if (bytes.length === 0) return
|
|
826
|
+
const line = bytes.toString("utf-8")
|
|
827
|
+
total_lines++
|
|
828
|
+
if (lines.length < capacity) lines.push(line)
|
|
829
|
+
else {
|
|
830
|
+
lines[next] = line
|
|
831
|
+
next = (next + 1) % capacity
|
|
832
|
+
}
|
|
833
|
+
if (MASK_ENABLED && line.includes('"tool_use"')) {
|
|
834
|
+
try {
|
|
835
|
+
collectToolUseNames(JSON.parse(line), toolNames)
|
|
836
|
+
} catch {
|
|
837
|
+
/* ignore */
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const handle = await open(filePath, "r")
|
|
842
|
+
try {
|
|
843
|
+
let remaining = byteLimit
|
|
844
|
+
while (remaining > 0) {
|
|
845
|
+
// 未完行の断片が参照するので、次の read でバッファを上書きしない。
|
|
846
|
+
const want = Math.min(chunkSize, remaining)
|
|
847
|
+
const buffer = Buffer.allocUnsafe(want)
|
|
848
|
+
const { bytesRead } = await handle.read(buffer, 0, want, null)
|
|
849
|
+
if (bytesRead === 0) break // 並行 truncate
|
|
850
|
+
remaining -= bytesRead
|
|
851
|
+
const chunk = buffer.subarray(0, bytesRead)
|
|
852
|
+
let start = 0
|
|
853
|
+
let end
|
|
854
|
+
while ((end = chunk.indexOf(10, start)) !== -1) {
|
|
855
|
+
const part = chunk.subarray(start, end)
|
|
856
|
+
if (fragments.length) {
|
|
857
|
+
fragments.push(part)
|
|
858
|
+
acceptLine(Buffer.concat(fragments, fragmentBytes + part.length))
|
|
859
|
+
fragments = []
|
|
860
|
+
fragmentBytes = 0
|
|
861
|
+
} else acceptLine(part)
|
|
862
|
+
start = end + 1
|
|
863
|
+
}
|
|
864
|
+
if (start < bytesRead) {
|
|
865
|
+
const part = chunk.subarray(start)
|
|
866
|
+
fragments.push(part)
|
|
867
|
+
fragmentBytes += part.length
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (fragments.length) acceptLine(Buffer.concat(fragments, fragmentBytes))
|
|
871
|
+
} finally {
|
|
872
|
+
await handle.close()
|
|
873
|
+
}
|
|
874
|
+
const ordered = next ? lines.slice(next).concat(lines.slice(0, next)) : lines
|
|
875
|
+
const truncated = total_lines > maxLines
|
|
876
|
+
const slice = truncated ? ordered.slice(-maxLines) : ordered
|
|
877
|
+
return { slice, total_lines, truncated, toolNames }
|
|
878
|
+
}
|
|
879
|
+
|
|
810
880
|
async function readParentSessionHistory(filePath, { maxLines, logger, timing, attach }) {
|
|
811
|
-
//
|
|
812
|
-
// ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
|
|
881
|
+
// mtimeMs+size が前回と同じなら、親ファイルの走査と parse をスキップする。
|
|
813
882
|
let st
|
|
814
883
|
try {
|
|
815
884
|
st = await stat(filePath)
|
|
@@ -826,10 +895,12 @@ async function readParentSessionHistory(filePath, { maxLines, logger, timing, at
|
|
|
826
895
|
return attach(cached)
|
|
827
896
|
}
|
|
828
897
|
|
|
898
|
+
// ⚠️ read_ms は走査とマスク用 tool_use 索引の JSON.parse を含む (2026-10-27 のチャンク
|
|
899
|
+
// 走査化以降)。それ以前の read_ms / parse_ms とは内訳が違うので、前後比較は parent_ms で行う。
|
|
829
900
|
const readStarted = performance.now()
|
|
830
|
-
let
|
|
901
|
+
let scanned
|
|
831
902
|
try {
|
|
832
|
-
|
|
903
|
+
scanned = await scanParentHistory(filePath, maxLines, st.size)
|
|
833
904
|
} catch (err) {
|
|
834
905
|
if (err.code === "ENOENT") {
|
|
835
906
|
return { events: [], total_lines: 0, truncated: false }
|
|
@@ -838,25 +909,7 @@ async function readParentSessionHistory(filePath, { maxLines, logger, timing, at
|
|
|
838
909
|
throw err
|
|
839
910
|
}
|
|
840
911
|
const parseStarted = performance.now()
|
|
841
|
-
|
|
842
|
-
const lines = text.split("\n").filter((l) => l.length > 0)
|
|
843
|
-
const total_lines = lines.length
|
|
844
|
-
const truncated = total_lines > maxLines
|
|
845
|
-
const slice = truncated ? lines.slice(-maxLines) : lines
|
|
846
|
-
|
|
847
|
-
// マスク (2026-06-23): tool_use 行が表示スライス外へ truncate されても tool_result の
|
|
848
|
-
// スキル判定が効くよう、全行を事前走査して tool_use_id→name を集めておく。
|
|
849
|
-
const toolNames = new Map()
|
|
850
|
-
if (MASK_ENABLED) {
|
|
851
|
-
for (const line of lines) {
|
|
852
|
-
if (!line.includes('"tool_use"')) continue
|
|
853
|
-
try {
|
|
854
|
-
collectToolUseNames(JSON.parse(line), toolNames)
|
|
855
|
-
} catch {
|
|
856
|
-
/* ignore */
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
}
|
|
912
|
+
const { slice, total_lines, truncated, toolNames } = scanned
|
|
860
913
|
|
|
861
914
|
const events = []
|
|
862
915
|
const requests = new Map()
|
|
@@ -479,6 +479,10 @@ export class GrokStreamBridge extends EventEmitter {
|
|
|
479
479
|
session.cancelledTurn = session.turnSeq
|
|
480
480
|
this._clearTurnPermissions(session, session.turnSeq, true)
|
|
481
481
|
session.client.notify('session/cancel', { sessionId: session.sessionId })
|
|
482
|
+
if (!session.promptInFlight && !session.dispatchingInput) {
|
|
483
|
+
this.emit('grok_turn_complete', { stream_id: session.stream_id, session_id: session.sessionId,
|
|
484
|
+
stopReason: 'cancelled', lastSeq: session.updateSeq })
|
|
485
|
+
}
|
|
482
486
|
}
|
|
483
487
|
|
|
484
488
|
permissionReply({ stream_id, request_id, result }) {
|
package/src/main.mjs
CHANGED
|
@@ -2804,7 +2804,7 @@ export async function handleClaudeHistoryRequest(msg, ctx, {
|
|
|
2804
2804
|
// ログ失敗でも応答を送る。
|
|
2805
2805
|
}
|
|
2806
2806
|
}
|
|
2807
|
-
sendOk = ctx
|
|
2807
|
+
sendOk = sendHistoryFrame(ctx, {
|
|
2808
2808
|
type: "claude.history.response",
|
|
2809
2809
|
stream_id,
|
|
2810
2810
|
session_id,
|
|
@@ -2822,7 +2822,7 @@ export async function handleClaudeHistoryRequest(msg, ctx, {
|
|
|
2822
2822
|
total_lines: result.total_lines,
|
|
2823
2823
|
truncated: result.truncated,
|
|
2824
2824
|
error: result.error,
|
|
2825
|
-
})
|
|
2825
|
+
}, timing)
|
|
2826
2826
|
} finally {
|
|
2827
2827
|
logHistoryDelivery(ctx, "request", msg.stream_id, timing, payload, sendOk)
|
|
2828
2828
|
}
|
|
@@ -2857,6 +2857,11 @@ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
|
|
|
2857
2857
|
cache_hit: timing.cache_hit, parent_ms: timing.parent_ms,
|
|
2858
2858
|
subagent_ms: timing.subagent_ms, subagent_files: timing.subagent_files,
|
|
2859
2859
|
subagent_events: timing.subagent_events, tag_ms: timing.tag_ms,
|
|
2860
|
+
// 送信側の内訳 (2026-10-27): 送る前に既に詰まっていた量・frame の大きさ・文字列化時間。
|
|
2861
|
+
// ws_buffered (送信直後) との差で、この 1 通が送信待ちに積んだ量も読める。
|
|
2862
|
+
ws_buffered_before: timing.ws_buffered_before ?? null,
|
|
2863
|
+
payload_bytes: timing.payload_bytes ?? null,
|
|
2864
|
+
stringify_ms: timing.stringify_ms ?? null,
|
|
2860
2865
|
events: payload?.events?.length || 0, total_lines: payload?.total_lines || 0,
|
|
2861
2866
|
truncated: payload?.truncated === true, send_ok: Boolean(sendOk),
|
|
2862
2867
|
ws_buffered: ctx.client.bufferedAmount ?? null,
|
|
@@ -2868,16 +2873,35 @@ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
|
|
|
2868
2873
|
}
|
|
2869
2874
|
}
|
|
2870
2875
|
|
|
2876
|
+
function sendHistoryFrame(ctx, frame, timing) {
|
|
2877
|
+
// 計測値の取得失敗で配送を止めない (cross-review major)。取れなかった値は 0 ではなく null。
|
|
2878
|
+
try {
|
|
2879
|
+
timing.ws_buffered_before = ctx.client.bufferedAmount ?? null
|
|
2880
|
+
} catch {
|
|
2881
|
+
timing.ws_buffered_before = null
|
|
2882
|
+
}
|
|
2883
|
+
const stats = {}
|
|
2884
|
+
const ok = ctx.client.send(frame, stats)
|
|
2885
|
+
if (typeof stats.bytes === "number") timing.payload_bytes = stats.bytes
|
|
2886
|
+
if (typeof stats.stringify_ms === "number") timing.stringify_ms = stats.stringify_ms
|
|
2887
|
+
return ok
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2871
2890
|
export function sendClaudeAttachHistory(ctx, stream_id, payload, timing) {
|
|
2872
2891
|
if (!payload) return
|
|
2873
2892
|
let sendOk = false
|
|
2874
2893
|
try {
|
|
2875
|
-
sendOk = ctx
|
|
2894
|
+
sendOk = sendHistoryFrame(ctx, { ...payload, stream_id }, timing)
|
|
2876
2895
|
} finally {
|
|
2877
2896
|
logHistoryDelivery(ctx, "push", stream_id, timing, payload, sendOk)
|
|
2878
2897
|
}
|
|
2879
2898
|
}
|
|
2880
2899
|
|
|
2900
|
+
// attach 時の先回り push の窓 (元行数)。⚠️ 縮めるときはブラウザ側を先に直す: 追加取得は
|
|
2901
|
+
// 「今の先頭イベント」を境界にするため、窓に境界候補 (照合キーを持つイベント) が無いと
|
|
2902
|
+
// 追加取得が止まる (2026-10-27 cross-review。100 行化は Hub 側の対応後に行う)。
|
|
2903
|
+
export const ATTACH_HISTORY_PUSH_LINES = 500
|
|
2904
|
+
|
|
2881
2905
|
/**
|
|
2882
2906
|
* claude.attach の ready 直後にpushする履歴を組み立てる。
|
|
2883
2907
|
* Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
|
|
@@ -2922,7 +2946,7 @@ export async function loadClaudeAttachHistory({
|
|
|
2922
2946
|
const result = await timeHistoryPhase(timing, "fetch_ms", () => fetchHistoryFn({
|
|
2923
2947
|
cwd,
|
|
2924
2948
|
session_id: sessionId,
|
|
2925
|
-
maxLines:
|
|
2949
|
+
maxLines: ATTACH_HISTORY_PUSH_LINES,
|
|
2926
2950
|
projectsRoot,
|
|
2927
2951
|
logger,
|
|
2928
2952
|
timing,
|
|
@@ -3794,10 +3818,11 @@ export async function dispatch(msg, ctx) {
|
|
|
3794
3818
|
runInLane: scheduler && ((lane, fn) => scheduler.runInLane(lane, fn)),
|
|
3795
3819
|
claudeBridge: ctx.claudeBridge,
|
|
3796
3820
|
codexBridge: ctx.codexBridge,
|
|
3821
|
+
grokBridge: ctx.grokBridge,
|
|
3797
3822
|
claudeAttachOptions: () => resolveClaudeAttachOptions(ctx.config),
|
|
3798
3823
|
available: (kind) => kind === "codex"
|
|
3799
3824
|
? codexAvailableForDispatch(ctx)
|
|
3800
|
-
: ctx.availableClis?.includes(
|
|
3825
|
+
: ctx.availableClis?.includes(kind) === true,
|
|
3801
3826
|
logger: ctx.logger,
|
|
3802
3827
|
})
|
|
3803
3828
|
return
|
|
@@ -4990,6 +5015,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4990
5015
|
noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
|
|
4991
5016
|
result.stopped = await ctx.claudeBridge.stopSessionsFor({ cwd, reason: "chat-stop" })
|
|
4992
5017
|
} else if (msg.cli_kind === "grok") {
|
|
5018
|
+
noteRestoreStop(ctx.sessionRestoreState, "grok", cwd)
|
|
4993
5019
|
result.stopped = await ctx.grokBridge?.stopSessionsForCwd({ cwd, reason: "chat-stop" }) ?? 0
|
|
4994
5020
|
} else {
|
|
4995
5021
|
noteRestoreStop(ctx.sessionRestoreState, "codex", cwd)
|
|
@@ -5010,7 +5036,11 @@ export async function dispatch(msg, ctx) {
|
|
|
5010
5036
|
}
|
|
5011
5037
|
} else if (msg.cli_kind === "grok") {
|
|
5012
5038
|
if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
|
|
5013
|
-
else
|
|
5039
|
+
else {
|
|
5040
|
+
const streamCwd = ctx.grokBridge?.sessions?.get?.(msg.stream_id)?.cwd
|
|
5041
|
+
if (streamCwd) noteRestoreStop(ctx.sessionRestoreState, "grok", streamCwd)
|
|
5042
|
+
result.stopped = await ctx.grokBridge?.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" }) ?? 0
|
|
5043
|
+
}
|
|
5014
5044
|
} else if (msg.cli_kind === "codex") {
|
|
5015
5045
|
if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
|
|
5016
5046
|
else {
|
package/src/session-restore.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export function noteRestoreStop(state, cliKind, cwd) {
|
|
|
31
31
|
* 受付状態と Hub の「接続ごとに 1 回」の送信がかみ合わず、復元が途中で止まる (2 巡目 blocker)。
|
|
32
32
|
*/
|
|
33
33
|
export async function restoreSessions(message, {
|
|
34
|
-
state, claudeBridge, codexBridge, available, logger,
|
|
34
|
+
state, claudeBridge, codexBridge, grokBridge, available, logger,
|
|
35
35
|
runInLane = (_lane, fn) => fn(), statCwd = stat,
|
|
36
36
|
claudeAttachOptions = () => ({}),
|
|
37
37
|
}) {
|
|
@@ -40,7 +40,7 @@ export async function restoreSessions(message, {
|
|
|
40
40
|
return
|
|
41
41
|
}
|
|
42
42
|
state.accepted = true
|
|
43
|
-
const bridges = { claude: claudeBridge, codex: codexBridge }
|
|
43
|
+
const bridges = { claude: claudeBridge, codex: codexBridge, grok: grokBridge }
|
|
44
44
|
const logSkip = (row, reason) => logger?.info?.({
|
|
45
45
|
cli_kind: row?.cli_kind, cwd: row?.cwd, session_id: row?.session_id, reason,
|
|
46
46
|
}, "agent.sessions.restore skipped")
|
|
@@ -48,7 +48,7 @@ export async function restoreSessions(message, {
|
|
|
48
48
|
logSkip(null, "sessions must be an array")
|
|
49
49
|
return
|
|
50
50
|
}
|
|
51
|
-
const snapshot = () => describeLiveSessionsUnion({ claudeBridge, codexBridge }).map((s) => ({
|
|
51
|
+
const snapshot = () => describeLiveSessionsUnion({ claudeBridge, codexBridge, grokBridge }).map((s) => ({
|
|
52
52
|
cli_kind: s.kind, cwd: s.cwd, session_id: s.session_id || s.thread_id,
|
|
53
53
|
}))
|
|
54
54
|
const same = (a, b) => a.cli_kind === b.cli_kind && a.cwd === b.cwd && a.session_id === b.session_id
|
|
@@ -73,7 +73,7 @@ export async function restoreSessions(message, {
|
|
|
73
73
|
const live = [...current, ...selected.filter((r) => !current.some((s) => same(s, r)))]
|
|
74
74
|
if (live.length >= WARM_TOTAL_MAX) break
|
|
75
75
|
try {
|
|
76
|
-
if (!row || !["claude", "codex"].includes(row.cli_kind)) throw new Error("unknown CLI")
|
|
76
|
+
if (!row || !["claude", "codex", "grok"].includes(row.cli_kind)) throw new Error("unknown CLI")
|
|
77
77
|
if (!bridges[row.cli_kind] || !available(row.cli_kind)) throw new Error("CLI unavailable")
|
|
78
78
|
if (typeof row.session_id !== "string" || !row.session_id.trim()) throw new Error("empty session_id")
|
|
79
79
|
if (typeof row.cwd !== "string" || !path.isAbsolute(row.cwd)) throw new Error("cwd must be absolute")
|
|
@@ -115,9 +115,11 @@ export async function restoreSessions(message, {
|
|
|
115
115
|
// ここで欠けると復元した会話だけ権限モードが既定に落ちる (0.8.91 の実測)。
|
|
116
116
|
...(row.cli_kind === "claude"
|
|
117
117
|
? { ...claudeAttachOptions(), resumeSessionId: row.session_id }
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
: row.cli_kind === "grok"
|
|
119
|
+
? { resumeSessionId: row.session_id }
|
|
120
|
+
// Codex は承認ポリシー / サンドボックスを thread 開始時にしか受け取らない。Hub が
|
|
121
|
+
// Cockpit の既定を行に載せる (空なら載せない = config.toml 既定。通常 attach と同じ)。
|
|
122
|
+
: {
|
|
121
123
|
resumeThreadId: row.session_id,
|
|
122
124
|
approvalPolicy: nonEmpty(row.approval_policy),
|
|
123
125
|
sandbox: nonEmpty(row.sandbox),
|
package/src/ws-client.mjs
CHANGED
|
@@ -333,7 +333,13 @@ export class WsClient extends EventEmitter {
|
|
|
333
333
|
* heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
|
|
334
334
|
* buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つのでバッファに保持する。
|
|
335
335
|
*/
|
|
336
|
-
|
|
336
|
+
/**
|
|
337
|
+
* @param {object} obj
|
|
338
|
+
* @param {{bytes?: number, stringify_ms?: number}} [stats] 渡されたときだけ、実際に送れた
|
|
339
|
+
* frame の UTF-8 バイト数と JSON 文字列化時間を書き込む (履歴配信の計測用。通常の
|
|
340
|
+
* frame で毎回 byteLength を数えないよう、呼び出し側が明示したときに限る)。
|
|
341
|
+
*/
|
|
342
|
+
send(obj, stats) {
|
|
337
343
|
const type = obj?.type
|
|
338
344
|
const wsOpen = this.ws?.readyState === WebSocket.OPEN
|
|
339
345
|
// sync barrier 中に新しく発生した frame も同じ buffer へ積み、切断中 frame より
|
|
@@ -365,7 +371,7 @@ export class WsClient extends EventEmitter {
|
|
|
365
371
|
return false
|
|
366
372
|
}
|
|
367
373
|
}
|
|
368
|
-
return this._sendJson(obj)
|
|
374
|
+
return this._sendJson(obj, stats)
|
|
369
375
|
}
|
|
370
376
|
|
|
371
377
|
/** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
|
|
@@ -548,9 +554,17 @@ export class WsClient extends EventEmitter {
|
|
|
548
554
|
return `${scheme}//${host}/ws/cockpit/agent/`
|
|
549
555
|
}
|
|
550
556
|
|
|
551
|
-
_sendJson(obj) {
|
|
557
|
+
_sendJson(obj, stats) {
|
|
552
558
|
try {
|
|
553
|
-
|
|
559
|
+
const started = stats ? performance.now() : 0
|
|
560
|
+
const text = JSON.stringify(obj)
|
|
561
|
+
const stringifyMs = stats ? performance.now() - started : 0
|
|
562
|
+
this.ws.send(text)
|
|
563
|
+
// 送れた frame だけを記録する (送信例外時は stats を書かない)。
|
|
564
|
+
if (stats) {
|
|
565
|
+
stats.stringify_ms = stringifyMs
|
|
566
|
+
stats.bytes = Buffer.byteLength(text)
|
|
567
|
+
}
|
|
554
568
|
return true
|
|
555
569
|
} catch (err) {
|
|
556
570
|
this.logger?.warn({ err: err.message }, "ws send failed")
|