@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.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. 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
- const WORKSPACE_IDS = ['flow', 'governance', 'health', 'domains'] as const;
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(private readonly readModel?: UiReadModel<UiCockpitSnapshot>) {
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 y/n confirmation helper
2
+ // useConfirmState<T> — reusable inline confirm/cancel helper
3
3
  //
4
- // Pattern (chosen over ConfirmableListPanel base class):
5
- // - Composable: any panel holds a ConfirmState field, not a new base class
6
- // - Identical y/n UX everywhere: y confirms, n/Esc cancels, any other key
7
- // is absorbed (does nothing) while confirm is active
8
- // - Render: caller calls renderConfirmLines(width, state) to get the two
9
- // lines that replace the normal content area when confirming
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 the action and
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 delete', palette.dim],
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 data from result if available
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 totalCost = calcCost(usage.input + usage.cacheRead + usage.cacheWrite, usage.output, pricing);
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 = calcCost(this.sessionUsage.input + this.sessionUsage.cacheRead + this.sessionUsage.cacheWrite, this.sessionUsage.output, pricing);
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);