@pellux/goodvibes-tui 0.20.3 → 0.21.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 (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -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 +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  39. package/src/input/commands/recall-review.ts +26 -2
  40. package/src/input/commands/services-runtime.ts +2 -2
  41. package/src/input/commands/session-workflow.ts +3 -3
  42. package/src/input/commands/share-runtime.ts +99 -12
  43. package/src/input/commands/tts-runtime.ts +30 -4
  44. package/src/input/commands.ts +2 -2
  45. package/src/input/delete-key-policy.ts +46 -0
  46. package/src/input/feed-context-factory.ts +2 -0
  47. package/src/input/handler-feed.ts +3 -0
  48. package/src/input/handler-interactions.ts +2 -15
  49. package/src/input/handler-modal-routes.ts +91 -12
  50. package/src/input/handler-modal-token-routes.ts +3 -0
  51. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  52. package/src/input/handler-onboarding.ts +55 -69
  53. package/src/input/handler-types.ts +163 -0
  54. package/src/input/handler.ts +5 -2
  55. package/src/input/input-history.ts +76 -6
  56. package/src/input/model-picker-filter.ts +265 -0
  57. package/src/input/model-picker-items.ts +208 -0
  58. package/src/input/model-picker.ts +92 -325
  59. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  60. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  61. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  62. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  63. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  64. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  65. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  66. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  67. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  68. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  69. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  70. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  71. package/src/input/settings-modal-data.ts +304 -0
  72. package/src/input/settings-modal-mutations.ts +154 -0
  73. package/src/input/settings-modal.ts +182 -220
  74. package/src/main.ts +57 -57
  75. package/src/panels/builtin/agent.ts +4 -1
  76. package/src/panels/builtin/development.ts +4 -1
  77. package/src/panels/confirm-state.ts +27 -12
  78. package/src/panels/cost-tracker-panel.ts +23 -67
  79. package/src/panels/eval-panel.ts +10 -9
  80. package/src/panels/knowledge-panel.ts +3 -5
  81. package/src/panels/local-auth-panel.ts +124 -4
  82. package/src/panels/project-planning-panel.ts +42 -4
  83. package/src/panels/search-focus.ts +11 -5
  84. package/src/panels/subscription-panel.ts +33 -25
  85. package/src/panels/types.ts +28 -1
  86. package/src/panels/wrfc-panel.ts +224 -41
  87. package/src/renderer/agent-detail-modal.ts +11 -10
  88. package/src/renderer/code-block.ts +10 -2
  89. package/src/renderer/compositor.ts +18 -4
  90. package/src/renderer/context-inspector.ts +1 -5
  91. package/src/renderer/diff.ts +94 -21
  92. package/src/renderer/markdown.ts +29 -13
  93. package/src/renderer/settings-modal-helpers.ts +1 -1
  94. package/src/renderer/settings-modal.ts +77 -8
  95. package/src/renderer/syntax-highlighter.ts +10 -3
  96. package/src/renderer/term-caps.ts +318 -0
  97. package/src/renderer/theme.ts +158 -0
  98. package/src/renderer/tool-call.ts +12 -2
  99. package/src/renderer/ui-factory.ts +50 -6
  100. package/src/runtime/bootstrap-command-context.ts +1 -0
  101. package/src/runtime/bootstrap-command-parts.ts +14 -0
  102. package/src/runtime/bootstrap-core.ts +121 -13
  103. package/src/runtime/bootstrap.ts +2 -0
  104. package/src/runtime/onboarding/apply.ts +4 -6
  105. package/src/runtime/onboarding/index.ts +1 -0
  106. package/src/runtime/onboarding/markers.ts +42 -49
  107. package/src/runtime/onboarding/progress.ts +148 -0
  108. package/src/runtime/onboarding/state.ts +133 -55
  109. package/src/runtime/onboarding/types.ts +20 -0
  110. package/src/runtime/services.ts +21 -0
  111. package/src/runtime/wrfc-persistence.ts +237 -0
  112. package/src/shell/blocking-input.ts +20 -5
  113. package/src/tools/wrfc-agent-guard.ts +64 -3
  114. package/src/utils/format-elapsed.ts +30 -0
  115. package/src/utils/terminal-width.ts +45 -0
  116. package/src/version.ts +1 -1
  117. package/src/work-plans/work-plan-store.ts +4 -6
  118. 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,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');
@@ -120,6 +120,16 @@ export interface CommandShellUiOpeners {
120
120
  openKnowledgePanel?: () => void;
121
121
  openRemotePanel?: () => void;
122
122
  openSubscriptionPanel?: () => void;
123
+ /**
124
+ * Open the LocalAuthPanel in masked-password-entry mode for the given
125
+ * operation and username. The panel captures keystrokes into a private
126
+ * buffer; no plaintext password is ever stored in input history, transcript,
127
+ * logs, or recovery files.
128
+ */
129
+ openLocalAuthMaskedEntry?: (
130
+ kind: 'add-user' | 'rotate-password',
131
+ username: string,
132
+ ) => void;
123
133
  }
124
134
 
125
135
  export interface CommandSessionServices {
@@ -233,8 +243,28 @@ export class CommandRegistry {
233
243
  private commands = new Map<string, SlashCommand>();
234
244
  private aliasIndex = new Map<string, SlashCommand>();
235
245
 
236
- /** Register a command. Also indexes all aliases for O(1) lookup. */
246
+ /**
247
+ * Register a command. Also indexes all aliases for O(1) lookup.
248
+ * Throws if the primary name or any alias collides with an existing
249
+ * registration — silent last-write-wins previously shadowed whole
250
+ * commands, so collisions must fail fast at startup (and are caught
251
+ * statically by the alias lint test).
252
+ */
237
253
  register(command: SlashCommand): void {
254
+ const existingByName = this.commands.get(command.name) ?? this.aliasIndex.get(command.name);
255
+ if (existingByName) {
256
+ throw new Error(
257
+ `Command registration collision: "${command.name}" is already registered by /${existingByName.name}.`,
258
+ );
259
+ }
260
+ for (const alias of command.aliases ?? []) {
261
+ const holder = this.commands.get(alias) ?? this.aliasIndex.get(alias);
262
+ if (holder) {
263
+ throw new Error(
264
+ `Command alias collision: "${alias}" on /${command.name} is already registered by /${holder.name}.`,
265
+ );
266
+ }
267
+ }
238
268
  this.commands.set(command.name, command);
239
269
  for (const alias of command.aliases ?? []) {
240
270
  this.aliasIndex.set(alias, command);
@@ -196,9 +196,9 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
196
196
  });
197
197
 
198
198
  registry.register({
199
- name: 'knowledge',
200
- aliases: ['know'],
201
- description: 'Inspect durable project knowledge, risks, runbooks, and architecture notes',
199
+ name: 'project-memory',
200
+ aliases: ['pmem'],
201
+ description: 'Inspect durable project memory: risks, runbooks, and architecture notes',
202
202
  usage: '[open | queue [limit] | explain <task...> [--scope <path> ...]]',
203
203
  handler(args, ctx) {
204
204
  const subcommand = (args[0] ?? 'open').toLowerCase();
@@ -237,7 +237,7 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
237
237
  });
238
238
  const task = taskTokens.join(' ').trim();
239
239
  if (!task) {
240
- ctx.print('Usage: /knowledge explain <task...> [--scope <path> ...]');
240
+ ctx.print('Usage: /project-memory explain <task...> [--scope <path> ...]');
241
241
  return;
242
242
  }
243
243
  const injections = selectKnowledgeForTask(memory, task, scopeValues);
@@ -249,7 +249,7 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
249
249
  ctx.openKnowledgePanel();
250
250
  return;
251
251
  }
252
- ctx.print(`Unknown knowledge subcommand: ${subcommand}`);
252
+ ctx.print(`Unknown project-memory subcommand: ${subcommand}`);
253
253
  },
254
254
  });
255
255
  }
@@ -222,7 +222,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
222
222
 
223
223
  registry.register({
224
224
  name: 'voice',
225
- description: 'Review voice posture and package portable voice-surface metadata',
225
+ description: 'Review or toggle always-speak mode (same switch as /tts on|off) and package portable voice metadata',
226
226
  usage: '[review|enable|disable|bundle export <path>|bundle inspect <path>]',
227
227
  handler(args, ctx) {
228
228
  const shellPaths = requireShellPaths(ctx);
@@ -231,8 +231,9 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
231
231
  const enabled = Boolean(ctx.platform.configManager.get('ui.voiceEnabled') ?? false);
232
232
  ctx.print([
233
233
  'Voice Review',
234
- ` enabled: ${enabled ? 'yes' : 'no'}`,
235
- ' posture: optional local companion surface; disabled by default',
234
+ ` always-speak: ${enabled ? 'on' : 'off'}`,
235
+ ' config key: ui.voiceEnabled (same as /tts on|off)',
236
+ ' posture: optional local TTS output; disabled by default',
236
237
  ' note: voice remains an optional operator convenience, not a required SaaS dependency',
237
238
  ].join('\n'));
238
239
  return;
@@ -240,7 +241,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
240
241
  if (sub === 'enable' || sub === 'disable') {
241
242
  const next = sub === 'enable';
242
243
  ctx.platform.configManager.setDynamic('ui.voiceEnabled', next);
243
- ctx.print(`Voice surface ${next ? 'enabled' : 'disabled'} for this runtime.`);
244
+ ctx.print(`Always-speak mode ${next ? 'enabled' : 'disabled'}. ${next ? 'Every submitted turn will be played through live TTS.' : 'Use /tts <prompt> to speak individual turns.'}`);
244
245
  return;
245
246
  }
246
247
  if (sub === 'bundle') {
@@ -131,7 +131,7 @@ function renderKnowledgeAskResult(result: KnowledgeAskResult): string {
131
131
 
132
132
  export const knowledgeCommand: SlashCommand = {
133
133
  name: 'knowledge',
134
- aliases: ['know', 'kb'],
134
+ aliases: ['know'],
135
135
  description: 'Structured knowledge graph: ingest URLs/bookmarks, inspect issues, and build compact prompt packets.',
136
136
  usage: '<subcommand> [args]',
137
137
  argsHint: 'status|ask|ingest-url|import-bookmarks|import-urls|list|search|get|queue|review-issue|candidates|reports|schedules|lint|packet|explain|reindex|consolidate',
@@ -17,11 +17,22 @@ export function handleLocalAuthCommand(args: string[], ctx: CommandContext): voi
17
17
  if (sub === 'add-user') {
18
18
  const username = args[1];
19
19
  const password = args[2];
20
- const roles = args[3]?.split(',').map((value) => value.trim()).filter(Boolean) ?? ['admin'];
21
- if (!username || !password) {
22
- ctx.print('Usage: /auth local add-user <username> <password> [roles]');
20
+ if (!username) {
21
+ ctx.print('Usage: /auth local add-user <username> <password> [roles]\nTip: invoke without a password to use the masked panel: /auth local add-user <username>');
22
+ return;
23
+ }
24
+ if (!password) {
25
+ // No password supplied — open masked-entry mode on the LocalAuthPanel.
26
+ if (ctx.openLocalAuthMaskedEntry) {
27
+ ctx.openLocalAuthMaskedEntry('add-user', username);
28
+ } else {
29
+ ctx.print('Masked entry unavailable in this context. Use: /auth local add-user <username> <password>');
30
+ }
23
31
  return;
24
32
  }
33
+ // Password supplied as argv: warn that the history entry has been scrubbed.
34
+ ctx.print('Warning: passwords passed as command arguments are scrubbed from history, but may appear in shell scrollback. The masked entry is preferred: /auth local add-user <username>');
35
+ const roles = args[3]?.split(',').map((value) => value.trim()).filter(Boolean) ?? ['admin'];
25
36
  try {
26
37
  const added = auth.addUser(username, password, roles);
27
38
  ctx.print(`Added local auth user ${added.username} (${formatRoles(added.roles)}).`);
@@ -49,10 +60,21 @@ export function handleLocalAuthCommand(args: string[], ctx: CommandContext): voi
49
60
  if (sub === 'rotate-password') {
50
61
  const username = args[1];
51
62
  const password = args[2];
52
- if (!username || !password) {
53
- ctx.print('Usage: /auth local rotate-password <username> <password>');
63
+ if (!username) {
64
+ ctx.print('Usage: /auth local rotate-password <username> <password>\nTip: invoke without a password to use the masked panel: /auth local rotate-password <username>');
65
+ return;
66
+ }
67
+ if (!password) {
68
+ // No password supplied — open masked-entry mode on the LocalAuthPanel.
69
+ if (ctx.openLocalAuthMaskedEntry) {
70
+ ctx.openLocalAuthMaskedEntry('rotate-password', username);
71
+ } else {
72
+ ctx.print('Masked entry unavailable in this context. Use: /auth local rotate-password <username> <password>');
73
+ }
54
74
  return;
55
75
  }
76
+ // Password supplied as argv: warn that the history entry has been scrubbed.
77
+ ctx.print('Warning: passwords passed as command arguments are scrubbed from history, but may appear in shell scrollback. The masked entry is preferred: /auth local rotate-password <username>');
56
78
  try {
57
79
  auth.rotatePassword(username, password);
58
80
  ctx.print(`Rotated password for ${username}. Existing sessions were revoked.`);
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { atomicWriteFileSync } from '../../config/atomic-write.ts';
3
4
  import type { CommandRegistry } from '../command-registry.ts';
4
5
  import type { ConfigKey } from '../../config/index.ts';
5
6
  import { CONFIG_SCHEMA } from '../../config/index.ts';
@@ -204,18 +205,15 @@ export function registerLocalSetupCommands(registry: CommandRegistry): void {
204
205
  }
205
206
  if (bundle.services) {
206
207
  const servicesPath = getShellPaths().resolveProjectPath('tui', 'services.json');
207
- mkdirSync(dirname(servicesPath), { recursive: true });
208
- writeFileSync(servicesPath, JSON.stringify(bundle.services, null, 2) + '\n', 'utf-8');
208
+ atomicWriteFileSync(servicesPath, JSON.stringify(bundle.services, null, 2) + '\n', { mkdirp: true });
209
209
  }
210
210
  if (bundle.ecosystem?.plugins) {
211
211
  const pluginsPath = getShellPaths().resolveProjectPath('tui', 'ecosystem', 'plugins.json');
212
- mkdirSync(dirname(pluginsPath), { recursive: true });
213
- writeFileSync(pluginsPath, JSON.stringify(bundle.ecosystem.plugins, null, 2) + '\n', 'utf-8');
212
+ atomicWriteFileSync(pluginsPath, JSON.stringify(bundle.ecosystem.plugins, null, 2) + '\n', { mkdirp: true });
214
213
  }
215
214
  if (bundle.ecosystem?.skills) {
216
215
  const skillsPath = getShellPaths().resolveProjectPath('tui', 'ecosystem', 'skills.json');
217
- mkdirSync(dirname(skillsPath), { recursive: true });
218
- writeFileSync(skillsPath, JSON.stringify(bundle.ecosystem.skills, null, 2) + '\n', 'utf-8');
216
+ atomicWriteFileSync(skillsPath, JSON.stringify(bundle.ecosystem.skills, null, 2) + '\n', { mkdirp: true });
219
217
  }
220
218
  ctx.print(`Imported setup transfer bundle from ${targetPath}`);
221
219
  } catch (error) {