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/server/server.js CHANGED
@@ -2216,11 +2216,10 @@ async function setupTerminalWebSocket(httpServer) {
2216
2216
  const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? (projectKeyForCwd(frame.cwd) || null) : null;
2217
2217
  return !fproj || fproj === viewProject; // no-cwd frame → replay; else must match
2218
2218
  };
2219
- const replaySdkPendingApprovals = ({ planOnly = false } = {}) => {
2219
+ const replaySdkPendingApprovals = () => {
2220
2220
  if (!_sdkGetPendingApprovals) return;
2221
2221
  for (const p of _sdkGetPendingApprovals() || []) {
2222
2222
  if (!p || !p.replay || !p.id) continue;
2223
- if (planOnly && p.kind !== 'plan') continue;
2224
2223
  const payload = { ...p.replay };
2225
2224
  if (!_sdkFrameForView(payload)) continue;
2226
2225
  // The generic replay filter is project-scoped. When an exact instance
@@ -2459,9 +2458,24 @@ async function setupTerminalWebSocket(httpServer) {
2459
2458
  }
2460
2459
  }
2461
2460
  // View changes use attach rather than a new connection: restore that view's
2462
- // pending approvals too, so a plan cleared on switch-away can be answered
2463
- // after switching back instead of waiting for the server timeout.
2464
- replaySdkPendingApprovals({ planOnly: true });
2461
+ // pending approvals too, so a plan/ask/perm cleared on switch-away can be
2462
+ // answered after switching back instead of waiting for the server timeout.
2463
+ // (Was plan-only; ask/perm were orphaned into a timeout deny on re-attach.
2464
+ // The replay helpers already filter by the just-stamped view project+instance,
2465
+ // and the frontend dedupes by id, so a full replay never double-opens a modal.)
2466
+ replaySdkPendingApprovals();
2467
+ // Re-announce the just-viewed workspace's turn state so the spinner / Stop
2468
+ // button reflects it immediately (otherwise a long-running turn shows idle
2469
+ // until the next state change or heartbeat).
2470
+ if (_sdkGetSessionStateSnapshot) {
2471
+ const stateAnchor = ws._ccvViewInstance
2472
+ ? { instanceKey: ws._ccvViewInstance }
2473
+ : (ws._ccvViewProject ? { project: ws._ccvViewProject } : null);
2474
+ const stateSnap = stateAnchor ? _sdkGetSessionStateSnapshot(stateAnchor) : _sdkGetSessionStateSnapshot();
2475
+ if (stateSnap && (!ws._ccvViewInstance || stateSnap.instanceKey === ws._ccvViewInstance)) {
2476
+ try { ws.send(JSON.stringify(stateSnap)); } catch (err) { reportSwallowed('sdk.session-state-replay', err); }
2477
+ }
2478
+ }
2465
2479
  replayQueueSnapshot();
2466
2480
  return;
2467
2481
  }
@@ -2879,7 +2893,11 @@ async function setupTerminalWebSocket(httpServer) {
2879
2893
  if (_sdkSendUserMessage && msg.text) {
2880
2894
  const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2881
2895
  _sdkSendUserMessage(msg.text, anchor).catch(err => {
2882
- console.error('[SDK] sendUserMessage error:', err.message);
2896
+ reportSwallowed('server.sdk-send', err);
2897
+ // Directed back to the initiator ONLY (a global broadcast would show every
2898
+ // viewer a send failure for a message only one of them typed).
2899
+ try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
2900
+ catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
2883
2901
  });
2884
2902
  }
2885
2903
  } else if (msg.type === 'sdk-interrupt') {
@@ -2897,6 +2915,25 @@ async function setupTerminalWebSocket(httpServer) {
2897
2915
  // 切非运行态并等 4s 兜底才翻回,输出仍在流 —— 打日志让这类错配可观测。
2898
2916
  console.warn('[SDK] sdk-interrupt ignored: isSdkMode=%s, handler=%s', isSdkMode, !!_sdkInterruptTurn);
2899
2917
  }
2918
+ } else if (msg.type === 'sdk-retry-turn') {
2919
+ // Manual [retry] of the last failed (retryable) turn: pushes a synthetic
2920
+ // continue instruction instead of resending the user's original message.
2921
+ // Refusals go back ONLY to the initiating ws (a global broadcast would show
2922
+ // every viewer a reject for an action only one of them took).
2923
+ if (isSdkMode && _sdkRetryTurn) {
2924
+ const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2925
+ _sdkRetryTurn(typeof msg.turnId === 'string' ? msg.turnId : null, anchor).then((r) => {
2926
+ if (!r || r.ok) return;
2927
+ // Echo the view stamps so a user who switched views before the reply
2928
+ // lands doesn't see the rejection (and its sdkRetry clear) in the new view.
2929
+ try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: r.reason || 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
2930
+ catch (err) { reportSwallowed('server.sdk-retry-reject', err); }
2931
+ }).catch((err) => {
2932
+ reportSwallowed('server.sdk-retry-turn', err);
2933
+ try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
2934
+ catch (e2) { reportSwallowed('server.sdk-retry-reject', e2); }
2935
+ });
2936
+ }
2900
2937
  } else if (msg.type === 'queue-message') {
2901
2938
  // Chat-composer busy-queue enqueue (terminal-hidden PTY mode). These queue-* types
2902
2939
  // are deliberately NOT in the SDK no-op gate above: each branch routes by mode.
@@ -2907,7 +2944,9 @@ async function setupTerminalWebSocket(httpServer) {
2907
2944
  if (_sdkSendUserMessage) {
2908
2945
  const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2909
2946
  _sdkSendUserMessage(msg.text, anchor).catch(err => {
2910
- console.error('[SDK] queue-message relay error:', err.message);
2947
+ reportSwallowed('server.sdk-queue-relay', err);
2948
+ try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
2949
+ catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
2911
2950
  });
2912
2951
  }
2913
2952
  } else {
@@ -2966,7 +3005,9 @@ async function setupTerminalWebSocket(httpServer) {
2966
3005
  { project: (typeof msg.project === 'string' && msg.project) || null, instance: (typeof msg.instanceKey === 'string' && msg.instanceKey) || null },
2967
3006
  _projectName || '',
2968
3007
  );
2969
- _setTurnActive(false, null, qScope);
3008
+ // Web Stop = user interrupt: the loop ends NOW (no Stop hook fires on
3009
+ // interrupt), so emit the immediate agent_loop_end alongside the clear.
3010
+ _setTurnActive(false, null, qScope, 'interrupted');
2970
3011
  }
2971
3012
  } else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
2972
3013
  // Security: only allow paths within upload directories, reject traversal
@@ -3007,7 +3048,7 @@ async function setupTerminalWebSocket(httpServer) {
3007
3048
  _armRedrawRetries();
3008
3049
  }
3009
3050
  }
3010
- } catch {}
3051
+ } catch (err) { reportSwallowed('server.ws-dispatch', err); }
3011
3052
  });
3012
3053
 
3013
3054
  ws.on('close', () => {
@@ -3124,6 +3165,7 @@ let _lastSdkActive = false;
3124
3165
  // Per-scope CLI streaming edge state ('' = bound — the legacy single flag).
3125
3166
  const _lastCliActiveByScope = new Map();
3126
3167
  let _onTurnEndBroadcastForTests = null;
3168
+ let _onAgentLoopBroadcastForTests = null;
3127
3169
 
3128
3170
  function _normalizeKey(sessionId) {
3129
3171
  return (typeof sessionId === 'string' && sessionId) ? sessionId : null;
@@ -3191,6 +3233,14 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null, scope = null) {
3191
3233
  const sid = _normalizeKey(sessionId);
3192
3234
  const t = ts || Date.now();
3193
3235
  const sc = scope || { project: '', instance: null };
3236
+ // PTY loop end rides the debounced boundary (the Stop hook is query()-level, so an
3237
+ // immediate frame would ring mid-loop on user-role injections). SDK mode already
3238
+ // emitted its authoritative per-result end — emitting here too would double-fire.
3239
+ // Emitted BEFORE turn_end so the client can suppress the legacy voice when the new
3240
+ // agentLoopEnd cue actually played (same boundary ts on both frames).
3241
+ if (!isSdkMode) {
3242
+ _emitAgentLoopBoundary('end', { sessionId: sid, ts: t, scope: sc, mode: 'pty', reason: 'stop-hook', transcriptPath });
3243
+ }
3194
3244
  try {
3195
3245
  if (clients.length > 0 && sendEventToClients) {
3196
3246
  const target = sc.project
@@ -3214,6 +3264,61 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null, scope = null) {
3214
3264
  }
3215
3265
  }
3216
3266
 
3267
+ // ── Agent-loop boundary frames (agent_loop_start / agent_loop_end) ─────────────
3268
+ // Authoritative per-turn loop lifecycle, uniform across modes:
3269
+ // SDK: emitted by sdk-manager at push-accept (start) and the turn's real result /
3270
+ // settlement (end) — immediate, per-turn correlated (turnId + turnStartedAt),
3271
+ // bypassing the turn_end debounce entirely (task-notification pseudo results
3272
+ // are filtered upstream and never reach here).
3273
+ // PTY: start on the UserPromptSubmit rising edge in _setTurnActive; end from
3274
+ // _emitTurnEnd (post-debounce, 'stop-hook') or the web Stop clear
3275
+ // ('interrupted'). PTY's end stays debounced on purpose — see the
3276
+ // SUNSET-MARKER block above.
3277
+ // Every emission also fires the matching plugin parallel hook (onAgentLoopStart /
3278
+ // onAgentLoopEnd) so plugins observe the same lifecycle as SSE clients.
3279
+ function _emitAgentLoopBoundary(phase, { sessionId = null, ts = Date.now(), scope = null, mode, turnId = null, turnStartedAt = null, reason = null, transcriptPath = null } = {}) {
3280
+ if (_isStopping) return;
3281
+ const sc = scope || { project: '', instance: null };
3282
+ const payload = {
3283
+ sessionId: _normalizeKey(sessionId), ts,
3284
+ project: sc.project || (_projectName || ''), instance: sc.instance || null,
3285
+ mode,
3286
+ turnId: turnId || null, turnStartedAt: turnStartedAt || null,
3287
+ ...(reason ? { reason } : {}),
3288
+ ...(transcriptPath ? { transcriptPath } : {}),
3289
+ };
3290
+ if (clients.length > 0 && sendEventToClients) {
3291
+ try {
3292
+ const target = sc.project
3293
+ ? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
3294
+ : _filterClientsByViewProject(clients, _projectName || '');
3295
+ sendEventToClients(target, phase === 'start' ? 'agent_loop_start' : 'agent_loop_end', payload);
3296
+ } catch (err) { reportSwallowed('agent-loop.broadcast', err); }
3297
+ }
3298
+ // Fire-and-forget: a plugin failure must never affect the SSE/turn pipeline.
3299
+ runParallelHook(phase === 'start' ? 'onAgentLoopStart' : 'onAgentLoopEnd', payload).catch(() => {});
3300
+ if (typeof _onAgentLoopBroadcastForTests === 'function') {
3301
+ try { _onAgentLoopBroadcastForTests({ phase, payload }); }
3302
+ catch (e) { if (process.env.NODE_ENV === 'test') throw e; /* never let a test stub pollute prod */ }
3303
+ }
3304
+ }
3305
+
3306
+ // SDK-mode entries (cli.js wires sdk-manager's per-turn callbacks here). The cwd
3307
+ // routes the frame to that workspace's scope, mirroring broadcastTurnEnd; instanceKey
3308
+ // narrows same-basename twins.
3309
+ export function broadcastAgentLoopStart(p = {}) {
3310
+ const scope = p.cwd
3311
+ ? normalizeProducerScope({ project: projectKeyForCwd(p.cwd) || '', instance: p.instanceKey || null }, _projectName || '')
3312
+ : null;
3313
+ _emitAgentLoopBoundary('start', { ...p, scope, mode: 'sdk' });
3314
+ }
3315
+ export function broadcastAgentLoopEnd(p = {}) {
3316
+ const scope = p.cwd
3317
+ ? normalizeProducerScope({ project: projectKeyForCwd(p.cwd) || '', instance: p.instanceKey || null }, _projectName || '')
3318
+ : null;
3319
+ _emitAgentLoopBoundary('end', { ...p, scope, mode: 'sdk' });
3320
+ }
3321
+
3217
3322
  function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null, scope = null) {
3218
3323
  if (_isStopping) return;
3219
3324
  const sid = _normalizeKey(sessionId);
@@ -3396,7 +3501,10 @@ function _broadcastTurnActive(active, sessionId, scope = null) {
3396
3501
  // turn-end entry points and would otherwise emit duplicate frames). The heartbeat
3397
3502
  // is armed/disarmed here so it tracks the flag exactly. Scope defaults to bound
3398
3503
  // ('') — the legacy single-workspace behavior is byte-identical.
3399
- function _setTurnActive(active, sessionId, scope = null) {
3504
+ // `source` distinguishes clear paths: only 'interrupted' (web Stop / queue-suppress)
3505
+ // emits an immediate agent_loop_end — the ordinary Stop-hook clear defers to the
3506
+ // debounced end in _emitTurnEnd so user-role injection loops don't ring mid-loop.
3507
+ function _setTurnActive(active, sessionId, scope = null, source = 'hook') {
3400
3508
  const sc = scope || { project: '', instance: null };
3401
3509
  const key = scopeKeyOf(sc.project, sc.instance);
3402
3510
  const bucket = turnStateFor(key);
@@ -3407,6 +3515,11 @@ function _setTurnActive(active, sessionId, scope = null) {
3407
3515
  if (next) _turnSessionIdByScope.set(key, sessionId || null);
3408
3516
  if (activeTurnScopes().length > 0) _armTurnActiveHeartbeat(); else _clearTurnActiveHeartbeat();
3409
3517
  _broadcastTurnActive(next, sessionId, sc);
3518
+ if (next) {
3519
+ _emitAgentLoopBoundary('start', { sessionId, ts: Date.now(), scope: sc, mode: 'pty' });
3520
+ } else if (source === 'interrupted') {
3521
+ _emitAgentLoopBoundary('end', { sessionId, ts: Date.now(), scope: sc, mode: 'pty', reason: 'interrupted' });
3522
+ }
3410
3523
  }
3411
3524
 
3412
3525
  // 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
@@ -3441,19 +3554,21 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
3441
3554
  _lastCliActiveByScope.clear();
3442
3555
  _stoppingPromise = null;
3443
3556
  _onTurnEndBroadcastForTests = null;
3557
+ _onAgentLoopBroadcastForTests = null;
3444
3558
  setTurnActive(false);
3445
3559
  _clearTurnActiveHeartbeat();
3446
3560
  _turnSessionIdByScope.clear();
3447
3561
  clearScopeState();
3448
3562
  },
3449
3563
  onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
3564
+ onAgentLoopBroadcast(fn) { _onAgentLoopBroadcastForTests = fn; },
3450
3565
  getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
3451
3566
  setIsStopping(v) { _isStopping = !!v; },
3452
3567
  observeStreamingTick(activeNow, mode = 'cli', scopeKey = BOUND_SCOPE) { return _observeStreamingTick(activeNow, mode, scopeKey); },
3453
3568
  scheduleTurnEnd(sessionId, ts, scope = null) { _scheduleTurnEndBroadcast(sessionId, ts, null, scope); },
3454
3569
  getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
3455
3570
  getPtyTurnActive(scopeKey = BOUND_SCOPE) { return turnStateFor(scopeKey).active; },
3456
- setPtyTurnActive(v, sessionId = null, scope = null) { _setTurnActive(v, sessionId, scope); },
3571
+ setPtyTurnActive(v, sessionId = null, scope = null, source = 'hook') { _setTurnActive(v, sessionId, scope, source); },
3457
3572
  // Routes the payload through the SAME predicate the deps.onTaskEvent closure
3458
3573
  // uses, so tests exercise the production gate rather than a mirror of it.
3459
3574
  notifyTaskEvent(payload) {
@@ -3473,6 +3588,7 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
3473
3588
  } : Object.freeze({
3474
3589
  reset() {},
3475
3590
  onBroadcast() {},
3591
+ onAgentLoopBroadcast() {},
3476
3592
  getPendingKeys() { return []; },
3477
3593
  setIsStopping() {},
3478
3594
  observeStreamingTick() { return false; },
@@ -3805,6 +3921,10 @@ export function setSdkSendUserMessage(fn) { _sdkSendUserMessage = fn; }
3805
3921
  let _sdkInterruptTurn = null;
3806
3922
  export function setSdkInterruptTurn(fn) { _sdkInterruptTurn = fn; }
3807
3923
 
3924
+ /** Reference to sdk-manager's retryInterruptedTurn (set by cli.js after import). */
3925
+ let _sdkRetryTurn = null;
3926
+ export function setSdkRetryTurn(fn) { _sdkRetryTurn = fn; }
3927
+
3808
3928
  /** Reference to sdk-manager's getPendingApprovals (set by cli.js after import). */
3809
3929
  let _sdkGetPendingApprovals = null;
3810
3930
  export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }