cc-viewer 1.9.17 → 1.9.19
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/cli.js +51 -1
- package/dist/assets/{App-BlNbziAQ.js → App-5GFhfhAM.js} +2 -2
- package/dist/assets/{MdxEditorPanel-C9cr-WBH.js → MdxEditorPanel-C71H-8ZP.js} +1 -1
- package/dist/assets/{Mobile-CDQPYYQo.js → Mobile-CAqle3TP.js} +1 -1
- package/dist/assets/{ProxyStatsModal-BEqrTR-F.js → ProxyStatsModal-B0ySgEsq.js} +1 -1
- package/dist/assets/{index-B4J-uwYG.js → index-C4_-ImaE.js} +2 -2
- package/dist/assets/{seqResourceLoaders-B-5xGs9q.css → seqResourceLoaders-C8O3LxzJ.css} +1 -1
- package/dist/assets/seqResourceLoaders-CXYXv_fq.js +2 -0
- package/dist/index.html +1 -1
- package/dist/voice-packs/default/agentLoopStart.wav +0 -0
- package/dist/voice-packs/default/pack.json +11 -0
- package/dist/voice-packs/sanguo/agentLoopStart.wav +0 -0
- package/dist/voice-packs/sanguo/pack.json +11 -0
- package/node_modules/@ccv/core/src/voice-pack-events.js +12 -3
- package/package.json +2 -2
- package/server/lib/attach-buffer.js +164 -0
- package/server/lib/plugin-loader.js +36 -2
- package/server/lib/plugin-runtime.js +12 -2
- package/server/lib/sdk-manager.js +727 -79
- package/server/lib/v2/live-feed.js +108 -7
- package/server/lib/v2/meta-rows.js +35 -4
- package/server/proxy.js +52 -8
- package/server/routes/events.js +64 -1
- package/server/routes/files-content.js +24 -1
- package/server/server.js +131 -11
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-C4_-ImaE.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-I6I2F7Rv.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-Zkm6UHQg.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CC09MDyZ.js">
|
|
Binary file
|
|
@@ -14,11 +14,22 @@
|
|
|
14
14
|
"turnEnd": {
|
|
15
15
|
"file": "It_is_done_sir.MP3",
|
|
16
16
|
"size": 45816
|
|
17
|
+
},
|
|
18
|
+
"agentLoopStart": {
|
|
19
|
+
"file": "agentLoopStart.wav",
|
|
20
|
+
"size": 12394
|
|
21
|
+
},
|
|
22
|
+
"agentLoopEnd": {
|
|
23
|
+
"file": "It_is_done_sir.MP3",
|
|
24
|
+
"size": 45816
|
|
17
25
|
}
|
|
18
26
|
},
|
|
19
27
|
"voiceLines": {
|
|
20
28
|
"planApproval": "The plan awaits your approval, sir.",
|
|
21
29
|
"askQuestion": "A choice for your consideration, sir.",
|
|
22
30
|
"turnEnd": "It is done, sir."
|
|
31
|
+
},
|
|
32
|
+
"cues": {
|
|
33
|
+
"agentLoopStart": "Dee-dee! (短促上行双音,\"开工\")"
|
|
23
34
|
}
|
|
24
35
|
}
|
|
Binary file
|
|
@@ -14,11 +14,22 @@
|
|
|
14
14
|
"turnEnd": {
|
|
15
15
|
"file": "end.MP3",
|
|
16
16
|
"size": 70894
|
|
17
|
+
},
|
|
18
|
+
"agentLoopStart": {
|
|
19
|
+
"file": "agentLoopStart.wav",
|
|
20
|
+
"size": 12394
|
|
21
|
+
},
|
|
22
|
+
"agentLoopEnd": {
|
|
23
|
+
"file": "end.MP3",
|
|
24
|
+
"size": 70894
|
|
17
25
|
}
|
|
18
26
|
},
|
|
19
27
|
"voiceLines": {
|
|
20
28
|
"planApproval": "请陛下御览。",
|
|
21
29
|
"askQuestion": "请陛下圣裁。",
|
|
22
30
|
"turnEnd": "臣已办妥。"
|
|
31
|
+
},
|
|
32
|
+
"cues": {
|
|
33
|
+
"agentLoopStart": "Dee-dee! (短促上行双音,\"开工\")"
|
|
23
34
|
}
|
|
24
35
|
}
|
|
@@ -19,6 +19,8 @@ export const EVENT_KEYS = [
|
|
|
19
19
|
'planApproval',
|
|
20
20
|
'askQuestion',
|
|
21
21
|
'turnEnd',
|
|
22
|
+
'agentLoopStart',
|
|
23
|
+
'agentLoopEnd',
|
|
22
24
|
];
|
|
23
25
|
|
|
24
26
|
// Per-event default binding when no user override is set:
|
|
@@ -26,11 +28,16 @@ export const EVENT_KEYS = [
|
|
|
26
28
|
// - 'sanguo' → play the bundled sanguo (三国) audio — initial seed for zh/zh-TW
|
|
27
29
|
// - null → event is OFF by default (user must opt in)
|
|
28
30
|
// turnEnd defaults to null because firing on every Claude reply is noisy
|
|
29
|
-
//( — frequency overload mitigation).
|
|
31
|
+
//( — frequency overload mitigation). agentLoopStart/agentLoopEnd are likewise
|
|
32
|
+
// per-turn and stay off by default for the same frequency reason. (Enabling both
|
|
33
|
+
// agentLoopEnd and turnEnd does NOT double-ring: the client suppresses the legacy
|
|
34
|
+
// turnEnd cue when agentLoopEnd actually played for the same boundary.)
|
|
30
35
|
export const DEFAULT_BINDINGS = Object.freeze({
|
|
31
36
|
planApproval: 'default',
|
|
32
37
|
askQuestion: 'default',
|
|
33
38
|
turnEnd: null,
|
|
39
|
+
agentLoopStart: null,
|
|
40
|
+
agentLoopEnd: null,
|
|
34
41
|
});
|
|
35
42
|
|
|
36
43
|
// Bundled packs that ship inside the npm tarball under public/voice-packs/<id>/.
|
|
@@ -54,13 +61,15 @@ const LOCALE_DEFAULT_SEEDS = Object.freeze({
|
|
|
54
61
|
});
|
|
55
62
|
|
|
56
63
|
// Per-event default bindings for a given locale pack. Event keys that should
|
|
57
|
-
// stay off-by-default (turnEnd is noisy on every reply
|
|
58
|
-
// of which pack the locale prefers.
|
|
64
|
+
// stay off-by-default (turnEnd is noisy on every reply; the agentLoop pair is
|
|
65
|
+
// likewise per-turn) keep null regardless of which pack the locale prefers.
|
|
59
66
|
function bindingsForPack(packId) {
|
|
60
67
|
return Object.freeze({
|
|
61
68
|
planApproval: packId,
|
|
62
69
|
askQuestion: packId,
|
|
63
70
|
turnEnd: null,
|
|
71
|
+
agentLoopStart: null,
|
|
72
|
+
agentLoopEnd: null,
|
|
64
73
|
});
|
|
65
74
|
}
|
|
66
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.19",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"ws": "^8.21.0"
|
|
67
67
|
},
|
|
68
68
|
"optionalDependencies": {
|
|
69
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
69
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.124"
|
|
70
70
|
},
|
|
71
71
|
"//bundledDependencies": "@ccv/core is the private workspace package (packages/core). npm pack follows the pnpm workspace symlink and ships it as real files inside the tarball — no registry publish, no import rewriting.",
|
|
72
72
|
"bundledDependencies": [
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Attach buffer (2026-10): closes the cold-load → clients.push broadcast-loss
|
|
2
|
+
// window on /events.
|
|
3
|
+
//
|
|
4
|
+
// Background: serveEventStream deliberately does NOT register the client into
|
|
5
|
+
// deps.clients until the whole cold history + load_end has been written (see
|
|
6
|
+
// the "do not clients.push here" note in routes/events.js — a live entry
|
|
7
|
+
// arriving mid-load would be overwritten by load_end's setState, causing a
|
|
8
|
+
// show→disappear→reappear flicker). The cost is a real gap: any live v2 delta
|
|
9
|
+
// that lands between the
|
|
10
|
+
// frozen cold snapshot and the registration is neither in that snapshot nor
|
|
11
|
+
// broadcast to this not-yet-registered client, and the server has no retained
|
|
12
|
+
// replay. The FIRST turn of a fresh `ccv --sdk` is the turn most likely to fall
|
|
13
|
+
// into this gap (lazy resident-query construction + first-time session-dir
|
|
14
|
+
// creation slow the cold load while the first turn is already producing
|
|
15
|
+
// entries), so the conversation stalls until a manual refresh re-cold-loads.
|
|
16
|
+
//
|
|
17
|
+
// The fix is a per-connection buffer proxy: while the cold load runs, a
|
|
18
|
+
// stand-in client — stamped with the SAME _ccvViewProject/_ccvViewInstance as
|
|
19
|
+
// the eventual registrable so the existing feed routing delivers exactly the
|
|
20
|
+
// frames this connection is owed — sits in deps.clients and queues every SSE
|
|
21
|
+
// frame instead of writing a socket. After load_end (and kv/context), the
|
|
22
|
+
// queued frames are replayed through the real sink in emission order, then the
|
|
23
|
+
// proxy is swapped for the real registrable in one synchronous block (no
|
|
24
|
+
// broadcast can interleave between the replay and the swap on the single
|
|
25
|
+
// thread). The anti-flicker invariant is preserved because the proxy never
|
|
26
|
+
// writes the socket before load_end.
|
|
27
|
+
//
|
|
28
|
+
// Dedup: rows (v2_requests_delta) and responses lines (v3_resp) upsert
|
|
29
|
+
// client-side by (sessionId, seq) and are therefore idempotent — replay them
|
|
30
|
+
// verbatim. v3_conv is NOT idempotent on the client (createV3Assembler
|
|
31
|
+
// appends conv events and consumes them with a monotonic pointer, so a
|
|
32
|
+
// replayed line that the cold bundle already carried would be applied twice
|
|
33
|
+
// and duplicate messages). The server therefore filters replayed v3_conv
|
|
34
|
+
// frames against the cold bundle's conv coverage (`native.convSeqs` from
|
|
35
|
+
// readV2NativeCold): a frame is skipped when its seq was already sent.
|
|
36
|
+
|
|
37
|
+
import { reportSwallowed } from './error-report.js';
|
|
38
|
+
|
|
39
|
+
/** Per-connection cap on buffered bytes (mirrors the mux QUEUE_MAX_BYTES). */
|
|
40
|
+
export const ATTACH_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
const CONV_FRAME_PREFIX = 'event: v3_conv\ndata: ';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a buffer client that stands in for one /events connection during its
|
|
46
|
+
* cold load. Compatible with _safeSseWrite (log-watcher.js): it exposes
|
|
47
|
+
* destroyed/writable/write, _wireEnc stays undefined so sseWrite routes the raw
|
|
48
|
+
* (uncompressed) SSE text straight to write(), and write() ALWAYS returns true
|
|
49
|
+
* so the backpressure-kill branch never fires for a proxy.
|
|
50
|
+
*
|
|
51
|
+
* @param {{project?: string, instance?: string, maxBytes?: number,
|
|
52
|
+
* onOverflow?: (bytes: number) => void}} opts
|
|
53
|
+
*/
|
|
54
|
+
export function createAttachBufferClient({ project, instance, maxBytes, onOverflow } = {}) {
|
|
55
|
+
const limit = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : ATTACH_BUFFER_MAX_BYTES;
|
|
56
|
+
const client = {
|
|
57
|
+
destroyed: false,
|
|
58
|
+
writable: true,
|
|
59
|
+
_wireEnc: undefined,
|
|
60
|
+
_ccvAttachBuffer: true,
|
|
61
|
+
frames: [],
|
|
62
|
+
bytes: 0,
|
|
63
|
+
overflowed: false,
|
|
64
|
+
write(text) {
|
|
65
|
+
if (client.destroyed || client.overflowed) return true;
|
|
66
|
+
const len = typeof text === 'string' ? text.length : (text ? text.length : 0);
|
|
67
|
+
if (client.bytes + len > limit) {
|
|
68
|
+
// Overflow: degrade to today's pre-fix behavior (drop the window's
|
|
69
|
+
// increments) rather than risk an unbounded queue. Never replay a
|
|
70
|
+
// partial window — a truncated conv/row sequence would corrupt order.
|
|
71
|
+
const at = client.bytes + len;
|
|
72
|
+
client.overflowed = true;
|
|
73
|
+
client._clear();
|
|
74
|
+
try { if (onOverflow) onOverflow(at); }
|
|
75
|
+
catch (err) { reportSwallowed('sse.attach-buffer.overflow-cb', err); }
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
client.frames.push(text);
|
|
79
|
+
client.bytes += len;
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
end() { client._clear(); client.destroyed = true; client.writable = false; },
|
|
83
|
+
// No-op listener surface: nothing should hang off a proxy, but a stray
|
|
84
|
+
// .once('drain') from a future code path must not throw.
|
|
85
|
+
on() {}, once() {}, off() {}, removeListener() {},
|
|
86
|
+
_clear() { client.frames.length = 0; client.bytes = 0; },
|
|
87
|
+
};
|
|
88
|
+
// Stamp the SAME view scope the real registrable will carry, so
|
|
89
|
+
// filterClientsByViewProject / filterClientsByInstance route this proxy
|
|
90
|
+
// exactly the frames the eventual client should receive.
|
|
91
|
+
if (project) client._ccvViewProject = project;
|
|
92
|
+
if (instance) client._ccvViewInstance = instance;
|
|
93
|
+
return client;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract the seq of a v3_conv frame's line, or null when the frame is not a
|
|
98
|
+
* conv frame / cannot be parsed. Parses the {sessionId, channel, line}
|
|
99
|
+
* envelope the live feed forwards (live-feed.js _forwardNative).
|
|
100
|
+
* @returns {{key: string, seq: number|null} | null}
|
|
101
|
+
*/
|
|
102
|
+
function _parseConvFrame(frameText) {
|
|
103
|
+
if (typeof frameText !== 'string' || !frameText.startsWith(CONV_FRAME_PREFIX)) return null;
|
|
104
|
+
const jsonStart = CONV_FRAME_PREFIX.length;
|
|
105
|
+
const jsonEnd = frameText.indexOf('\n', jsonStart);
|
|
106
|
+
const json = jsonEnd === -1 ? frameText.slice(jsonStart) : frameText.slice(jsonStart, jsonEnd);
|
|
107
|
+
let env;
|
|
108
|
+
try { env = JSON.parse(json); } catch { return null; }
|
|
109
|
+
if (!env || typeof env.sessionId !== 'string') return null;
|
|
110
|
+
const channel = typeof env.channel === 'string' ? env.channel : 'main';
|
|
111
|
+
const seq = env.line && Number.isInteger(env.line.seq) ? env.line.seq : null;
|
|
112
|
+
return { key: `${env.sessionId}\x00${channel}`, seq };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Should this replayed conv frame be skipped because the cold bundle already
|
|
117
|
+
* carried it? A frame is covered when its channel's coverage has the seq in
|
|
118
|
+
* its window set, OR the seq predates the window's base (a snapshot before
|
|
119
|
+
* baseSeq was already reflected in the cold assembly — replaying it would be
|
|
120
|
+
* consumed a second time by the monotonic conv pointer).
|
|
121
|
+
*/
|
|
122
|
+
function _isCoveredConvFrame(frameText, convCoverage) {
|
|
123
|
+
if (!convCoverage) return false;
|
|
124
|
+
const parsed = _parseConvFrame(frameText);
|
|
125
|
+
if (!parsed || parsed.seq === null) return false; // fail-open: replay
|
|
126
|
+
const cov = convCoverage.get(parsed.key);
|
|
127
|
+
if (!cov) return false;
|
|
128
|
+
if (cov.seqs && cov.seqs.has(parsed.seq)) return true;
|
|
129
|
+
if (Number.isInteger(cov.baseSeq) && parsed.seq < cov.baseSeq) return true;
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Replay the queued frames through the real sink (in emission order), skipping
|
|
135
|
+
* any v3_conv frame the cold bundle already covered. Safe to call with a null
|
|
136
|
+
* or overflowed client (no-op). Stops at the first dead sink or write error.
|
|
137
|
+
*
|
|
138
|
+
* @param {{write: (s:string)=>any, alive?: ()=>boolean}} sink
|
|
139
|
+
* @param {object|null} client - the attach buffer client.
|
|
140
|
+
* @param {Map<string,{seqs:Set<number>, baseSeq:number}>|null} convCoverage
|
|
141
|
+
* @returns {{replayed: number, skipped: number}}
|
|
142
|
+
*/
|
|
143
|
+
export function flushAttachBuffer(sink, client, convCoverage) {
|
|
144
|
+
const result = { replayed: 0, skipped: 0 };
|
|
145
|
+
if (!client || client.overflowed || client.destroyed) {
|
|
146
|
+
if (client) client._clear();
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
const frames = client.frames;
|
|
150
|
+
for (let i = 0; i < frames.length; i++) {
|
|
151
|
+
if (typeof sink.alive === 'function' && !sink.alive()) break;
|
|
152
|
+
const frame = frames[i];
|
|
153
|
+
if (_isCoveredConvFrame(frame, convCoverage)) { result.skipped++; continue; }
|
|
154
|
+
try {
|
|
155
|
+
sink.write(frame);
|
|
156
|
+
result.replayed++;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
reportSwallowed('sse.attach-buffer.flush', err);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
client._clear();
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
@@ -31,8 +31,17 @@ const HOOK_TYPES = {
|
|
|
31
31
|
serverStopping: 'parallel',
|
|
32
32
|
onNewEntry: 'parallel',
|
|
33
33
|
onStreamChunk: 'parallel',
|
|
34
|
+
onAgentLoopStart: 'parallel',
|
|
35
|
+
onAgentLoopEnd: 'parallel',
|
|
34
36
|
};
|
|
35
37
|
|
|
38
|
+
// Approval hooks gate an interactive UI modal, so a hung plugin must not park the approval
|
|
39
|
+
// forever: each invocation times out and falls back to the normal approval modal.
|
|
40
|
+
// Non-approval waterfall hooks (beforeRequest/httpsOptions/localUrl) are startup/auth-critical
|
|
41
|
+
// and stay UNTIMED.
|
|
42
|
+
const APPROVAL_HOOK_TIMEOUT_MS = 15 * 1000;
|
|
43
|
+
const APPROVAL_HOOKS = new Set(['onPlanRequest', 'onAskRequest', 'onPermRequest']);
|
|
44
|
+
|
|
36
45
|
let _plugins = [];
|
|
37
46
|
// Full result of the last loadPlugins (directory service/panel plugins and failures included)
|
|
38
47
|
let _entries = [];
|
|
@@ -312,14 +321,39 @@ export async function loadPlugins(opts = {}) {
|
|
|
312
321
|
|
|
313
322
|
/**
|
|
314
323
|
* waterfall hook:串行管道执行,前一个的返回值传给下一个
|
|
324
|
+
* `timeoutMsOverride` is test-only; approval hooks default to APPROVAL_HOOK_TIMEOUT_MS.
|
|
315
325
|
*/
|
|
316
|
-
export async function runWaterfallHook(name, initialValue) {
|
|
326
|
+
export async function runWaterfallHook(name, initialValue, timeoutMsOverride) {
|
|
317
327
|
let value = initialValue;
|
|
328
|
+
// Only approval hooks are timed out; the override is ignored for the other hooks.
|
|
329
|
+
const timeoutMs = APPROVAL_HOOKS.has(name)
|
|
330
|
+
? (timeoutMsOverride ?? APPROVAL_HOOK_TIMEOUT_MS)
|
|
331
|
+
: undefined;
|
|
318
332
|
for (const plugin of _plugins) {
|
|
319
333
|
const hookFn = plugin.hooks[name];
|
|
320
334
|
if (typeof hookFn !== 'function') continue;
|
|
321
335
|
try {
|
|
322
|
-
|
|
336
|
+
let result;
|
|
337
|
+
if (timeoutMs !== undefined) {
|
|
338
|
+
// A timed-out hook promise itself is not cancellable (bounded leak: it may keep
|
|
339
|
+
// running in the background); the timer is cleared as soon as the hook settles.
|
|
340
|
+
let timer;
|
|
341
|
+
try {
|
|
342
|
+
result = await Promise.race([
|
|
343
|
+
Promise.resolve().then(() => hookFn(value)),
|
|
344
|
+
new Promise((_, reject) => {
|
|
345
|
+
timer = setTimeout(
|
|
346
|
+
() => reject(new Error(`hook timed out after ${timeoutMs} ms`)),
|
|
347
|
+
timeoutMs
|
|
348
|
+
);
|
|
349
|
+
}),
|
|
350
|
+
]);
|
|
351
|
+
} finally {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
result = await hookFn(value);
|
|
356
|
+
}
|
|
323
357
|
if (result != null && typeof result === 'object') {
|
|
324
358
|
value = { ...value, ...result };
|
|
325
359
|
}
|
|
@@ -491,7 +491,7 @@ function buildPluginContext(rt) {
|
|
|
491
491
|
on(event, cb) {
|
|
492
492
|
// Subscriptions go into the runtime dispatcher table (injected into the loader pipeline via
|
|
493
493
|
// getEventDispatcherPlugin); abort on deactivate/unload unsubscribes. No second event bus.
|
|
494
|
-
const hookName = event
|
|
494
|
+
const hookName = EVENT_TO_HOOK[event] || null;
|
|
495
495
|
if (!hookName || typeof cb !== 'function') return () => {};
|
|
496
496
|
const wrapper = (payload) => { if (!signal.aborted) cb(payload); };
|
|
497
497
|
_eventSubs[hookName].add(wrapper);
|
|
@@ -508,14 +508,24 @@ function buildPluginContext(rt) {
|
|
|
508
508
|
|
|
509
509
|
// Events dispatcher: the subscription table lives in the runtime; getEventDispatcherPlugin()
|
|
510
510
|
// returns the plugin object that bridges into the loader hook pipeline (no second event bus).
|
|
511
|
+
// ctx.events.on('…') event name → the loader hook that feeds it (agent_loop_* are the
|
|
512
|
+
// service-plugin view of the agent-loop lifecycle, same payload as the SSE frames).
|
|
513
|
+
const EVENT_TO_HOOK = {
|
|
514
|
+
new_entry: 'onNewEntry',
|
|
515
|
+
stream_chunk: 'onStreamChunk',
|
|
516
|
+
agent_loop_start: 'onAgentLoopStart',
|
|
517
|
+
agent_loop_end: 'onAgentLoopEnd',
|
|
518
|
+
};
|
|
511
519
|
const _eventDispatcher = {
|
|
512
520
|
name: 'ccv-plugin-events',
|
|
513
521
|
hooks: {
|
|
514
522
|
onNewEntry(payload) { dispatchEvent('onNewEntry', payload); },
|
|
515
523
|
onStreamChunk(payload) { dispatchEvent('onStreamChunk', payload); },
|
|
524
|
+
onAgentLoopStart(payload) { dispatchEvent('onAgentLoopStart', payload); },
|
|
525
|
+
onAgentLoopEnd(payload) { dispatchEvent('onAgentLoopEnd', payload); },
|
|
516
526
|
},
|
|
517
527
|
};
|
|
518
|
-
const _eventSubs = { onNewEntry: new Set(), onStreamChunk: new Set() };
|
|
528
|
+
const _eventSubs = { onNewEntry: new Set(), onStreamChunk: new Set(), onAgentLoopStart: new Set(), onAgentLoopEnd: new Set() };
|
|
519
529
|
function dispatchEvent(hookName, payload) {
|
|
520
530
|
for (const fn of _eventSubs[hookName]) {
|
|
521
531
|
try { fn(payload); } catch (err) { reportSwallowed('plugin-runtime.event', err); }
|