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/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-B4J-uwYG.js"></script>
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">
@@ -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
  }
@@ -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) keep null regardless
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.17",
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.91"
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
- const result = await hookFn(value);
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 === 'new_entry' ? 'onNewEntry' : event === 'stream_chunk' ? 'onStreamChunk' : null;
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); }