@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
@@ -0,0 +1,144 @@
1
+ import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
+ import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
3
+ import { formatUserFacingErrorLine } from './format-user-error.ts';
4
+
5
+ /**
6
+ * Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
7
+ * The object is mutated in place by event handlers; callers declare it before
8
+ * render() and pass it in, so render() can read the fields without copying.
9
+ */
10
+ export interface StreamMetrics {
11
+ /** Epoch ms when the most recent STREAM_START fired; 0 when idle. */
12
+ startTime: number;
13
+ /** Number of STREAM_DELTA events received since the last STREAM_START. */
14
+ deltaCount: number;
15
+ /** Computed tokens-per-second at the last STREAM_DELTA; 0 when idle. */
16
+ tokenSpeed: number;
17
+ /** Elapsed ms from STREAM_START to first STREAM_DELTA (time-to-first-token). */
18
+ ttftMs: number | undefined;
19
+ /** Whether TTFT has been recorded for the current turn. */
20
+ ttftRecorded: boolean;
21
+ /** Epoch ms when the most recent TOOL_EXECUTING event fired; undefined when idle. */
22
+ activeToolStartedAtMs: number | undefined;
23
+ /** Name of the currently executing tool; cleared when execution completes. */
24
+ activeToolName: string | undefined;
25
+ }
26
+
27
+ /** Minimal orchestrator surface required for stream token-speed calculation. */
28
+ interface StreamOrchestrator {
29
+ readonly streamingOutputTokens: number;
30
+ }
31
+
32
+ /** Minimal provider surface required for the stream stall watchdog. */
33
+ interface StreamProviderRegistry {
34
+ getCurrentModel(): { readonly provider: string };
35
+ }
36
+
37
+ /** Minimal system-message surface required for user-visible notifications. */
38
+ interface StreamSystemMessageRouter {
39
+ high(message: string): void;
40
+ low(message: string): void;
41
+ }
42
+
43
+ export interface WireStreamEventMetricsOptions {
44
+ /** The UI runtime event bus (turns + tools sub-buses). */
45
+ readonly events: UiRuntimeEvents;
46
+ /** Orchestrator reference used to read real output token counts. */
47
+ readonly orchestrator: StreamOrchestrator;
48
+ /** Provider registry used by the stall watchdog to name the current provider. */
49
+ readonly providerRegistry: StreamProviderRegistry;
50
+ /** System message router for turn-error and stall notifications. */
51
+ readonly systemMessageRouter: StreamSystemMessageRouter;
52
+ /** Trigger a UI repaint after a state mutation. */
53
+ readonly render: () => void;
54
+ /**
55
+ * Caller-owned metrics object to mutate in place. Declared before render()
56
+ * so the render closure can read it without a forward-reference issue.
57
+ */
58
+ readonly metrics: StreamMetrics;
59
+ }
60
+
61
+ /**
62
+ * Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
63
+ * and install the stream-stall watchdog. The caller owns the metrics object
64
+ * and declares it before render() so both the render closure and the returned
65
+ * event handlers share the same reference.
66
+ *
67
+ * Returns an array of unsubscribe functions; push them into the parent unsubs
68
+ * array so they are cleaned up on exit.
69
+ *
70
+ * Responsibilities:
71
+ * - Track stream start time, delta count, token speed, and TTFT
72
+ * - Track the currently executing tool name and start time
73
+ * - Display TURN_ERROR messages via systemMessageRouter
74
+ * - Emit a stall hint when STREAM_START has no delta within the watchdog threshold
75
+ */
76
+ export function wireStreamEventMetrics(
77
+ options: WireStreamEventMetricsOptions,
78
+ ): ReadonlyArray<() => void> {
79
+ const { events, metrics, orchestrator, providerRegistry, systemMessageRouter, render } = options;
80
+
81
+ const unsubs: Array<() => void> = [];
82
+
83
+ unsubs.push(events.turns.on('STREAM_START', () => {
84
+ metrics.startTime = Date.now();
85
+ metrics.deltaCount = 0;
86
+ metrics.tokenSpeed = 0;
87
+ metrics.ttftMs = undefined;
88
+ metrics.ttftRecorded = false;
89
+ }));
90
+
91
+ unsubs.push(events.turns.on('STREAM_DELTA', () => {
92
+ metrics.deltaCount++;
93
+ const elapsed = (Date.now() - metrics.startTime) / 1000;
94
+ // Record TTFT on the first delta of each turn.
95
+ if (!metrics.ttftRecorded) {
96
+ metrics.ttftMs = Date.now() - metrics.startTime;
97
+ metrics.ttftRecorded = true;
98
+ }
99
+ // Use real output token count for accurate tok/s; fall back to delta count.
100
+ const tokenCount = orchestrator.streamingOutputTokens > 0
101
+ ? orchestrator.streamingOutputTokens
102
+ : metrics.deltaCount;
103
+ metrics.tokenSpeed = elapsed > 0 ? tokenCount / elapsed : 0;
104
+ }));
105
+
106
+ unsubs.push(events.turns.on('TURN_ERROR', (event) => {
107
+ const errVal: string = event.error;
108
+ const formatted = formatUserFacingErrorLine(errVal);
109
+ systemMessageRouter.high(`[Error] ${formatted}`);
110
+ render();
111
+ }));
112
+
113
+ // --- Stream stall watchdog: emit one low hint if STREAM_START has no delta within 30s ---
114
+ const stallWatchdog = createStreamStallWatchdog({
115
+ events: events.turns,
116
+ onStall: (providerName) => {
117
+ systemMessageRouter.low(`Still waiting on ${providerName}… Ctrl+C to cancel`);
118
+ render();
119
+ },
120
+ getProviderName: () => providerRegistry.getCurrentModel().provider,
121
+ // thresholdMs uses the default 30 000
122
+ });
123
+ unsubs.push(() => stallWatchdog.dispose());
124
+
125
+ unsubs.push(events.tools.on('TOOL_EXECUTING', (ev) => {
126
+ metrics.activeToolStartedAtMs = ev.startedAt;
127
+ metrics.activeToolName = ev.tool;
128
+ render();
129
+ }));
130
+ unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
131
+ metrics.activeToolStartedAtMs = undefined;
132
+ metrics.activeToolName = undefined;
133
+ }));
134
+ unsubs.push(events.tools.on('TOOL_FAILED', () => {
135
+ metrics.activeToolStartedAtMs = undefined;
136
+ metrics.activeToolName = undefined;
137
+ }));
138
+ unsubs.push(events.tools.on('TOOL_CANCELLED', () => {
139
+ metrics.activeToolStartedAtMs = undefined;
140
+ metrics.activeToolName = undefined;
141
+ }));
142
+
143
+ return unsubs;
144
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * StreamStallWatchdog — detects STREAM_START events where no STREAM_DELTA
3
+ * arrives within a configurable threshold, and emits a single low-priority
4
+ * hint to the user.
5
+ *
6
+ * Design:
7
+ * - Arm on STREAM_START: set a timeout for STALL_THRESHOLD_MS.
8
+ * - Disarm on STREAM_DELTA: clear the pending timeout (stream is alive).
9
+ * - Disarm on STREAM_END / TURN_COMPLETED / TURN_ERROR / TURN_CANCEL:
10
+ * clear the timeout (turn finished normally or with error).
11
+ * - If the timeout fires: emit ONE hint, do NOT repeat until the next turn.
12
+ * - Re-arm only on the next STREAM_START (next turn).
13
+ * - dispose(): clears all subscriptions and any pending timeout.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ /** Milliseconds of silence after STREAM_START before emitting the hint. */
19
+ export const STALL_THRESHOLD_MS = 30_000;
20
+
21
+ /** Events surface subset the watchdog needs. */
22
+ export interface WatchdogTurnEvents {
23
+ on(event: 'STREAM_START', handler: () => void): () => void;
24
+ on(event: 'STREAM_DELTA', handler: () => void): () => void;
25
+ on(event: 'STREAM_END', handler: () => void): () => void;
26
+ on(event: 'TURN_COMPLETED', handler: () => void): () => void;
27
+ on(event: 'TURN_ERROR', handler: () => void): () => void;
28
+ on(event: 'TURN_CANCEL', handler: () => void): () => void;
29
+ }
30
+
31
+ export interface StreamStallWatchdogOptions {
32
+ /** The turns event surface to subscribe on. */
33
+ events: WatchdogTurnEvents;
34
+ /**
35
+ * Called once per turn when the stall threshold is exceeded.
36
+ * Receives the provider display name for the hint message.
37
+ */
38
+ onStall: (providerName: string) => void;
39
+ /**
40
+ * Provides the current provider display name at the moment the hint fires.
41
+ * Optional — defaults to 'provider' when not supplied.
42
+ */
43
+ getProviderName?: () => string;
44
+ /**
45
+ * Stall threshold in ms. Defaults to STALL_THRESHOLD_MS (30 000).
46
+ * Exposed for unit tests.
47
+ */
48
+ thresholdMs?: number;
49
+ }
50
+
51
+ export interface StreamStallWatchdog {
52
+ /** Tear down all subscriptions and cancel any pending timeout. */
53
+ dispose(): void;
54
+ }
55
+
56
+ /**
57
+ * Creates and starts a StreamStallWatchdog.
58
+ *
59
+ * Returns a dispose function; add it to the `unsubs` array alongside other
60
+ * event subscriptions so it is cleaned up on exit.
61
+ */
62
+ export function createStreamStallWatchdog(opts: StreamStallWatchdogOptions): StreamStallWatchdog {
63
+ const { events, onStall, getProviderName, thresholdMs = STALL_THRESHOLD_MS } = opts;
64
+
65
+ let stallTimer: ReturnType<typeof setTimeout> | null = null;
66
+ let hintFiredForTurn = false;
67
+
68
+ function arm(): void {
69
+ disarm();
70
+ hintFiredForTurn = false;
71
+ stallTimer = setTimeout(() => {
72
+ stallTimer = null;
73
+ if (!hintFiredForTurn) {
74
+ hintFiredForTurn = true;
75
+ const provider = getProviderName ? getProviderName() : 'provider';
76
+ onStall(provider);
77
+ }
78
+ }, thresholdMs);
79
+ }
80
+
81
+ function disarm(): void {
82
+ if (stallTimer !== null) {
83
+ clearTimeout(stallTimer);
84
+ stallTimer = null;
85
+ }
86
+ }
87
+
88
+ const unsubs: Array<() => void> = [
89
+ events.on('STREAM_START', arm),
90
+ events.on('STREAM_DELTA', disarm),
91
+ events.on('STREAM_END', disarm),
92
+ events.on('TURN_COMPLETED', disarm),
93
+ events.on('TURN_ERROR', disarm),
94
+ events.on('TURN_CANCEL', disarm),
95
+ ];
96
+
97
+ return {
98
+ dispose(): void {
99
+ disarm();
100
+ for (const unsub of unsubs) unsub();
101
+ },
102
+ };
103
+ }
@@ -81,6 +81,8 @@ export class SystemMessageRouter {
81
81
  *
82
82
  * @param message - Message text.
83
83
  * @param priority - 'high' | 'low'.
84
+ * @param kind - Classification kind ('system' | 'wrfc' | 'operational');
85
+ * used to resolve routing target and conversation navigability.
84
86
  */
85
87
  routeTypedSystemMessage(
86
88
  message: string,
@@ -93,7 +95,9 @@ export class SystemMessageRouter {
93
95
  this.panel?.push(message, priority);
94
96
  }
95
97
  if (delivery.toConversation) {
96
- this.conversation.addSystemMessage(message);
98
+ // addTypedSystemMessage threads the kind into the conversation so the
99
+ // renderer can use kind-based navigability instead of substring matching.
100
+ this.conversation.addTypedSystemMessage(message, kind);
97
101
  }
98
102
  }
99
103
 
@@ -0,0 +1,124 @@
1
+ import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
+ import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
+ import { maybeAutoCompact } from './context-auto-compact.ts';
4
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
+ import type { HookDispatcher, HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
7
+ import type { ConversationManager } from './conversation.ts';
8
+
9
+ /** Infer the options param of persistConversation to pick up SessionManager correctly. */
10
+ type PersistOptions = NonNullable<Parameters<typeof persistConversation>[5]>;
11
+
12
+ /** Minimal orchestrator surface required by turn-event wiring. */
13
+ interface TurnOrchestrator {
14
+ readonly lastInputTokens: number;
15
+ }
16
+
17
+ /** Minimal provider registry surface required by turn-event wiring. */
18
+ interface TurnProviderRegistry {
19
+ getCurrentModel(): { readonly contextWindow: number };
20
+ }
21
+
22
+ /** Minimal config manager surface required by turn-event wiring. */
23
+ interface TurnConfigManager {
24
+ get(key: string): unknown;
25
+ }
26
+
27
+ /** Minimal system message router surface required by turn-event wiring. */
28
+ interface TurnSystemMessageRouter {
29
+ high(message: string): void;
30
+ low(message: string): void;
31
+ routeSystemMessage(message: string, level: string): void;
32
+ }
33
+
34
+ export interface WireTurnEventHandlersOptions {
35
+ readonly events: UiRuntimeEvents;
36
+ readonly conversation: ConversationManager;
37
+ readonly runtime: { sessionId: string; model: string; provider: string };
38
+ readonly orchestrator: TurnOrchestrator;
39
+ readonly configManager: TurnConfigManager;
40
+ readonly providerRegistry: TurnProviderRegistry;
41
+ readonly systemMessageRouter: TurnSystemMessageRouter;
42
+ readonly hookDispatcher: HookDispatcher;
43
+ readonly workingDir: string;
44
+ readonly homeDirectory: string;
45
+ readonly sessionManager: PersistOptions['sessionManager'];
46
+ readonly gitStatusProvider: { refresh(): Promise<unknown> };
47
+ readonly lastGitInfoRef: { value: unknown };
48
+ readonly buildSessionContinuityHints: () => Record<string, unknown>;
49
+ readonly render: () => void;
50
+ }
51
+
52
+ export interface WireTurnEventHandlersResult {
53
+ /** Trigger a git status refresh; may be called from external code after tool execution. */
54
+ readonly refreshGit: () => void;
55
+ /** Unsubscribe functions to push into the parent unsubs array. */
56
+ readonly unsubs: ReadonlyArray<() => void>;
57
+ }
58
+
59
+ /**
60
+ * Wire TURN_COMPLETED, TOOL_SUCCEEDED, and TOOL_FAILED runtime events.
61
+ *
62
+ * Responsibilities:
63
+ * - Auto-save conversation to persistent store after each LLM turn
64
+ * - Fire the Lifecycle:session:save hook
65
+ * - Trigger auto-compact when context usage exceeds the configured threshold
66
+ * - Refresh git status after turns and tool results
67
+ *
68
+ * Returns refreshGit (callable externally) and unsubs (push into parent unsubs).
69
+ */
70
+ export function wireTurnEventHandlers(
71
+ options: WireTurnEventHandlersOptions,
72
+ ): WireTurnEventHandlersResult {
73
+ const {
74
+ events, conversation, runtime, orchestrator, configManager,
75
+ providerRegistry, systemMessageRouter, hookDispatcher,
76
+ workingDir, homeDirectory, sessionManager, gitStatusProvider,
77
+ lastGitInfoRef, buildSessionContinuityHints, render,
78
+ } = options;
79
+
80
+ const unsubs: Array<() => void> = [];
81
+
82
+ const refreshGit = (): void => {
83
+ gitStatusProvider.refresh().then((info) => { lastGitInfoRef.value = info; render(); }).catch(() => { /* non-fatal */ });
84
+ };
85
+
86
+ unsubs.push(events.turns.on('TURN_COMPLETED', () => {
87
+ // Auto-save after every LLM turn so kills don't lose the session
88
+ try {
89
+ const snapshot = conversation.toJSON() as { messages: Array<import('./conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
90
+ const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
91
+ persistConversation(
92
+ runtime.sessionId,
93
+ { ...snapshot, ...persisted },
94
+ runtime.model,
95
+ runtime.provider,
96
+ conversation.title || '',
97
+ { workingDirectory: workingDir, homeDirectory, sessionManager },
98
+ );
99
+ hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
100
+ } catch (e) { logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) }); }
101
+ // Auto-compact: check context usage and compact if threshold exceeded
102
+ const currentModelForCompact = providerRegistry.getCurrentModel();
103
+ maybeAutoCompact({
104
+ configManager: configManager as Parameters<typeof maybeAutoCompact>[0]['configManager'],
105
+ conversation,
106
+ providerRegistry: providerRegistry as Parameters<typeof maybeAutoCompact>[0]['providerRegistry'],
107
+ systemMessageRouter: systemMessageRouter as Parameters<typeof maybeAutoCompact>[0]['systemMessageRouter'],
108
+ model: runtime.model,
109
+ provider: runtime.provider,
110
+ lastInputTokens: orchestrator.lastInputTokens,
111
+ contextWindow: currentModelForCompact.contextWindow,
112
+ }).catch((err: unknown) => logger.debug('maybeAutoCompact error', { error: summarizeError(err) }));
113
+ refreshGit();
114
+ }));
115
+
116
+ unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
117
+ refreshGit();
118
+ }));
119
+ unsubs.push(events.tools.on('TOOL_FAILED', () => {
120
+ refreshGit();
121
+ }));
122
+
123
+ return { refreshGit, unsubs };
124
+ }
package/src/daemon/cli.ts CHANGED
@@ -104,6 +104,11 @@ async function main(): Promise<void> {
104
104
  console.error(renderGoodVibesDaemonHelp('goodvibes-daemon'));
105
105
  process.exit(2);
106
106
  }
107
+ if (cli.warnings.length > 0) {
108
+ for (const warning of cli.warnings) {
109
+ console.warn(`[goodvibes-daemon] warning: ${warning}`);
110
+ }
111
+ }
107
112
  if (cli.flags.help || cli.command === 'help') {
108
113
  console.log(renderGoodVibesDaemonHelp('goodvibes-daemon'));
109
114
  process.exit(0);
@@ -0,0 +1,71 @@
1
+ // ---------------------------------------------------------------------------
2
+ // cost-utils — canonical pricing source for token cost calculations (consumed by CostTrackerPanel and share-runtime)
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface ModelPricing {
6
+ input: number;
7
+ output: number;
8
+ }
9
+
10
+ const MODEL_PRICING: Record<string, ModelPricing> = {
11
+ // Free tier
12
+ 'openrouter/free': { input: 0, output: 0 },
13
+
14
+ // InceptionLabs
15
+ 'mercury-2': { input: 0.50, output: 1.50 },
16
+ 'mercury-edit': { input: 0.50, output: 1.50 },
17
+
18
+ // OpenAI
19
+ 'gpt-5.4': { input: 5, output: 15 },
20
+ 'gpt-5.3-chat-latest': { input: 3, output: 10 },
21
+ 'gpt-5-mini': { input: 0.15, output: 0.60 },
22
+ 'gpt-5-nano': { input: 0.05, output: 0.20 },
23
+ 'gpt-oss-120b': { input: 0, output: 0 },
24
+
25
+ // Anthropic
26
+ 'claude-opus-4-6': { input: 15, output: 75 },
27
+ 'claude-sonnet-4-6': { input: 3, output: 15 },
28
+ 'claude-haiku-4-5': { input: 0.80, output: 4 },
29
+
30
+ // Google
31
+ 'gemini-3.1-pro': { input: 1.25, output: 5 },
32
+ 'gemini-3-flash': { input: 0.075, output: 0.30 },
33
+ 'gemini-3.1-flash-lite': { input: 0.02, output: 0.10 },
34
+ 'gemini-2.5-pro': { input: 1.25, output: 5 },
35
+ };
36
+
37
+ /**
38
+ * getPricing — resolve USD-per-1M-token pricing for a model ID.
39
+ * Exact match first; then prefix/substring; falls back to zero.
40
+ */
41
+ export function getPricing(modelId: string): ModelPricing {
42
+ if (MODEL_PRICING[modelId]) return MODEL_PRICING[modelId]!;
43
+ if (modelId.endsWith(':free')) return { input: 0, output: 0 };
44
+ for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
45
+ if (modelId.startsWith(key) || modelId.includes(key)) return pricing;
46
+ }
47
+ return { input: 0, output: 0 };
48
+ }
49
+
50
+ /**
51
+ * calcSessionCost — compute total session cost in USD given raw token counters
52
+ * and the active model ID. Same formula used by CostTrackerPanel.onTurnComplete.
53
+ *
54
+ * inputTokens — cumulative input tokens
55
+ * outputTokens — cumulative output tokens
56
+ * cacheRead — cumulative cache-read tokens
57
+ * cacheWrite — cumulative cache-write tokens
58
+ * modelId — registry model identifier
59
+ */
60
+ export function calcSessionCost(
61
+ inputTokens: number,
62
+ outputTokens: number,
63
+ cacheRead: number,
64
+ cacheWrite: number,
65
+ modelId: string,
66
+ ): number {
67
+ const pricing = getPricing(modelId);
68
+ // Cache reads/writes count toward billable input side (same convention as panel)
69
+ const billableInput = inputTokens + cacheRead + cacheWrite;
70
+ return (billableInput * pricing.input + outputTokens * pricing.output) / 1_000_000;
71
+ }
@@ -0,0 +1,136 @@
1
+ // ---------------------------------------------------------------------------
2
+ // gist-uploader — upload export content to a GitHub Gist
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Architecture: UploadTarget interface with a single GistUploadTarget
6
+ // implementation. Future targets (HTTP PUT, Pastebin, etc.) implement
7
+ // UploadTarget without changing the caller in share-runtime.
8
+ //
9
+ // Token resolution:
10
+ // 1. serviceRegistry.resolveAuth('github') — standard service registry path
11
+ // (configured via .goodvibes/tui/services.json with tokenKey: GITHUB_TOKEN)
12
+ // 2. process.env.GITHUB_TOKEN fallback
13
+ // 3. No token → honest guidance; no upload.
14
+ //
15
+ // Privacy: Gist is created as secret=true (unlisted, not private — anyone with
16
+ // the URL can view it).
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type UploadResult =
20
+ | { ok: true; url: string }
21
+ | { ok: false; error: string };
22
+
23
+ /**
24
+ * UploadTarget — interface for pluggable export upload backends.
25
+ * Future HTTP PUT / other targets implement this.
26
+ */
27
+ export interface UploadTarget {
28
+ upload(content: string, filename: string): Promise<UploadResult>;
29
+ }
30
+
31
+ export interface GistUploaderOptions {
32
+ /**
33
+ * GitHub PAT with `gist` scope. If not provided the uploader will try
34
+ * process.env.GITHUB_TOKEN then return an error with guidance.
35
+ */
36
+ token?: string;
37
+ /** Description shown on the Gist page. Defaults to filename. */
38
+ description?: string;
39
+ }
40
+
41
+ /**
42
+ * resolveGithubToken — try auth header map then env var.
43
+ * Returns undefined when no token is available.
44
+ */
45
+ export function resolveGithubToken(
46
+ authHeaders: Record<string, string> | null | undefined,
47
+ ): string | undefined {
48
+ if (authHeaders) {
49
+ // Service registry returns { Authorization: 'Bearer <token>' } for bearer type
50
+ const authHeader = authHeaders['Authorization'] ?? authHeaders['authorization'];
51
+ if (authHeader) {
52
+ const match = /^Bearer (.+)$/.exec(authHeader);
53
+ if (match?.[1]) return match[1];
54
+ }
55
+ // Fallback: raw token value under any key that contains 'token'
56
+ for (const [key, val] of Object.entries(authHeaders)) {
57
+ if (key.toLowerCase().includes('token') && val) return val;
58
+ }
59
+ }
60
+ // Env var fallback
61
+ const envToken = process.env['GITHUB_TOKEN'];
62
+ return envToken || undefined;
63
+ }
64
+
65
+ /**
66
+ * GistUploadTarget — uploads content to a secret (unlisted) GitHub Gist.
67
+ */
68
+ export class GistUploadTarget implements UploadTarget {
69
+ private readonly token: string;
70
+ private readonly description: string;
71
+
72
+ constructor(token: string, description?: string) {
73
+ this.token = token;
74
+ this.description = description ?? 'GoodVibes session export';
75
+ }
76
+
77
+ async upload(content: string, filename: string): Promise<UploadResult> {
78
+ const body = JSON.stringify({
79
+ description: this.description,
80
+ public: false, // secret gist: unlisted, not private
81
+ files: {
82
+ [filename]: { content },
83
+ },
84
+ });
85
+
86
+ let response: Response;
87
+ try {
88
+ response = await fetch('https://api.github.com/gists', {
89
+ method: 'POST',
90
+ headers: {
91
+ 'Accept': 'application/vnd.github+json',
92
+ 'Authorization': `Bearer ${this.token}`,
93
+ 'Content-Type': 'application/json',
94
+ 'X-GitHub-Api-Version': '2022-11-28',
95
+ },
96
+ body,
97
+ });
98
+ } catch (fetchErr: unknown) {
99
+ const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
100
+ return { ok: false, error: `Network error: ${msg}` };
101
+ }
102
+
103
+ if (!response.ok) {
104
+ const text = await response.text().catch(() => '');
105
+ return {
106
+ ok: false,
107
+ error: `GitHub API error ${response.status}: ${text.slice(0, 200)}`,
108
+ };
109
+ }
110
+
111
+ let json: unknown;
112
+ try {
113
+ json = await response.json();
114
+ } catch {
115
+ return { ok: false, error: 'GitHub API returned non-JSON response' };
116
+ }
117
+
118
+ const gistUrl = (json as Record<string, unknown>)['html_url'];
119
+ if (typeof gistUrl !== 'string') {
120
+ return { ok: false, error: 'GitHub API response missing html_url field' };
121
+ }
122
+
123
+ return { ok: true, url: gistUrl };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * noTokenGuidance — message printed when no GitHub PAT is found.
129
+ */
130
+ export const NO_TOKEN_GUIDANCE = [
131
+ 'No GitHub token found for --upload.',
132
+ 'To enable Gist upload, configure a GitHub service entry:',
133
+ ' /services import .goodvibes/tui/services.json (if already configured)',
134
+ 'Or set the GITHUB_TOKEN environment variable to a PAT with the `gist` scope.',
135
+ 'Token is sent to api.github.com only. Gists are secret (unlisted, not private).',
136
+ ].join('\n');
@@ -118,8 +118,19 @@ export interface CommandShellUiOpeners {
118
118
  openMcpWorkspace?: () => void;
119
119
  openSecurityPanel?: () => void;
120
120
  openKnowledgePanel?: () => void;
121
+ openMemoryPanel?: () => void;
121
122
  openRemotePanel?: () => void;
122
123
  openSubscriptionPanel?: () => void;
124
+ /**
125
+ * Open the LocalAuthPanel in masked-password-entry mode for the given
126
+ * operation and username. The panel captures keystrokes into a private
127
+ * buffer; no plaintext password is ever stored in input history, transcript,
128
+ * logs, or recovery files.
129
+ */
130
+ openLocalAuthMaskedEntry?: (
131
+ kind: 'add-user' | 'rotate-password',
132
+ username: string,
133
+ ) => void;
123
134
  }
124
135
 
125
136
  export interface CommandSessionServices {
@@ -233,8 +244,28 @@ export class CommandRegistry {
233
244
  private commands = new Map<string, SlashCommand>();
234
245
  private aliasIndex = new Map<string, SlashCommand>();
235
246
 
236
- /** Register a command. Also indexes all aliases for O(1) lookup. */
247
+ /**
248
+ * Register a command. Also indexes all aliases for O(1) lookup.
249
+ * Throws if the primary name or any alias collides with an existing
250
+ * registration — silent last-write-wins previously shadowed whole
251
+ * commands, so collisions must fail fast at startup (and are caught
252
+ * statically by the alias lint test).
253
+ */
237
254
  register(command: SlashCommand): void {
255
+ const existingByName = this.commands.get(command.name) ?? this.aliasIndex.get(command.name);
256
+ if (existingByName) {
257
+ throw new Error(
258
+ `Command registration collision: "${command.name}" is already registered by /${existingByName.name}.`,
259
+ );
260
+ }
261
+ for (const alias of command.aliases ?? []) {
262
+ const holder = this.commands.get(alias) ?? this.aliasIndex.get(alias);
263
+ if (holder) {
264
+ throw new Error(
265
+ `Command alias collision: "${alias}" on /${command.name} is already registered by /${holder.name}.`,
266
+ );
267
+ }
268
+ }
238
269
  this.commands.set(command.name, command);
239
270
  for (const alias of command.aliases ?? []) {
240
271
  this.aliasIndex.set(alias, command);