@pellux/goodvibes-tui 1.0.0 → 1.7.0
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/CHANGELOG.md +130 -127
- package/README.md +32 -14
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
- package/src/cli/entrypoint.ts +2 -0
- package/src/core/alert-gating.ts +67 -0
- package/src/core/approval-alert.ts +72 -0
- package/src/core/budget-breach-notifier.ts +106 -0
- package/src/core/conversation-rendering.ts +19 -0
- package/src/core/conversation.ts +58 -0
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/long-task-notifier.ts +33 -6
- package/src/core/stream-event-wiring.ts +104 -2
- package/src/core/stream-stall-watchdog.ts +41 -17
- package/src/core/system-message-router.ts +37 -51
- package/src/core/turn-cancellation.ts +20 -0
- package/src/core/turn-event-wiring.ts +64 -3
- package/src/export/cost-utils.ts +126 -8
- package/src/input/command-registry.ts +54 -1
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +192 -0
- package/src/input/commands/diff-runtime.ts +61 -30
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +1 -1
- package/src/input/commands/image-runtime.ts +112 -0
- package/src/input/commands/intelligence-runtime.ts +11 -1
- package/src/input/commands/local-auth-runtime.ts +4 -1
- package/src/input/commands/local-runtime.ts +9 -3
- package/src/input/commands/marketplace-runtime.ts +2 -2
- package/src/input/commands/memory.ts +6 -1
- package/src/input/commands/operator-panel-runtime.ts +40 -24
- package/src/input/commands/planning-runtime.ts +26 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -6
- package/src/input/commands/plugin-runtime.ts +2 -2
- package/src/input/commands/policy-dispatch.ts +21 -0
- package/src/input/commands/provider-accounts-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +3 -3
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime.ts +3 -3
- package/src/input/commands/runtime-services.ts +54 -13
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +12 -0
- package/src/input/commands/session-workflow.ts +19 -1
- package/src/input/commands/settings-sync-runtime.ts +1 -1
- package/src/input/commands/share-runtime.ts +9 -2
- package/src/input/commands/shell-core.ts +15 -7
- package/src/input/commands/skills-runtime.ts +2 -8
- package/src/input/commands/subscription-runtime.ts +2 -2
- package/src/input/commands/test-runtime.ts +277 -0
- package/src/input/commands/websearch-runtime.ts +101 -0
- package/src/input/commands/work-plan-runtime.ts +36 -10
- package/src/input/commands/workstream-runtime.ts +338 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +161 -0
- package/src/input/config-modal.ts +377 -0
- package/src/input/feed-context-factory.ts +7 -8
- package/src/input/handler-command-route.ts +27 -17
- package/src/input/handler-content-actions.ts +22 -8
- package/src/input/handler-feed-routes.ts +107 -7
- package/src/input/handler-feed.ts +49 -13
- package/src/input/handler-interactions.ts +3 -3
- package/src/input/handler-modal-routes.ts +65 -0
- package/src/input/handler-modal-stack.ts +3 -5
- package/src/input/handler-modal-token-routes.ts +16 -49
- package/src/input/handler-picker-routes.ts +11 -94
- package/src/input/handler-shortcuts.ts +24 -14
- package/src/input/handler-types.ts +3 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +10 -28
- package/src/input/keybindings.ts +22 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +58 -0
- package/src/input/panel-integration-actions.ts +9 -170
- package/src/input/settings-modal-data.ts +95 -0
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/main.ts +41 -44
- package/src/panels/agent-inspector-shared.ts +35 -11
- package/src/panels/base-panel.ts +22 -1
- package/src/panels/builtin/agent.ts +21 -107
- package/src/panels/builtin/development.ts +16 -62
- package/src/panels/builtin/knowledge.ts +8 -32
- package/src/panels/builtin/operations.ts +84 -428
- package/src/panels/builtin/session.ts +21 -112
- package/src/panels/builtin/shared.ts +9 -43
- package/src/panels/builtin-modals.ts +218 -0
- package/src/panels/builtin-panels.ts +5 -0
- package/src/panels/cost-tracker-panel.ts +63 -14
- package/src/panels/diff-panel.ts +123 -60
- package/src/panels/eval-registry.ts +60 -0
- package/src/panels/fleet-panel.ts +800 -0
- package/src/panels/fleet-read-model.ts +477 -0
- package/src/panels/fleet-steer.ts +92 -0
- package/src/panels/fleet-stop.ts +125 -0
- package/src/panels/fleet-tabs.ts +230 -0
- package/src/panels/fleet-transcript.ts +356 -0
- package/src/panels/git-panel.ts +9 -9
- package/src/panels/index.ts +7 -31
- package/src/panels/local-auth-panel.ts +10 -0
- package/src/panels/modals/hooks-modal.ts +187 -0
- package/src/panels/modals/keybindings-modal.ts +151 -0
- package/src/panels/modals/knowledge-modal.ts +158 -0
- package/src/panels/modals/local-auth-modal.ts +132 -0
- package/src/panels/modals/marketplace-modal.ts +218 -0
- package/src/panels/modals/memory-modal.ts +180 -0
- package/src/panels/modals/modal-surface-helpers.ts +54 -0
- package/src/panels/modals/modal-theme.ts +36 -0
- package/src/panels/modals/pairing-modal.ts +144 -0
- package/src/panels/modals/planning-modal.ts +282 -0
- package/src/panels/modals/plugins-modal.ts +174 -0
- package/src/panels/modals/policy-modal.ts +256 -0
- package/src/panels/modals/provider-health-modal.ts +136 -0
- package/src/panels/modals/remote-modal.ts +115 -0
- package/src/panels/modals/sandbox-modal.ts +150 -0
- package/src/panels/modals/security-modal.ts +217 -0
- package/src/panels/modals/services-modal.ts +179 -0
- package/src/panels/modals/settings-sync-modal.ts +142 -0
- package/src/panels/modals/skills-modal.ts +189 -0
- package/src/panels/modals/subscription-modal.ts +172 -0
- package/src/panels/modals/work-plan-modal.ts +149 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +108 -5
- package/src/panels/project-planning-answer-actions.ts +4 -2
- package/src/panels/scrollable-list-panel.ts +9 -0
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/token-budget-panel.ts +5 -3
- package/src/panels/types.ts +26 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +60 -4
- package/src/renderer/compaction-history-modal.ts +19 -3
- package/src/renderer/compaction-preview.ts +13 -2
- package/src/renderer/compaction-quality.ts +100 -0
- package/src/renderer/config-modal.ts +81 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +10 -2
- package/src/renderer/git-status.ts +40 -0
- package/src/renderer/help-overlay.ts +2 -2
- package/src/renderer/model-workspace.ts +44 -1
- package/src/renderer/panel-workspace-bar.ts +8 -2
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/turn-injection.ts +114 -0
- package/src/renderer/ui-factory.ts +91 -7
- package/src/runtime/bootstrap-command-context.ts +24 -1
- package/src/runtime/bootstrap-command-parts.ts +36 -7
- package/src/runtime/bootstrap-core.ts +19 -4
- package/src/runtime/bootstrap-hook-bridge.ts +5 -0
- package/src/runtime/bootstrap-shell.ts +41 -17
- package/src/runtime/bootstrap.ts +11 -17
- package/src/runtime/code-index-services.ts +112 -0
- package/src/runtime/orchestrator-core-services.ts +49 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +91 -43
- package/src/runtime/ui-services.ts +8 -0
- package/src/runtime/workstream-services.ts +195 -0
- package/src/shell/blocking-input.ts +22 -1
- package/src/shell/ui-openers.ts +129 -17
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -786
- package/src/panels/approval-panel.ts +0 -252
- package/src/panels/automation-control-panel.ts +0 -479
- package/src/panels/cockpit-panel.ts +0 -481
- package/src/panels/cockpit-read-model.ts +0 -233
- package/src/panels/communication-panel.ts +0 -256
- package/src/panels/control-plane-panel.ts +0 -470
- package/src/panels/debug-panel.ts +0 -609
- package/src/panels/docs-panel.ts +0 -375
- package/src/panels/eval-panel.ts +0 -627
- package/src/panels/file-explorer-panel.ts +0 -664
- package/src/panels/file-preview-panel.ts +0 -517
- package/src/panels/hooks-panel.ts +0 -401
- package/src/panels/incident-review-panel.ts +0 -406
- package/src/panels/intelligence-panel.ts +0 -383
- package/src/panels/knowledge-graph-panel.ts +0 -506
- package/src/panels/marketplace-panel.ts +0 -399
- package/src/panels/memory-panel.ts +0 -558
- package/src/panels/ops-control-panel.ts +0 -329
- package/src/panels/ops-strategy-panel.ts +0 -321
- package/src/panels/orchestration-panel.ts +0 -465
- package/src/panels/panel-list-panel.ts +0 -557
- package/src/panels/plan-dashboard-panel.ts +0 -707
- package/src/panels/policy-panel.ts +0 -517
- package/src/panels/project-planning-panel.ts +0 -721
- package/src/panels/provider-health-panel.ts +0 -678
- package/src/panels/provider-health-tracker.ts +0 -306
- package/src/panels/provider-health-views.ts +0 -560
- package/src/panels/qr-panel.ts +0 -280
- package/src/panels/remote-panel.ts +0 -534
- package/src/panels/routes-panel.ts +0 -241
- package/src/panels/sandbox-panel.ts +0 -456
- package/src/panels/security-panel.ts +0 -447
- package/src/panels/services-panel.ts +0 -329
- package/src/panels/session-browser-panel.ts +0 -487
- package/src/panels/settings-sync-panel.ts +0 -398
- package/src/panels/subscription-panel.ts +0 -342
- package/src/panels/symbol-outline-panel.ts +0 -619
- package/src/panels/system-messages-panel.ts +0 -364
- package/src/panels/tasks-panel.ts +0 -608
- package/src/panels/thinking-panel.ts +0 -333
- package/src/panels/tool-inspector-panel.ts +0 -537
- package/src/panels/work-plan-panel.ts +0 -530
- package/src/panels/worktree-panel.ts +0 -360
- package/src/panels/wrfc-panel.ts +0 -790
- package/src/renderer/agent-detail-modal.ts +0 -465
- package/src/renderer/live-tail-modal.ts +0 -156
- package/src/renderer/process-modal.ts +0 -656
- package/src/renderer/qr-renderer.ts +0 -120
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approval-alert — fires an unfocused-user alert the moment a tool call
|
|
3
|
+
* becomes a real, user-blocking permission prompt.
|
|
4
|
+
*
|
|
5
|
+
* Anchor: `PERMISSION_REQUESTED` (SDK events/permissions.ts) is the wrong
|
|
6
|
+
* signal — it fires for every policy-evaluation step, including ones
|
|
7
|
+
* auto-resolved by allow-lists/yolo-mode in milliseconds. The only place a
|
|
8
|
+
* request becomes an actual user-facing prompt is where main.ts assigns
|
|
9
|
+
* `permissionPromptRef.requestPermission`. There is no `permissions` feed on
|
|
10
|
+
* UiRuntimeEvents to subscribe to instead, so this wraps the handler at that
|
|
11
|
+
* assignment site.
|
|
12
|
+
*
|
|
13
|
+
* wrapRequestPermissionWithAlert fires the alert synchronously (before the
|
|
14
|
+
* wrapped promise settles) so it reflects "a prompt just appeared", not
|
|
15
|
+
* "a prompt was just resolved".
|
|
16
|
+
*
|
|
17
|
+
* PRIVACY: message content is tool name + permission category only — no
|
|
18
|
+
* args, no file contents, no command strings (mirrors long-task-notifier's
|
|
19
|
+
* privacy guarantee).
|
|
20
|
+
*/
|
|
21
|
+
import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
|
|
22
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
23
|
+
import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
|
|
24
|
+
import type { PermissionRequestHandler, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
25
|
+
import type { FocusTracker } from './focus-tracker.ts';
|
|
26
|
+
import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
|
|
27
|
+
|
|
28
|
+
export interface ApprovalAlertDeps {
|
|
29
|
+
readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
|
|
30
|
+
readonly configGet: ConfigGet;
|
|
31
|
+
readonly webhookNotifier?: WebhookNotifier | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a PermissionRequestHandler so every call also fires an unfocused-user
|
|
36
|
+
* alert (subject to the behavior.notifyOnApprovalPending / notifyOnlyWhenUnfocused
|
|
37
|
+
* gates). The wrapped handler's behavior (what it resolves to) is completely
|
|
38
|
+
* unchanged — this only adds a side effect at call time.
|
|
39
|
+
*/
|
|
40
|
+
export function wrapRequestPermissionWithAlert(
|
|
41
|
+
original: PermissionRequestHandler,
|
|
42
|
+
deps: ApprovalAlertDeps,
|
|
43
|
+
): PermissionRequestHandler {
|
|
44
|
+
return (request: PermissionPromptRequest) => {
|
|
45
|
+
fireApprovalAlert(request, deps);
|
|
46
|
+
return original(request);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fireApprovalAlert(request: PermissionPromptRequest, deps: ApprovalAlertDeps): void {
|
|
51
|
+
if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnApprovalPending')) return;
|
|
52
|
+
|
|
53
|
+
const title = 'GoodVibes — approval needed';
|
|
54
|
+
// PRIVACY: tool name + category only. Never request.args.
|
|
55
|
+
const message = `${request.tool} (${request.category}) is waiting for approval`;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
logger.debug('approval-alert: desktop notify error', { error: String(err) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const webhookNotifier = deps.webhookNotifier;
|
|
64
|
+
if (webhookNotifier) {
|
|
65
|
+
const urls = webhookNotifier.getUrls();
|
|
66
|
+
if (urls.length > 0) {
|
|
67
|
+
webhookNotifier.send(message).catch((err: unknown) => {
|
|
68
|
+
logger.debug('approval-alert: webhook send error', { error: String(err) });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* budget-breach-notifier — fires an unfocused-user alert the moment session
|
|
3
|
+
* cost crosses the configured budget threshold (behavior.budgetAlertUsd).
|
|
4
|
+
*
|
|
5
|
+
* One-shot semantics: fires exactly once on the false→true edge (crossing
|
|
6
|
+
* into breach), never again while still over budget on every subsequent
|
|
7
|
+
* turn. The "already notified" latch resets when the session drops back
|
|
8
|
+
* under budget (so a later re-breach can fire again) or when the threshold
|
|
9
|
+
* itself changes (raising, lowering, or clearing the budget re-arms it
|
|
10
|
+
* against the new value).
|
|
11
|
+
*
|
|
12
|
+
* Delivery mirrors long-task-notifier.ts: desktop notification
|
|
13
|
+
* (notifyCompletion) + outbound webhook (WebhookNotifier), both gated by
|
|
14
|
+
* the shared focus/config gating in alert-gating.ts. Skips evaluation
|
|
15
|
+
* entirely when the session model is unpriced — a $0 placeholder cost must
|
|
16
|
+
* never be reported as a real breach (mirrors CostTrackerPanel's "unpriced"
|
|
17
|
+
* display convention).
|
|
18
|
+
*
|
|
19
|
+
* PRIVACY: message text is built from cost/threshold numbers and the
|
|
20
|
+
* session id prefix only — no conversation content.
|
|
21
|
+
*/
|
|
22
|
+
import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
|
|
23
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
24
|
+
import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
|
|
25
|
+
import { calcSessionCost, computeBudgetBreach, isModelPriced } from '../export/cost-utils.ts';
|
|
26
|
+
import type { FocusTracker } from './focus-tracker.ts';
|
|
27
|
+
import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
|
|
28
|
+
|
|
29
|
+
export interface BudgetBreachUsageSnapshot {
|
|
30
|
+
readonly input: number;
|
|
31
|
+
readonly output: number;
|
|
32
|
+
readonly cacheRead: number;
|
|
33
|
+
readonly cacheWrite: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BudgetBreachNotifierDeps {
|
|
37
|
+
readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
|
|
38
|
+
readonly configGet: ConfigGet;
|
|
39
|
+
readonly webhookNotifier?: WebhookNotifier | null;
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Stateful edge-trigger checker — construct once per session, call on every TURN_COMPLETED. */
|
|
44
|
+
export interface BudgetBreachNotifier {
|
|
45
|
+
/**
|
|
46
|
+
* Evaluate current usage against the configured threshold and fire an
|
|
47
|
+
* alert on the false→true breach edge. Returns true when an alert was
|
|
48
|
+
* fired this call (for tests / observability), false otherwise.
|
|
49
|
+
*/
|
|
50
|
+
check(usage: BudgetBreachUsageSnapshot, sessionModel: string, budgetThresholdUsd: number): boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createBudgetBreachNotifier(deps: BudgetBreachNotifierDeps): BudgetBreachNotifier {
|
|
54
|
+
let lastThreshold: number | null = null;
|
|
55
|
+
let notified = false;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
check(usage, sessionModel, budgetThresholdUsd) {
|
|
59
|
+
if (budgetThresholdUsd !== lastThreshold) {
|
|
60
|
+
// Threshold raised, lowered, or cleared since the last check — re-arm
|
|
61
|
+
// the latch so a breach against the new threshold can fire again.
|
|
62
|
+
lastThreshold = budgetThresholdUsd;
|
|
63
|
+
notified = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (budgetThresholdUsd <= 0) return false; // disabled
|
|
67
|
+
if (!isModelPriced(sessionModel)) return false; // cost would be a placeholder, not real
|
|
68
|
+
|
|
69
|
+
const sessionCost = calcSessionCost(usage.input, usage.output, usage.cacheRead, usage.cacheWrite, sessionModel);
|
|
70
|
+
const breached = computeBudgetBreach(sessionCost, budgetThresholdUsd);
|
|
71
|
+
|
|
72
|
+
if (!breached) {
|
|
73
|
+
notified = false; // dropped back under budget — can re-fire on the next crossing
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (notified) return false; // already alerted for this crossing
|
|
77
|
+
|
|
78
|
+
notified = true;
|
|
79
|
+
fireBudgetBreachAlert(deps, sessionCost, budgetThresholdUsd);
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function fireBudgetBreachAlert(deps: BudgetBreachNotifierDeps, sessionCost: number, budgetThresholdUsd: number): void {
|
|
86
|
+
if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnBudgetBreach')) return;
|
|
87
|
+
|
|
88
|
+
const title = 'GoodVibes — budget breach';
|
|
89
|
+
const message = `session cost $${sessionCost.toFixed(2)} exceeded budget $${budgetThresholdUsd.toFixed(2)} · session ${deps.sessionId.slice(0, 8)}`;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.debug('budget-breach-notifier: desktop notify error', { error: String(err) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const webhookNotifier = deps.webhookNotifier;
|
|
98
|
+
if (webhookNotifier) {
|
|
99
|
+
const urls = webhookNotifier.getUrls();
|
|
100
|
+
if (urls.length > 0) {
|
|
101
|
+
webhookNotifier.send(message).catch((err: unknown) => {
|
|
102
|
+
logger.debug('budget-breach-notifier: webhook send error', { error: String(err) });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -3,6 +3,7 @@ import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
|
3
3
|
import { renderDiffView } from '../renderer/diff-view.ts';
|
|
4
4
|
import { DARK_THEME } from '../renderer/theme.ts';
|
|
5
5
|
import { renderToolCallBlock } from '../renderer/tool-call.ts';
|
|
6
|
+
import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
|
|
6
7
|
import { renderThinkingBlock } from '../renderer/thinking.ts';
|
|
7
8
|
import { renderSystemMessage } from '../renderer/system-message.ts';
|
|
8
9
|
import { createEmptyLine, type Line, type Cell } from '../types/grid.ts';
|
|
@@ -402,3 +403,21 @@ export function logConversationText(
|
|
|
402
403
|
const lines = text.split('\n').map((line) => UIFactory.stringToLine(indent + line, width, style));
|
|
403
404
|
context.history.addLines(lines);
|
|
404
405
|
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* logConversationToolResult - Append a single tool-call-style result line to the
|
|
409
|
+
* display history only, reusing renderToolCallBlock so a display-only render (e.g.
|
|
410
|
+
* a slash command's subprocess result) is visually indistinguishable from the
|
|
411
|
+
* model's own tool-call results. Display-only: never touches message history.
|
|
412
|
+
*/
|
|
413
|
+
export function logConversationToolResult(
|
|
414
|
+
context: Pick<ConversationRenderContext, 'history'>,
|
|
415
|
+
width: number,
|
|
416
|
+
toolCall: ToolCall,
|
|
417
|
+
status: 'done' | 'error',
|
|
418
|
+
resultSummary: string,
|
|
419
|
+
durationMs: number,
|
|
420
|
+
errorMsg?: string,
|
|
421
|
+
): void {
|
|
422
|
+
context.history.addLines(renderToolCallBlock(toolCall, status, resultSummary, width, durationMs, errorMsg));
|
|
423
|
+
}
|
package/src/core/conversation.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
addConversationSplashScreen,
|
|
19
19
|
conversationTextToLines,
|
|
20
20
|
logConversationText,
|
|
21
|
+
logConversationToolResult,
|
|
21
22
|
renderConversationAssistantMessage,
|
|
22
23
|
renderConversationSystemMessage,
|
|
23
24
|
renderConversationToolMessage,
|
|
@@ -49,6 +50,46 @@ export type { BlockMeta };
|
|
|
49
50
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
50
51
|
type Message = ConversationMessageSnapshot;
|
|
51
52
|
|
|
53
|
+
/** Matches Orchestrator.usage's shape (SDK platform/core/orchestrator.ts). Not
|
|
54
|
+
* exported by the SDK as a standalone type, so mirrored here. */
|
|
55
|
+
export interface OrchestratorUsageTotals {
|
|
56
|
+
input: number;
|
|
57
|
+
output: number;
|
|
58
|
+
cacheRead: number;
|
|
59
|
+
cacheWrite: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* sumConversationUsage - Fold every assistant message's per-turn usage into
|
|
64
|
+
* running totals, plus the *last* assistant message's own input-token figure
|
|
65
|
+
* (context-window occupancy — not a running sum).
|
|
66
|
+
*
|
|
67
|
+
* TUI-side counterpart to SDK Orchestrator.usage: after a session resume
|
|
68
|
+
* replays historical messages into a freshly-constructed Orchestrator (whose
|
|
69
|
+
* `usage` starts at all zeros — bootstrap.ts always constructs a fresh
|
|
70
|
+
* instance, see W0.9), this lets the caller hydrate the footer's token
|
|
71
|
+
* counters from messages that already carry real usage data instead of
|
|
72
|
+
* waiting for the first new turn to populate them.
|
|
73
|
+
*/
|
|
74
|
+
export function sumConversationUsage(
|
|
75
|
+
messages: readonly ConversationMessageSnapshot[],
|
|
76
|
+
): { usage: OrchestratorUsageTotals; lastInputTokens: number } {
|
|
77
|
+
const usage: OrchestratorUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
78
|
+
let lastInputTokens = 0;
|
|
79
|
+
for (const message of messages) {
|
|
80
|
+
if (message.role !== 'assistant' || !message.usage) continue;
|
|
81
|
+
usage.input += message.usage.inputTokens;
|
|
82
|
+
usage.output += message.usage.outputTokens;
|
|
83
|
+
usage.cacheRead += message.usage.cacheReadTokens ?? 0;
|
|
84
|
+
usage.cacheWrite += message.usage.cacheWriteTokens ?? 0;
|
|
85
|
+
// Context-window occupancy tracks the most recent turn only, not a sum.
|
|
86
|
+
lastInputTokens = message.usage.inputTokens
|
|
87
|
+
+ (message.usage.cacheReadTokens ?? 0)
|
|
88
|
+
+ (message.usage.cacheWriteTokens ?? 0);
|
|
89
|
+
}
|
|
90
|
+
return { usage, lastInputTokens };
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
export class ConversationManager extends SdkConversationManager {
|
|
53
94
|
public history = new InfiniteBuffer();
|
|
54
95
|
private _getWidth: () => number;
|
|
@@ -687,6 +728,23 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
687
728
|
logConversationText(this.renderingContext(), this._getWidth(), text, style, indent);
|
|
688
729
|
}
|
|
689
730
|
|
|
731
|
+
/**
|
|
732
|
+
* logToolResultBlock - Append a single tool-call-styled result line to the display
|
|
733
|
+
* history only (never touches the LLM message list), so a slash command's subprocess
|
|
734
|
+
* result (e.g. /test) renders visually identical to a real tool-call result without
|
|
735
|
+
* leaking into model context.
|
|
736
|
+
*/
|
|
737
|
+
public logToolResultBlock(
|
|
738
|
+
toolCall: ToolCall,
|
|
739
|
+
status: 'done' | 'error',
|
|
740
|
+
resultSummary: string,
|
|
741
|
+
durationMs: number,
|
|
742
|
+
errorMsg?: string,
|
|
743
|
+
): void {
|
|
744
|
+
logConversationToolResult(this.renderingContext(), this._getWidth(), toolCall, status, resultSummary, durationMs, errorMsg);
|
|
745
|
+
this.markDirty();
|
|
746
|
+
}
|
|
747
|
+
|
|
690
748
|
/**
|
|
691
749
|
* clearDisplay - Clear the visual history buffer without touching the LLM context messages.
|
|
692
750
|
* The next render will show a blank conversation area. Subsequent message additions
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* focus-tracker — tracks whether the terminal window currently has OS-level
|
|
3
|
+
* input focus, using the terminal focus-reporting protocol (DECSET ?1004h,
|
|
4
|
+
* enabled at terminal setup in main.ts). The SDK's InputTokenizer already
|
|
5
|
+
* parses the resulting `\x1b[I` (focus-in) / `\x1b[O` (focus-out) sequences
|
|
6
|
+
* into `{ type: 'focus', action }` tokens (platform/core/tokenizer.ts); this
|
|
7
|
+
* module is the single place those tokens land (consumed in
|
|
8
|
+
* src/input/handler-feed.ts's feedInputTokens()).
|
|
9
|
+
*
|
|
10
|
+
* Honest degradation: a terminal that does not implement focus reporting
|
|
11
|
+
* (or a multiplexer that swallows the sequences) never sends focus tokens.
|
|
12
|
+
* In that case `isFocused()` stays `null` forever — "unknown", never a
|
|
13
|
+
* guessed `true`/`false`. Callers that gate a user-facing behavior on focus
|
|
14
|
+
* (e.g. the unfocused-alert notifiers in this directory) must treat `null`
|
|
15
|
+
* the same as "not focused" per the fallback rule: alerts fire when the
|
|
16
|
+
* terminal is definitely unfocused OR when focus state was never observed.
|
|
17
|
+
*/
|
|
18
|
+
export class FocusTracker {
|
|
19
|
+
private focused: boolean | null = null;
|
|
20
|
+
|
|
21
|
+
/** True/false once at least one focus token has arrived; null if none ever has. */
|
|
22
|
+
isFocused(): boolean | null {
|
|
23
|
+
return this.focused;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Called from the input pipeline on every focus token. */
|
|
27
|
+
setFocused(value: boolean): void {
|
|
28
|
+
this.focused = value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The alert-gating rule shared by every unfocused-alert notifier: fire
|
|
33
|
+
* when the terminal is known to be unfocused, or when focus was never
|
|
34
|
+
* observed (honest fallback — never silently suppress on an unknown
|
|
35
|
+
* terminal). Only `isFocused() === true` (a real, observed focus-in with
|
|
36
|
+
* no observed focus-out since) suppresses an alert.
|
|
37
|
+
*/
|
|
38
|
+
shouldAlertWhenUnfocused(): boolean {
|
|
39
|
+
return this.focused !== true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -18,15 +18,21 @@
|
|
|
18
18
|
* When neither target is available the function is an honest no-op (debug log
|
|
19
19
|
* only; no user-facing error spam).
|
|
20
20
|
*
|
|
21
|
-
* Focus tracking:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Focus tracking (W2.3): when `focusTracker` is supplied, notifications are
|
|
22
|
+
* gated the same way as the other unfocused-user alert classes (see
|
|
23
|
+
* alert-gating.ts) — they fire when the terminal is unfocused or focus state
|
|
24
|
+
* was never observed, and are suppressed when it's known to be focused,
|
|
25
|
+
* unless `behavior.notifyOnlyWhenUnfocused` is turned off. `focusTracker` is
|
|
26
|
+
* optional and defaults to "always fire" (the pre-W2.3 behavior) when
|
|
27
|
+
* omitted, so existing callers that don't have a FocusTracker in scope are
|
|
28
|
+
* unaffected.
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
|
|
28
32
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
29
33
|
import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
|
|
34
|
+
import type { FocusTracker } from './focus-tracker.ts';
|
|
35
|
+
import { readNotifyOnlyWhenUnfocused, type ConfigGet } from './alert-gating.ts';
|
|
30
36
|
|
|
31
37
|
/** Default threshold in seconds. Turns shorter than this do not notify. */
|
|
32
38
|
export const NOTIFY_AFTER_SECONDS_DEFAULT = 60;
|
|
@@ -74,6 +80,18 @@ export interface MaybeNotifyLongTaskOptions {
|
|
|
74
80
|
* skipped silently.
|
|
75
81
|
*/
|
|
76
82
|
readonly webhookNotifier?: WebhookNotifier | null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Terminal focus tracker (W2.3). When supplied together with `configGet`,
|
|
86
|
+
* the notification additionally respects behavior.notifyOnlyWhenUnfocused
|
|
87
|
+
* (default on): suppressed when the terminal is known to be focused, fires
|
|
88
|
+
* when unfocused or when focus was never observed. Omit both to preserve
|
|
89
|
+
* pre-W2.3 behavior (always fire once the threshold is met).
|
|
90
|
+
*/
|
|
91
|
+
readonly focusTracker?: Pick<FocusTracker, 'shouldAlertWhenUnfocused'> | null;
|
|
92
|
+
|
|
93
|
+
/** Config reader used only for the notifyOnlyWhenUnfocused gate above. */
|
|
94
|
+
readonly configGet?: ConfigGet;
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
/**
|
|
@@ -81,13 +99,14 @@ export interface MaybeNotifyLongTaskOptions {
|
|
|
81
99
|
* exceeds the configured threshold.
|
|
82
100
|
*
|
|
83
101
|
* Returns true when at least one delivery was attempted, false when the
|
|
84
|
-
* call was a no-op (threshold not reached, or off
|
|
102
|
+
* call was a no-op (threshold not reached, off-state, or focus-gated off —
|
|
103
|
+
* see `focusTracker`/`configGet` above).
|
|
85
104
|
*
|
|
86
105
|
* PRIVACY: builds message text from structural metadata only (kind, elapsed,
|
|
87
106
|
* status, sessionId). Never includes conversation content.
|
|
88
107
|
*/
|
|
89
108
|
export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
|
|
90
|
-
const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier } = opts;
|
|
109
|
+
const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier, focusTracker, configGet } = opts;
|
|
91
110
|
|
|
92
111
|
// Off-state: 0 disables notifications entirely.
|
|
93
112
|
if (thresholdSeconds === NOTIFY_AFTER_SECONDS_OFF) {
|
|
@@ -102,6 +121,14 @@ export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
|
|
|
102
121
|
return false;
|
|
103
122
|
}
|
|
104
123
|
|
|
124
|
+
// Focus gate (W2.3): only applied when both a tracker and a config reader
|
|
125
|
+
// are supplied. Absent either one, behavior is unchanged from pre-W2.3
|
|
126
|
+
// (always fire once the threshold is met).
|
|
127
|
+
if (focusTracker && configGet && readNotifyOnlyWhenUnfocused(configGet) && !focusTracker.shouldAlertWhenUnfocused()) {
|
|
128
|
+
logger.debug('long-task-notifier: suppressed — terminal focused');
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
// Build concise, metadata-only message. No conversation text.
|
|
106
133
|
const statusLabel = status === 'ok' ? 'completed' : 'failed';
|
|
107
134
|
const title = `GoodVibes — ${kind} ${statusLabel}`;
|
|
@@ -23,6 +23,29 @@ export interface StreamMetrics {
|
|
|
23
23
|
activeToolStartedAtMs: number | undefined;
|
|
24
24
|
/** Name of the currently executing tool; cleared when execution completes. */
|
|
25
25
|
activeToolName: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Epoch ms of the most recent STREAM_START or STREAM_DELTA; undefined when
|
|
28
|
+
* idle (no turn in flight). Read every render frame — not just on the
|
|
29
|
+
* watchdog's one-shot hint — so "ms since last byte" can be computed even
|
|
30
|
+
* when no new SDK event has arrived at all (a no-delta stall watchdog for
|
|
31
|
+
* the render loop itself, independent of the low-priority system message).
|
|
32
|
+
*/
|
|
33
|
+
lastDeltaAtMs: number | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* 1-based count of stall episodes the watchdog has fired for the current
|
|
36
|
+
* turn; 0 = no stall yet. Increments each time a fresh silence (after a
|
|
37
|
+
* recovery) crosses the stall threshold.
|
|
38
|
+
*/
|
|
39
|
+
stallEpisode: number;
|
|
40
|
+
/**
|
|
41
|
+
* Populated from the SDK's STREAM_RETRY event when present. SDK 0.35.0 (the
|
|
42
|
+
* pinned dependency) has no such event on the TurnEvent union yet — these
|
|
43
|
+
* fields are consumed structurally (see looseTurnsFeed below) and stay
|
|
44
|
+
* undefined until a future SDK version emits it. Cleared on STREAM_DELTA
|
|
45
|
+
* (a byte arriving means the reconnect, if any, succeeded).
|
|
46
|
+
*/
|
|
47
|
+
reconnectAttempt: number | undefined;
|
|
48
|
+
reconnectMaxAttempts: number | undefined;
|
|
26
49
|
}
|
|
27
50
|
|
|
28
51
|
/** Minimal orchestrator surface required for stream token-speed calculation. */
|
|
@@ -60,6 +83,33 @@ interface StreamSystemMessageRouter {
|
|
|
60
83
|
low(message: string): void;
|
|
61
84
|
}
|
|
62
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Loosely-typed variant of the turns event feed, used only to subscribe to
|
|
88
|
+
* event names not yet present in the SDK's TurnEvent union (STREAM_RETRY,
|
|
89
|
+
* STREAM_STALL — see the structural-consumption comment at the subscription
|
|
90
|
+
* site below). `events.turns.on` itself stays fully typed against the real
|
|
91
|
+
* TurnEvent union everywhere else in this file.
|
|
92
|
+
*/
|
|
93
|
+
interface LooseTurnEventFeed {
|
|
94
|
+
on(type: string, listener: (payload: unknown) => void): () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Payload shape expected from a future SDK STREAM_RETRY event. */
|
|
98
|
+
interface StreamRetryLikePayload {
|
|
99
|
+
readonly attempt: number;
|
|
100
|
+
readonly maxAttempts: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Runtime guard validating an unknown STREAM_RETRY-like payload's shape. */
|
|
104
|
+
function isStreamRetryLikePayload(payload: unknown): payload is StreamRetryLikePayload {
|
|
105
|
+
return (
|
|
106
|
+
typeof payload === 'object' &&
|
|
107
|
+
payload !== null &&
|
|
108
|
+
typeof (payload as Record<string, unknown>).attempt === 'number' &&
|
|
109
|
+
typeof (payload as Record<string, unknown>).maxAttempts === 'number'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
63
113
|
/**
|
|
64
114
|
* Minimal cost lookup surface for attaching cost-delta information to failover notices.
|
|
65
115
|
* Returns USD-per-1M-token pricing for the given model ID.
|
|
@@ -197,10 +247,19 @@ export function wireStreamEventMetrics(
|
|
|
197
247
|
metrics.tokenSpeed = 0;
|
|
198
248
|
metrics.ttftMs = undefined;
|
|
199
249
|
metrics.ttftRecorded = false;
|
|
250
|
+
metrics.lastDeltaAtMs = Date.now();
|
|
251
|
+
metrics.stallEpisode = 0;
|
|
252
|
+
metrics.reconnectAttempt = undefined;
|
|
253
|
+
metrics.reconnectMaxAttempts = undefined;
|
|
200
254
|
}));
|
|
201
255
|
|
|
202
256
|
unsubs.push(events.turns.on('STREAM_DELTA', () => {
|
|
203
257
|
metrics.deltaCount++;
|
|
258
|
+
metrics.lastDeltaAtMs = Date.now();
|
|
259
|
+
// A byte arrived: any in-flight reconnect (if the SDK reported one)
|
|
260
|
+
// has succeeded.
|
|
261
|
+
metrics.reconnectAttempt = undefined;
|
|
262
|
+
metrics.reconnectMaxAttempts = undefined;
|
|
204
263
|
const elapsed = (Date.now() - metrics.startTime) / 1000;
|
|
205
264
|
// Record TTFT on the first delta of each turn.
|
|
206
265
|
if (!metrics.ttftRecorded) {
|
|
@@ -296,10 +355,16 @@ export function wireStreamEventMetrics(
|
|
|
296
355
|
render();
|
|
297
356
|
}));
|
|
298
357
|
|
|
299
|
-
// --- Stream stall watchdog: emit
|
|
358
|
+
// --- Stream stall watchdog: emit a low hint each time a no-delta gap (at
|
|
359
|
+
// start of turn OR mid-stream, after a re-arm) crosses 30s. The transient
|
|
360
|
+
// system message below is the initial alert; streamMetrics.stallEpisode
|
|
361
|
+
// (read every render frame) drives the persistent "stalled Ns" indicator
|
|
362
|
+
// in the thinking fragment, so the two coexist rather than conflict — the
|
|
363
|
+
// message announces the stall, the indicator tracks it ongoing.
|
|
300
364
|
const stallWatchdog = createStreamStallWatchdog({
|
|
301
365
|
events: events.turns,
|
|
302
|
-
onStall: (providerName) => {
|
|
366
|
+
onStall: (providerName, episode) => {
|
|
367
|
+
metrics.stallEpisode = episode;
|
|
303
368
|
systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
|
|
304
369
|
render();
|
|
305
370
|
},
|
|
@@ -308,22 +373,59 @@ export function wireStreamEventMetrics(
|
|
|
308
373
|
});
|
|
309
374
|
unsubs.push(() => stallWatchdog.dispose());
|
|
310
375
|
|
|
376
|
+
// --- Structural consumption of STREAM_RETRY / STREAM_STALL ---
|
|
377
|
+
// Neither event exists on the pinned SDK's (0.35.0) TurnEvent union today —
|
|
378
|
+
// both are proposed additions for the transport-level withRetry() callback
|
|
379
|
+
// (see the stall-honesty audit brief). The typed `events.turns.on` feed
|
|
380
|
+
// rejects unknown event names at compile time, so this casts the feed to a
|
|
381
|
+
// loosely-typed variant and validates each payload with a runtime guard
|
|
382
|
+
// instead of importing a type name that doesn't exist yet — same pattern
|
|
383
|
+
// used elsewhere in this codebase for settings pending SDK schema additions
|
|
384
|
+
// (see src/input/settings-modal-data.ts). Compiles today against 0.35.0;
|
|
385
|
+
// lights up automatically once the SDK adds the real event.
|
|
386
|
+
const looseTurnsFeed = events.turns as unknown as LooseTurnEventFeed;
|
|
387
|
+
unsubs.push(looseTurnsFeed.on('STREAM_RETRY', (payload) => {
|
|
388
|
+
if (!isStreamRetryLikePayload(payload)) return;
|
|
389
|
+
metrics.reconnectAttempt = payload.attempt;
|
|
390
|
+
metrics.reconnectMaxAttempts = payload.maxAttempts;
|
|
391
|
+
render();
|
|
392
|
+
}));
|
|
393
|
+
unsubs.push(looseTurnsFeed.on('STREAM_STALL', () => {
|
|
394
|
+
// Informational only: the TUI's own createStreamStallWatchdog above is
|
|
395
|
+
// the authoritative no-delta detector and already drives the visible
|
|
396
|
+
// indicator via streamMetrics.stallEpisode. This subscription exists so
|
|
397
|
+
// the SDK's own signal (once it lands) is observed rather than silently
|
|
398
|
+
// dropped, without duplicating or fighting the local watchdog.
|
|
399
|
+
render();
|
|
400
|
+
}));
|
|
401
|
+
|
|
311
402
|
unsubs.push(events.tools.on('TOOL_EXECUTING', (ev) => {
|
|
312
403
|
metrics.activeToolStartedAtMs = ev.startedAt;
|
|
313
404
|
metrics.activeToolName = ev.tool;
|
|
314
405
|
render();
|
|
315
406
|
}));
|
|
407
|
+
// On every tool-completion path, reset lastDeltaAtMs to "now" so the
|
|
408
|
+
// post-tool silence window starts fresh. Tool execution suppresses stall
|
|
409
|
+
// detection at the render call site (see main.ts), but lastDeltaAtMs itself
|
|
410
|
+
// keeps its pre-tool value the whole time the tool runs — without this
|
|
411
|
+
// reset, the instant a tool completes (potentially long after the last real
|
|
412
|
+
// delta), the very next frame would read msSinceLastDelta as the full
|
|
413
|
+
// tool-execution duration and immediately report a stall, even though the
|
|
414
|
+
// model hasn't had a chance to resume producing tokens yet.
|
|
316
415
|
unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
|
|
317
416
|
metrics.activeToolStartedAtMs = undefined;
|
|
318
417
|
metrics.activeToolName = undefined;
|
|
418
|
+
metrics.lastDeltaAtMs = Date.now();
|
|
319
419
|
}));
|
|
320
420
|
unsubs.push(events.tools.on('TOOL_FAILED', () => {
|
|
321
421
|
metrics.activeToolStartedAtMs = undefined;
|
|
322
422
|
metrics.activeToolName = undefined;
|
|
423
|
+
metrics.lastDeltaAtMs = Date.now();
|
|
323
424
|
}));
|
|
324
425
|
unsubs.push(events.tools.on('TOOL_CANCELLED', () => {
|
|
325
426
|
metrics.activeToolStartedAtMs = undefined;
|
|
326
427
|
metrics.activeToolName = undefined;
|
|
428
|
+
metrics.lastDeltaAtMs = Date.now();
|
|
327
429
|
}));
|
|
328
430
|
|
|
329
431
|
let _errorSurfacedCb: ((exhausted: boolean) => void) | undefined;
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StreamStallWatchdog — detects
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* StreamStallWatchdog — detects gaps of silence (no STREAM_DELTA) longer than
|
|
3
|
+
* a configurable threshold, whether the gap is at the start of a turn or in
|
|
4
|
+
* the middle of an otherwise-flowing stream, and emits a low-priority hint
|
|
5
|
+
* each time a gap crosses the threshold.
|
|
5
6
|
*
|
|
6
7
|
* Design:
|
|
7
|
-
* - Arm on STREAM_START: set a timeout for
|
|
8
|
-
*
|
|
8
|
+
* - Arm on STREAM_START: reset the episode counter, set a timeout for
|
|
9
|
+
* STALL_THRESHOLD_MS.
|
|
10
|
+
* - Re-arm on STREAM_DELTA: every byte received resets the no-delta clock,
|
|
11
|
+
* so a stall that begins mid-stream (after tokens were already flowing)
|
|
12
|
+
* is caught just as reliably as a stall at turn start. This is the fix
|
|
13
|
+
* for the "silence after the first delta never re-triggers" gap: the
|
|
14
|
+
* previous behaviour disarmed permanently on the first STREAM_DELTA,
|
|
15
|
+
* which meant a multi-minute stall after output had already started
|
|
16
|
+
* produced zero indication.
|
|
9
17
|
* - Disarm on STREAM_END / TURN_COMPLETED / TURN_ERROR / TURN_CANCEL:
|
|
10
18
|
* clear the timeout (turn finished normally or with error).
|
|
11
|
-
* - If the timeout fires: emit ONE hint
|
|
12
|
-
*
|
|
19
|
+
* - If the timeout fires: emit ONE hint for that episode (do not repeat
|
|
20
|
+
* while the same gap continues), then wait for the gap to close (another
|
|
21
|
+
* STREAM_DELTA/STREAM_START) before a further silence can fire again —
|
|
22
|
+
* each such re-arm-then-timeout cycle is a new "stall episode" and the
|
|
23
|
+
* episode counter passed to onStall increments each time.
|
|
13
24
|
* - dispose(): clears all subscriptions and any pending timeout.
|
|
14
25
|
*
|
|
15
26
|
* @module
|
|
@@ -32,10 +43,13 @@ export interface StreamStallWatchdogOptions {
|
|
|
32
43
|
/** The turns event surface to subscribe on. */
|
|
33
44
|
events: WatchdogTurnEvents;
|
|
34
45
|
/**
|
|
35
|
-
* Called once per
|
|
36
|
-
* Receives the provider display name
|
|
46
|
+
* Called once per stall episode when the no-delta threshold is exceeded.
|
|
47
|
+
* Receives the provider display name and a 1-based episode counter that
|
|
48
|
+
* increments each time a new silence (after a re-arm) crosses the
|
|
49
|
+
* threshold within the same turn — so a second mid-stream stall after a
|
|
50
|
+
* recovery is distinguishable from the first.
|
|
37
51
|
*/
|
|
38
|
-
onStall: (providerName: string) => void;
|
|
52
|
+
onStall: (providerName: string, episode: number) => void;
|
|
39
53
|
/**
|
|
40
54
|
* Provides the current provider display name at the moment the hint fires.
|
|
41
55
|
* Optional — defaults to 'provider' when not supplied.
|
|
@@ -63,17 +77,19 @@ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): Str
|
|
|
63
77
|
const { events, onStall, getProviderName, thresholdMs = STALL_THRESHOLD_MS } = opts;
|
|
64
78
|
|
|
65
79
|
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
-
let
|
|
80
|
+
let hintFiredForEpisode = false;
|
|
81
|
+
let episodeCount = 0;
|
|
67
82
|
|
|
68
83
|
function arm(): void {
|
|
69
84
|
disarm();
|
|
70
|
-
|
|
85
|
+
hintFiredForEpisode = false;
|
|
71
86
|
stallTimer = setTimeout(() => {
|
|
72
87
|
stallTimer = null;
|
|
73
|
-
if (!
|
|
74
|
-
|
|
88
|
+
if (!hintFiredForEpisode) {
|
|
89
|
+
hintFiredForEpisode = true;
|
|
90
|
+
episodeCount++;
|
|
75
91
|
const provider = getProviderName ? getProviderName() : 'provider';
|
|
76
|
-
onStall(provider);
|
|
92
|
+
onStall(provider, episodeCount);
|
|
77
93
|
}
|
|
78
94
|
}, thresholdMs);
|
|
79
95
|
}
|
|
@@ -85,9 +101,17 @@ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): Str
|
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
function onStreamStart(): void {
|
|
105
|
+
episodeCount = 0;
|
|
106
|
+
arm();
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
const unsubs: Array<() => void> = [
|
|
89
|
-
events.on('STREAM_START',
|
|
90
|
-
|
|
110
|
+
events.on('STREAM_START', onStreamStart),
|
|
111
|
+
// Re-arm (not disarm) on every delta: the no-delta clock must reset on
|
|
112
|
+
// each byte so a stall that starts mid-stream is still caught, not just
|
|
113
|
+
// a stall before the first byte.
|
|
114
|
+
events.on('STREAM_DELTA', arm),
|
|
91
115
|
events.on('STREAM_END', disarm),
|
|
92
116
|
events.on('TURN_COMPLETED', disarm),
|
|
93
117
|
events.on('TURN_ERROR', disarm),
|