@pellux/goodvibes-tui 0.20.3 → 0.22.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 +50 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +4 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +658 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +31 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +14 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/context-auto-compact.ts +77 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +32 -1
- package/src/input/commands/control-room-runtime.ts +10 -10
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -4
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +128 -12
- package/src/input/handler-modal-token-routes.ts +22 -5
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +73 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +6 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +378 -0
- package/src/input/settings-modal-mutations.ts +157 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +236 -232
- package/src/main.ts +93 -85
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +5 -1
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/session-maintenance.ts +66 -15
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +118 -13
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +90 -10
- package/src/renderer/shell-surface.ts +10 -0
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +18 -0
- package/src/runtime/bootstrap-core.ts +145 -13
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +9 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +27 -1
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/panels/knowledge-panel.ts +0 -345
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -2,6 +2,7 @@ import type { Line } from '../types/grid.ts';
|
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
3
|
import { BasePanel } from './base-panel.ts';
|
|
4
4
|
import type { UiCockpitSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
|
|
5
|
+
import type { CockpitRosterReadModel } from './cockpit-read-model.ts';
|
|
5
6
|
import {
|
|
6
7
|
buildGuidanceLine,
|
|
7
8
|
buildKeyValueLine,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
DEFAULT_PANEL_PALETTE,
|
|
12
13
|
type PanelWorkspaceSection,
|
|
13
14
|
} from './polish.ts';
|
|
15
|
+
import { agentStatusColor } from './agent-inspector-shared.ts';
|
|
14
16
|
|
|
15
17
|
const C = {
|
|
16
18
|
...DEFAULT_PANEL_PALETTE,
|
|
@@ -24,45 +26,160 @@ function pickColor(value: number, warnAt = 1, badAt = 3): string {
|
|
|
24
26
|
return C.good;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
export interface CockpitPanelActionCallbacks {
|
|
30
|
+
readonly openAgentDetail: (agentId: string) => void;
|
|
31
|
+
readonly cancelAgent: (agentId: string) => boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const WORKSPACE_IDS = ['flow', 'governance', 'health', 'domains', 'agents'] as const;
|
|
35
|
+
type WorkspaceId = (typeof WORKSPACE_IDS)[number];
|
|
36
|
+
|
|
37
|
+
function formatCost(cost: number | null): string {
|
|
38
|
+
if (cost === null) return 'n/a';
|
|
39
|
+
return cost < 0.01 ? '<$0.01' : `$${cost.toFixed(2)}`;
|
|
40
|
+
}
|
|
28
41
|
|
|
29
42
|
export class CockpitPanel extends BasePanel {
|
|
30
43
|
private readonly unsub: (() => void) | null;
|
|
44
|
+
private readonly rosterUnsub: (() => void) | null;
|
|
45
|
+
private readonly actionCallbacks: CockpitPanelActionCallbacks;
|
|
31
46
|
private selectedWorkspaceIndex = 0;
|
|
47
|
+
private agentCursorIndex = 0;
|
|
48
|
+
private pendingCancelId: string | null = null;
|
|
32
49
|
|
|
33
|
-
public constructor(
|
|
50
|
+
public constructor(
|
|
51
|
+
private readonly readModel?: UiReadModel<UiCockpitSnapshot>,
|
|
52
|
+
private readonly rosterReadModel?: CockpitRosterReadModel,
|
|
53
|
+
actionCallbacks?: Partial<CockpitPanelActionCallbacks>,
|
|
54
|
+
) {
|
|
34
55
|
super('cockpit', 'Cockpit', 'O', 'monitoring');
|
|
35
56
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
57
|
+
this.rosterUnsub = rosterReadModel ? rosterReadModel.subscribe(() => this.markDirty()) : null;
|
|
58
|
+
this.actionCallbacks = {
|
|
59
|
+
openAgentDetail: actionCallbacks?.openAgentDetail ?? ((_id: string) => { /* noop */ }),
|
|
60
|
+
cancelAgent: actionCallbacks?.cancelAgent ?? ((_id: string) => false),
|
|
61
|
+
};
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
public override onDestroy(): void {
|
|
39
65
|
this.unsub?.();
|
|
66
|
+
this.rosterUnsub?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private get selectedWorkspace(): WorkspaceId {
|
|
70
|
+
return WORKSPACE_IDS[this.selectedWorkspaceIndex] ?? 'flow';
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
public handleInput(key: string): boolean {
|
|
74
|
+
// Confirm-cancel absorb: when a cancel is pending, only y/enter confirm, escape/n dismiss, everything else is consumed
|
|
75
|
+
if (this.pendingCancelId !== null) {
|
|
76
|
+
if (key === 'y' || key === 'enter' || key === 'return') {
|
|
77
|
+
this.actionCallbacks.cancelAgent(this.pendingCancelId);
|
|
78
|
+
this.pendingCancelId = null;
|
|
79
|
+
this.markDirty();
|
|
80
|
+
} else if (key === 'escape' || key === 'n') {
|
|
81
|
+
this.pendingCancelId = null;
|
|
82
|
+
this.markDirty();
|
|
83
|
+
}
|
|
84
|
+
// All other keys are consumed while confirm is pending
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
43
87
|
if (key === 'left' || key === 'h') {
|
|
44
88
|
this.selectedWorkspaceIndex = Math.max(0, this.selectedWorkspaceIndex - 1);
|
|
89
|
+
this.agentCursorIndex = 0;
|
|
45
90
|
this.markDirty();
|
|
46
91
|
return true;
|
|
47
92
|
}
|
|
48
93
|
if (key === 'right' || key === 'l') {
|
|
49
94
|
this.selectedWorkspaceIndex = Math.min(WORKSPACE_IDS.length - 1, this.selectedWorkspaceIndex + 1);
|
|
95
|
+
this.agentCursorIndex = 0;
|
|
50
96
|
this.markDirty();
|
|
51
97
|
return true;
|
|
52
98
|
}
|
|
53
99
|
if (key === 'home') {
|
|
54
100
|
this.selectedWorkspaceIndex = 0;
|
|
101
|
+
this.agentCursorIndex = 0;
|
|
55
102
|
this.markDirty();
|
|
56
103
|
return true;
|
|
57
104
|
}
|
|
58
105
|
if (key === 'end') {
|
|
59
106
|
this.selectedWorkspaceIndex = WORKSPACE_IDS.length - 1;
|
|
107
|
+
this.agentCursorIndex = 0;
|
|
60
108
|
this.markDirty();
|
|
61
109
|
return true;
|
|
62
110
|
}
|
|
111
|
+
// Agents workspace: cursor movement, inspect, and cancel-initiation
|
|
112
|
+
if (this.selectedWorkspace === 'agents') {
|
|
113
|
+
const roster = this.rosterReadModel?.getSnapshot().roster ?? [];
|
|
114
|
+
if (key === 'up' || key === 'k') {
|
|
115
|
+
this.agentCursorIndex = Math.max(0, this.agentCursorIndex - 1);
|
|
116
|
+
this.markDirty();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (key === 'down' || key === 'j') {
|
|
120
|
+
this.agentCursorIndex = Math.min(Math.max(0, roster.length - 1), this.agentCursorIndex + 1);
|
|
121
|
+
this.markDirty();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (key === 'i' || key === 'enter' || key === 'return') {
|
|
125
|
+
const entry = roster[this.agentCursorIndex];
|
|
126
|
+
if (entry) {
|
|
127
|
+
this.actionCallbacks.openAgentDetail(entry.id);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (key === 'c') {
|
|
132
|
+
const entry = roster[this.agentCursorIndex];
|
|
133
|
+
if (entry && !this.isTerminal(entry.status)) {
|
|
134
|
+
this.pendingCancelId = entry.id;
|
|
135
|
+
this.markDirty();
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
63
140
|
return false;
|
|
64
141
|
}
|
|
65
142
|
|
|
143
|
+
private isTerminal(status: string): boolean {
|
|
144
|
+
return status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private renderAgentsWorkspace(width: number): Line[] {
|
|
148
|
+
const lines: Line[] = [];
|
|
149
|
+
if (!this.rosterReadModel) {
|
|
150
|
+
lines.push(buildPanelLine(width, [[' Agent roster read model not wired.', C.empty]]));
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
const { roster, stalledAgentCount, totalCost } = this.rosterReadModel.getSnapshot();
|
|
154
|
+
lines.push(buildPanelLine(width, [
|
|
155
|
+
[` ${roster.length} agent${roster.length !== 1 ? 's' : ''}`, C.label],
|
|
156
|
+
stalledAgentCount > 0 ? [` ${stalledAgentCount} stalled`, C.bad] : ['', C.dim],
|
|
157
|
+
[` cost: ${formatCost(totalCost)}`, C.dim],
|
|
158
|
+
]));
|
|
159
|
+
const colors: Record<string, string> = {
|
|
160
|
+
pending: C.dim, running: C.value, completed: C.good, failed: C.bad, cancelled: C.dim, system: C.dim,
|
|
161
|
+
};
|
|
162
|
+
for (let i = 0; i < roster.length; i++) {
|
|
163
|
+
const entry = roster[i]!;
|
|
164
|
+
const cursor = i === this.agentCursorIndex ? '>' : ' ';
|
|
165
|
+
const shortId = entry.id.length > 8 ? entry.id.slice(-8) : entry.id;
|
|
166
|
+
const statusColor = agentStatusColor(entry.status, colors);
|
|
167
|
+
const stalledBadge: [string, string] = entry.stalled ? [' STALLED', C.bad] : ['', C.dim];
|
|
168
|
+
lines.push(buildPanelLine(width, [
|
|
169
|
+
[cursor, i === this.agentCursorIndex ? C.value : C.dim],
|
|
170
|
+
[` ${shortId} `, C.dim],
|
|
171
|
+
[entry.status, statusColor],
|
|
172
|
+
stalledBadge,
|
|
173
|
+
[' ', C.dim],
|
|
174
|
+
[entry.task.slice(0, Math.max(0, width - 30)), C.label],
|
|
175
|
+
]));
|
|
176
|
+
}
|
|
177
|
+
if (roster.length === 0) {
|
|
178
|
+
lines.push(buildPanelLine(width, [[' No agents tracked yet.', C.empty]]));
|
|
179
|
+
}
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
66
183
|
public render(width: number, height: number): Line[] {
|
|
67
184
|
this.needsRender = false;
|
|
68
185
|
|
|
@@ -153,7 +270,7 @@ export class CockpitPanel extends BasePanel {
|
|
|
153
270
|
], C));
|
|
154
271
|
workspaceLines.push(buildGuidanceLine(width, '/incident latest', 'inspect the latest incident bundle and replay fallout', C));
|
|
155
272
|
workspaceLines.push(buildGuidanceLine(width, '/plugins', 'review errored plugins and provenance posture', C));
|
|
156
|
-
} else {
|
|
273
|
+
} else if (selectedWorkspace === 'domains') {
|
|
157
274
|
workspaceLines.push(buildKeyValueLine(width, [
|
|
158
275
|
{ label: 'tasks', value: String(snapshot.taskCount), valueColor: C.value },
|
|
159
276
|
{ label: 'comms', value: String(snapshot.communicationCount), valueColor: C.value },
|
|
@@ -161,6 +278,9 @@ export class CockpitPanel extends BasePanel {
|
|
|
161
278
|
], C));
|
|
162
279
|
workspaceLines.push(buildGuidanceLine(width, '/mcp', 'inspect trust, quarantine, and risky server posture', C));
|
|
163
280
|
workspaceLines.push(buildGuidanceLine(width, '/communication', 'review blocked lanes and agent message flow', C));
|
|
281
|
+
} else {
|
|
282
|
+
// 'agents' workspace — roster from rosterReadModel
|
|
283
|
+
workspaceLines.push(...this.renderAgentsWorkspace(width));
|
|
164
284
|
}
|
|
165
285
|
|
|
166
286
|
const sections: PanelWorkspaceSection[] = [
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// cockpit-read-model.ts
|
|
3
|
+
//
|
|
4
|
+
// TASK-046: Cockpit agent roster slice + cost/token aggregates + stalledAgentCount
|
|
5
|
+
//
|
|
6
|
+
// Provides a thin read-model over AgentManager.list() / getStatus() so the
|
|
7
|
+
// CockpitPanel can display an agent roster without depending on the full
|
|
8
|
+
// AgentInspectorPanel object.
|
|
9
|
+
//
|
|
10
|
+
// Design notes:
|
|
11
|
+
// - Per-agent cost delegates to calcSessionCost() from cost-utils.ts (canonical
|
|
12
|
+
// billing formula) and requires real usage data from AgentRecord.usage. When
|
|
13
|
+
// usage is absent (agent spawned but not yet completed), cost/tokens show as n/a
|
|
14
|
+
// rather than fabricated values (39327f86 honest-UX standard).
|
|
15
|
+
// - stalledAgentCount delegates to countStalledAgents() from agent-inspector-shared.ts
|
|
16
|
+
// (canonical stall-count function extracted from TASK-046 review) — no reimplementation.
|
|
17
|
+
// - The read-model is a plain object (snapshot + subscribe) so it can be
|
|
18
|
+
// wired in tests without a full runtime.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
22
|
+
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
23
|
+
import {
|
|
24
|
+
AGENT_TERMINAL_STATUSES,
|
|
25
|
+
AGENT_STALL_THRESHOLD_MS,
|
|
26
|
+
countStalledAgents,
|
|
27
|
+
} from './agent-inspector-shared.ts';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Public types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Status of a single agent in the cockpit roster. */
|
|
34
|
+
export type CockpitAgentStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
|
35
|
+
|
|
36
|
+
/** A single row in the cockpit agent roster. */
|
|
37
|
+
export interface CockpitAgentRosterEntry {
|
|
38
|
+
/** Full agent id. */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
/** Short task description (truncated at 50 chars). */
|
|
41
|
+
readonly task: string;
|
|
42
|
+
/** Model identifier, or 'unknown' when not yet resolved. */
|
|
43
|
+
readonly model: string;
|
|
44
|
+
/** Agent lifecycle status. */
|
|
45
|
+
readonly status: CockpitAgentStatus;
|
|
46
|
+
/** True when the agent is non-terminal and has exceeded AGENT_STALL_THRESHOLD_MS. */
|
|
47
|
+
readonly stalled: boolean;
|
|
48
|
+
/** Input tokens consumed (including cache read+write), or null when unavailable. */
|
|
49
|
+
readonly inputTokens: number | null;
|
|
50
|
+
/** Output tokens produced, or null when unavailable. */
|
|
51
|
+
readonly outputTokens: number | null;
|
|
52
|
+
/** Estimated cost in USD, or null when token data is unavailable. */
|
|
53
|
+
readonly cost: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Aggregate snapshot produced by the cockpit roster read-model. */
|
|
57
|
+
export interface CockpitRosterSnapshot {
|
|
58
|
+
/** All agents in the manager, newest-first by startedAt. */
|
|
59
|
+
readonly roster: readonly CockpitAgentRosterEntry[];
|
|
60
|
+
/** Number of non-terminal agents running past AGENT_STALL_THRESHOLD_MS. */
|
|
61
|
+
readonly stalledAgentCount: number;
|
|
62
|
+
/**
|
|
63
|
+
* Sum of all input tokens across agents with real usage data.
|
|
64
|
+
* null when NO agent has usage data yet (avoids showing 0 when data is simply absent).
|
|
65
|
+
*/
|
|
66
|
+
readonly totalInputTokens: number | null;
|
|
67
|
+
/**
|
|
68
|
+
* Sum of all output tokens across agents with real usage data.
|
|
69
|
+
* null when NO agent has usage data yet.
|
|
70
|
+
*/
|
|
71
|
+
readonly totalOutputTokens: number | null;
|
|
72
|
+
/**
|
|
73
|
+
* Total estimated cost in USD across agents with real pricing data.
|
|
74
|
+
* null when NO agent has priceable data yet.
|
|
75
|
+
*/
|
|
76
|
+
readonly totalCost: number | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// AgentManager minimal interface (subset used here)
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface CockpitRosterAgentManager {
|
|
84
|
+
list(): AgentRecord[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Snapshot builder — pure, testable
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a CockpitRosterSnapshot from a raw AgentRecord list.
|
|
93
|
+
* Exported so unit tests can drive it directly without a manager stub.
|
|
94
|
+
*/
|
|
95
|
+
export function buildCockpitRosterSnapshot(
|
|
96
|
+
records: AgentRecord[],
|
|
97
|
+
now: number = Date.now(),
|
|
98
|
+
): CockpitRosterSnapshot {
|
|
99
|
+
// Sort newest-first by startedAt
|
|
100
|
+
const sorted = [...records].sort((a, b) => b.startedAt - a.startedAt);
|
|
101
|
+
|
|
102
|
+
let hasUsage = false;
|
|
103
|
+
let totalInputTokens = 0;
|
|
104
|
+
let totalOutputTokens = 0;
|
|
105
|
+
let totalCost = 0;
|
|
106
|
+
|
|
107
|
+
const roster: CockpitAgentRosterEntry[] = sorted.map((rec) => {
|
|
108
|
+
const isTerminal = AGENT_TERMINAL_STATUSES.has(rec.status);
|
|
109
|
+
const elapsed = now - rec.startedAt;
|
|
110
|
+
const stalled = !isTerminal && elapsed >= AGENT_STALL_THRESHOLD_MS;
|
|
111
|
+
|
|
112
|
+
let inputTokens: number | null = null;
|
|
113
|
+
let outputTokens: number | null = null;
|
|
114
|
+
let cost: number | null = null;
|
|
115
|
+
|
|
116
|
+
if (rec.usage) {
|
|
117
|
+
hasUsage = true;
|
|
118
|
+
const inp =
|
|
119
|
+
rec.usage.inputTokens +
|
|
120
|
+
(rec.usage.cacheReadTokens ?? 0) +
|
|
121
|
+
(rec.usage.cacheWriteTokens ?? 0);
|
|
122
|
+
const out = rec.usage.outputTokens;
|
|
123
|
+
const agentCost = calcSessionCost(
|
|
124
|
+
rec.usage.inputTokens,
|
|
125
|
+
rec.usage.outputTokens,
|
|
126
|
+
rec.usage.cacheReadTokens ?? 0,
|
|
127
|
+
rec.usage.cacheWriteTokens ?? 0,
|
|
128
|
+
rec.model ?? 'unknown',
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
inputTokens = inp;
|
|
132
|
+
outputTokens = out;
|
|
133
|
+
cost = agentCost;
|
|
134
|
+
|
|
135
|
+
totalInputTokens += inp;
|
|
136
|
+
totalOutputTokens += out;
|
|
137
|
+
totalCost += agentCost;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const task = rec.task.length > 50 ? rec.task.slice(0, 47) + '...' : rec.task;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
id: rec.id,
|
|
144
|
+
task,
|
|
145
|
+
model: rec.model ?? 'unknown',
|
|
146
|
+
status: rec.status,
|
|
147
|
+
stalled,
|
|
148
|
+
inputTokens,
|
|
149
|
+
outputTokens,
|
|
150
|
+
cost,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
roster,
|
|
156
|
+
stalledAgentCount: countStalledAgents(sorted, now),
|
|
157
|
+
totalInputTokens: hasUsage ? totalInputTokens : null,
|
|
158
|
+
totalOutputTokens: hasUsage ? totalOutputTokens : null,
|
|
159
|
+
totalCost: hasUsage ? totalCost : null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Read-model factory
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** Minimal read-model interface matching the existing UiReadModel shape. */
|
|
168
|
+
export interface CockpitRosterReadModel {
|
|
169
|
+
getSnapshot(): CockpitRosterSnapshot;
|
|
170
|
+
/**
|
|
171
|
+
* Notify all subscribers that the roster has changed and the cockpit panel
|
|
172
|
+
* should re-render. Wire this to an AgentManager event feed so live agent
|
|
173
|
+
* state changes propagate:
|
|
174
|
+
*
|
|
175
|
+
* const roster = createCockpitRosterReadModel(agentManager);
|
|
176
|
+
* agentEvents.subscribe(() => roster.markDirty());
|
|
177
|
+
*
|
|
178
|
+
* For static/test fixtures the implementation is a no-op.
|
|
179
|
+
*/
|
|
180
|
+
markDirty(): void;
|
|
181
|
+
/**
|
|
182
|
+
* Subscribe to changes. The listener is called whenever markDirty() is
|
|
183
|
+
* invoked. Returns an unsubscribe function.
|
|
184
|
+
*
|
|
185
|
+
* For static/test fixtures, returns a no-op unsubscribe.
|
|
186
|
+
*/
|
|
187
|
+
subscribe(listener: () => void): () => void;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a live CockpitRosterReadModel backed by an AgentManager.
|
|
192
|
+
*
|
|
193
|
+
* The returned read-model re-derives its snapshot on every getSnapshot() call
|
|
194
|
+
* so it always reflects the current agent state without needing an event bus.
|
|
195
|
+
* Callers that want reactive updates should poll or wire in their own
|
|
196
|
+
* event-driven markDirty() path.
|
|
197
|
+
*/
|
|
198
|
+
export function createCockpitRosterReadModel(
|
|
199
|
+
agentManager: CockpitRosterAgentManager,
|
|
200
|
+
): CockpitRosterReadModel {
|
|
201
|
+
const listeners = new Set<() => void>();
|
|
202
|
+
|
|
203
|
+
function markDirty(): void {
|
|
204
|
+
for (const listener of listeners) {
|
|
205
|
+
listener();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
getSnapshot(): CockpitRosterSnapshot {
|
|
211
|
+
return buildCockpitRosterSnapshot(agentManager.list());
|
|
212
|
+
},
|
|
213
|
+
markDirty,
|
|
214
|
+
subscribe(listener: () => void): () => void {
|
|
215
|
+
listeners.add(listener);
|
|
216
|
+
return () => listeners.delete(listener);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Create a static CockpitRosterReadModel for testing.
|
|
223
|
+
*/
|
|
224
|
+
export function createStaticCockpitRosterReadModel(
|
|
225
|
+
snapshot: CockpitRosterSnapshot,
|
|
226
|
+
): CockpitRosterReadModel {
|
|
227
|
+
return {
|
|
228
|
+
getSnapshot: () => snapshot,
|
|
229
|
+
markDirty: () => {},
|
|
230
|
+
subscribe: () => () => {},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// useConfirmState<T> — reusable inline
|
|
2
|
+
// useConfirmState<T> — reusable inline confirm/cancel helper
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// ── Project-standard confirm contract (all panels must match) ──────────────
|
|
5
|
+
//
|
|
6
|
+
// CONFIRM: Enter, Return, or y
|
|
7
|
+
// CANCEL: Esc or n
|
|
8
|
+
// ABSORBED: any other key while confirm is active (keeps confirm pending)
|
|
9
|
+
//
|
|
10
|
+
// Implementation:
|
|
11
|
+
// - Composable: any panel holds a ConfirmState<T> field; no new base class
|
|
12
|
+
// - Call handleConfirmInput(confirm, key) BEFORE normal key dispatch.
|
|
13
|
+
// It handles all four outcomes and returns one of the four result tokens.
|
|
14
|
+
// - Call renderConfirmLines(width, state) to render the two-line overlay
|
|
15
|
+
// that replaces normal content while a confirm is pending.
|
|
16
|
+
//
|
|
17
|
+
// ── This file is the canonical contract for all confirm flows ─────────────
|
|
18
|
+
// Any new panel confirm flow must use ConfirmState<T> and handleConfirmInput;
|
|
19
|
+
// do not implement a bespoke two-press or Enter-only variant.
|
|
10
20
|
// ---------------------------------------------------------------------------
|
|
11
21
|
|
|
12
22
|
import type { Line } from '../types/grid.ts';
|
|
@@ -23,9 +33,14 @@ export interface ConfirmState<T = string> {
|
|
|
23
33
|
/**
|
|
24
34
|
* Call this from a panel's handleInput() BEFORE any other key handling.
|
|
25
35
|
*
|
|
36
|
+
* Project-standard confirm contract:
|
|
37
|
+
* - CONFIRM: Enter, Return, or y
|
|
38
|
+
* - CANCEL: Esc or n
|
|
39
|
+
* - ABSORBED: any other key while confirm is active (keeps confirm pending)
|
|
40
|
+
*
|
|
26
41
|
* Returns:
|
|
27
|
-
* - `'confirmed'` — user pressed y; caller must execute
|
|
28
|
-
* clear state (set confirm to null)
|
|
42
|
+
* - `'confirmed'` — user pressed Enter, Return, or y; caller must execute
|
|
43
|
+
* the action and clear state (set confirm to null)
|
|
29
44
|
* - `'cancelled'` — user pressed n or Esc; caller must clear state
|
|
30
45
|
* - `'absorbed'` — any other key while confirm is active; caller returns true
|
|
31
46
|
* - `'inactive'` — no confirm pending; caller continues normal dispatch
|
|
@@ -35,7 +50,7 @@ export function handleConfirmInput<T = string>(
|
|
|
35
50
|
key: string,
|
|
36
51
|
): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
|
|
37
52
|
if (!confirm) return 'inactive';
|
|
38
|
-
if (key === 'y') return 'confirmed';
|
|
53
|
+
if (key === 'y' || key === 'enter' || key === 'return') return 'confirmed';
|
|
39
54
|
if (key === 'n' || key === 'escape') return 'cancelled';
|
|
40
55
|
return 'absorbed';
|
|
41
56
|
}
|
|
@@ -52,8 +67,8 @@ export function renderConfirmLines<T = string>(width: number, state: ConfirmStat
|
|
|
52
67
|
palette.warn,
|
|
53
68
|
]]),
|
|
54
69
|
buildPanelLine(width, [
|
|
55
|
-
[' y', palette.info],
|
|
56
|
-
[' confirm
|
|
70
|
+
[' Enter / y', palette.info],
|
|
71
|
+
[' confirm', palette.dim],
|
|
57
72
|
[' n / Esc', palette.info],
|
|
58
73
|
[' cancel', palette.dim],
|
|
59
74
|
]),
|
|
@@ -7,6 +7,7 @@ import { createStyledCell, createEmptyLine } from '../types/grid.ts';
|
|
|
7
7
|
import { BasePanel } from './base-panel.ts';
|
|
8
8
|
import type { AgentEvent, TurnEvent } from '@/runtime/index.ts';
|
|
9
9
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
10
|
+
import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
10
11
|
import {
|
|
11
12
|
buildEmptyState,
|
|
12
13
|
buildPanelLine,
|
|
@@ -16,68 +17,9 @@ import {
|
|
|
16
17
|
DEFAULT_PANEL_PALETTE,
|
|
17
18
|
type PanelWorkspaceSection,
|
|
18
19
|
} from './polish.ts';
|
|
20
|
+
import { getPricing } from '../export/cost-utils.ts';
|
|
19
21
|
|
|
20
|
-
//
|
|
21
|
-
// Pricing table (USD per 1M tokens)
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
|
|
24
|
-
interface ModelPricing {
|
|
25
|
-
input: number;
|
|
26
|
-
output: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
30
|
-
// Free tier
|
|
31
|
-
'openrouter/free': { input: 0, output: 0 },
|
|
32
|
-
|
|
33
|
-
// InceptionLabs
|
|
34
|
-
'mercury-2': { input: 0.50, output: 1.50 },
|
|
35
|
-
'mercury-edit': { input: 0.50, output: 1.50 },
|
|
36
|
-
|
|
37
|
-
// OpenAI
|
|
38
|
-
'gpt-5.4': { input: 5, output: 15 },
|
|
39
|
-
'gpt-5.3-chat-latest': { input: 3, output: 10 },
|
|
40
|
-
'gpt-5-mini': { input: 0.15, output: 0.60 },
|
|
41
|
-
'gpt-5-nano': { input: 0.05, output: 0.20 },
|
|
42
|
-
'gpt-oss-120b': { input: 0, output: 0 },
|
|
43
|
-
|
|
44
|
-
// Anthropic (correct registry IDs)
|
|
45
|
-
'claude-opus-4-6': { input: 15, output: 75 },
|
|
46
|
-
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
47
|
-
'claude-haiku-4-5': { input: 0.80, output: 4 },
|
|
48
|
-
|
|
49
|
-
// Google
|
|
50
|
-
'gemini-3.1-pro': { input: 1.25, output: 5 },
|
|
51
|
-
'gemini-3-flash': { input: 0.075, output: 0.30 },
|
|
52
|
-
'gemini-3.1-flash-lite': { input: 0.02, output: 0.10 },
|
|
53
|
-
'gemini-2.5-pro': { input: 1.25, output: 5 },
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Look up pricing from the model catalog.
|
|
58
|
-
* Returns { input: 0, output: 0 } for free models and unknown models.
|
|
59
|
-
*/
|
|
60
|
-
function getCostFromCatalogForPanel(modelId: string): ModelPricing {
|
|
61
|
-
if (modelId.endsWith(':free')) return { input: 0, output: 0 };
|
|
62
|
-
return { input: 0, output: 0 };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function getPricing(modelId: string): ModelPricing {
|
|
66
|
-
// 2. Hardcoded table — exact match
|
|
67
|
-
if (MODEL_PRICING[modelId]) return MODEL_PRICING[modelId]!;
|
|
68
|
-
// 1. OpenRouter :free suffix — treat as free
|
|
69
|
-
if (modelId.endsWith(':free')) return { input: 0, output: 0 };
|
|
70
|
-
// 2. Prefix match (e.g. "openrouter/free:..." or "claude-sonnet-4-6-20..")
|
|
71
|
-
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
|
72
|
-
if (modelId.startsWith(key) || modelId.includes(key)) return pricing;
|
|
73
|
-
}
|
|
74
|
-
// 3. Unknown model — default to free-ish safe fallback
|
|
75
|
-
return getCostFromCatalogForPanel(modelId);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function calcCost(inputTokens: number, outputTokens: number, pricing: ModelPricing): number {
|
|
79
|
-
return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
|
|
80
|
-
}
|
|
22
|
+
// Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
|
|
81
23
|
|
|
82
24
|
function formatCost(usd: number): string {
|
|
83
25
|
if (usd === 0) return '$0.00';
|
|
@@ -176,14 +118,19 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
176
118
|
// Getter for live orchestrator usage
|
|
177
119
|
private readonly getOrchestratorUsage: () => UsageSnapshot & { model?: string };
|
|
178
120
|
|
|
121
|
+
// Optional resolver for agent usage on completion — enables real cost attribution.
|
|
122
|
+
// When omitted, completed agents show $0 (honest: data unavailable).
|
|
123
|
+
private readonly getAgentStatus: ((agentId: string) => AgentRecord | null) | undefined;
|
|
124
|
+
|
|
179
125
|
constructor(
|
|
180
126
|
turnEvents: UiEventFeed<TurnEvent>,
|
|
181
127
|
agentEvents: UiEventFeed<AgentEvent>,
|
|
182
128
|
getOrchestratorUsage: () => UsageSnapshot & { model?: string },
|
|
183
|
-
opts: { budgetThreshold?: number } = {},
|
|
129
|
+
opts: { budgetThreshold?: number; getAgentStatus?: (agentId: string) => AgentRecord | null } = {},
|
|
184
130
|
) {
|
|
185
131
|
super('cost', 'Cost', '$', 'monitoring');
|
|
186
132
|
this.getOrchestratorUsage = getOrchestratorUsage;
|
|
133
|
+
this.getAgentStatus = opts.getAgentStatus;
|
|
187
134
|
this.budgetThreshold = opts.budgetThreshold ?? 0;
|
|
188
135
|
this.attachEvents(turnEvents, agentEvents);
|
|
189
136
|
}
|
|
@@ -214,12 +161,22 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
214
161
|
}),
|
|
215
162
|
);
|
|
216
163
|
|
|
217
|
-
// Agent completed — capture token
|
|
164
|
+
// Agent completed — capture real token usage via AgentRecord when available
|
|
218
165
|
this.unsubs.push(
|
|
219
166
|
agentEvents.on('AGENT_COMPLETED', (payload) => {
|
|
220
167
|
const entry = this.agents.get(payload.agentId);
|
|
221
168
|
if (entry) {
|
|
222
169
|
entry.status = 'done';
|
|
170
|
+
if (this.getAgentStatus) {
|
|
171
|
+
const rec = this.getAgentStatus(payload.agentId);
|
|
172
|
+
if (rec?.usage) {
|
|
173
|
+
entry.inputTokens = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
|
|
174
|
+
entry.outputTokens = rec.usage.outputTokens;
|
|
175
|
+
const pricing = getPricing(rec.model ?? 'unknown');
|
|
176
|
+
entry.cost = (entry.inputTokens * pricing.input + entry.outputTokens * pricing.output) / 1_000_000;
|
|
177
|
+
if (rec.model && rec.model !== 'unknown') entry.model = rec.model;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
223
180
|
this.markDirty();
|
|
224
181
|
}
|
|
225
182
|
}),
|
|
@@ -248,9 +205,9 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
248
205
|
if (usage.model) this.sessionModel = usage.model;
|
|
249
206
|
|
|
250
207
|
// Record cost delta for sparkline
|
|
251
|
-
const sessionProvider = this.sessionModel.includes('/') ? this.sessionModel.split('/')[0]! : '';
|
|
252
208
|
const pricing = getPricing(this.sessionModel);
|
|
253
|
-
const
|
|
209
|
+
const billableInput = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
210
|
+
const totalCost = (billableInput * pricing.input + usage.output * pricing.output) / 1_000_000;
|
|
254
211
|
const delta = Math.max(0, totalCost - this.lastSessionCost);
|
|
255
212
|
this.lastSessionCost = totalCost;
|
|
256
213
|
this.costHistory.push(delta);
|
|
@@ -310,10 +267,9 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
310
267
|
render(width: number, height: number): Line[] {
|
|
311
268
|
if (height <= 0 || width <= 0) return [];
|
|
312
269
|
|
|
313
|
-
const sessionProvider = this.sessionModel.includes('/') ? this.sessionModel.split('/')[0]! : '';
|
|
314
270
|
const pricing = getPricing(this.sessionModel);
|
|
315
271
|
const totalInputTokens = this.sessionUsage.input + this.sessionUsage.cacheRead + this.sessionUsage.cacheWrite;
|
|
316
|
-
const sessionCost =
|
|
272
|
+
const sessionCost = (totalInputTokens * pricing.input + this.sessionUsage.output * pricing.output) / 1_000_000;
|
|
317
273
|
const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
|
|
318
274
|
const sparkline = buildSparkline(this.costHistory);
|
|
319
275
|
const costStr = formatCost(sessionCost);
|