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/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 = (
|
|
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
|
|
2463
|
-
// after switching back instead of waiting for the server timeout.
|
|
2464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|