@pellux/goodvibes-tui 1.13.1 → 1.14.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 (158) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +33 -33
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
@@ -1,5 +1,7 @@
1
- import type { UiRuntimeEvents } from '@/runtime/index.ts';
1
+ import type { UiRuntimeEvents, RuntimeEventBus } from '@/runtime/index.ts';
2
2
  import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
+ import { buildCompactionReceiptBlock } from './compaction-receipt.ts';
4
+ import { persistTurnAnchors, recordTurnAnchor, summarizeTurnLabel } from './rewind-turn-anchors.ts';
3
5
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
4
6
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
5
7
  import type { HookDispatcher, HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
@@ -32,6 +34,10 @@ interface TurnProviderRegistry {
32
34
  /** Minimal config manager surface required by turn-event wiring. */
33
35
  interface TurnConfigManager {
34
36
  get(key: string): unknown;
37
+ /** Subscribe to a config-key change; returns an unsubscribe. Used to keep the
38
+ * footer permission-mode pill live without polling. Optional so bare test
39
+ * doubles that only implement get() still satisfy the type. */
40
+ subscribe?(key: string, cb: (...args: unknown[]) => void): () => void;
35
41
  }
36
42
 
37
43
  /** Minimal system message router surface required by turn-event wiring. */
@@ -43,6 +49,13 @@ interface TurnSystemMessageRouter {
43
49
 
44
50
  export interface WireTurnEventHandlersOptions {
45
51
  readonly events: UiRuntimeEvents;
52
+ /**
53
+ * Raw runtime event bus. Needed for domains NOT surfaced by the grouped
54
+ * UiRuntimeEvents feed — specifically the 'compaction' domain, whose
55
+ * mandatory COMPACTION_RECEIPT the transcript renders as a distinct block.
56
+ * Optional so headless/test callers can omit it.
57
+ */
58
+ readonly runtimeBus?: RuntimeEventBus | null;
46
59
  readonly conversation: ConversationManager;
47
60
  readonly runtime: { sessionId: string; model: string; provider: string };
48
61
  readonly orchestrator: TurnOrchestrator;
@@ -74,6 +87,14 @@ export interface WireTurnEventHandlersOptions {
74
87
  * to fall back to).
75
88
  */
76
89
  readonly focusTracker?: Pick<FocusTracker, 'shouldAlertWhenUnfocused'> | null;
90
+ /**
91
+ * Optional in-terminal (OSC 9) notifier. When present: a turn finishing emits
92
+ * a 'turn-end' notification, and a delegated agent blocking on human input
93
+ * (AGENT_AWAITING_MESSAGE) emits an 'agent-blocked' notification. Each is
94
+ * gated independently by the notifier's own per-signal config + focus rule.
95
+ * Absent in tests/headless.
96
+ */
97
+ readonly terminalNotifier?: import('./terminal-notifier.ts').TerminalNotifier | null;
77
98
  /**
78
99
  * Minimal test seam: injectable clock for controlling Date.now() in tests.
79
100
  * Defaults to the real Date.now when absent.
@@ -114,6 +135,7 @@ export function wireTurnEventHandlers(
114
135
  events, conversation, runtime, configManager, hookDispatcher, orchestrator, providerRegistry,
115
136
  workingDir, homeDirectory, sessionManager, gitStatusProvider,
116
137
  lastGitInfoRef, buildSessionContinuityHints, render, webhookNotifier, focusTracker,
138
+ terminalNotifier, runtimeBus, systemMessageRouter,
117
139
  _clock = Date.now,
118
140
  } = options;
119
141
 
@@ -158,6 +180,9 @@ export function wireTurnEventHandlers(
158
180
  const notifyThreshold = readNotifyAfterSeconds((k) => configManager.get(k as Parameters<typeof configManager.get>[0]));
159
181
  // stopReason 'empty_response' signals a non-successful completion.
160
182
  const taskStatus: LongTaskStatus = evt.stopReason === 'completed' ? 'ok' : 'fail';
183
+ // In-terminal (OSC 9) turn-end notification — fires on its own per-signal
184
+ // config + focus gate, independent of the long-task duration threshold below.
185
+ terminalNotifier?.notify('turn-end', taskStatus === 'ok' ? 'Turn finished' : 'Turn finished with errors');
161
186
  maybeNotifyLongTask({
162
187
  elapsedMs: turnElapsedMs,
163
188
  status: taskStatus,
@@ -198,6 +223,22 @@ export function wireTurnEventHandlers(
198
223
  } catch { /* best-effort */ }
199
224
  logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) });
200
225
  }
226
+ // Record this turn's rewind anchor: pair the turnId (shared with the
227
+ // workspace checkpoint the turn engine snapshots for this same turn) with
228
+ // the live conversation message count, so a later message-anchored /rewind
229
+ // can truncate the conversation to exactly this boundary — the join key
230
+ // between conversation and files rewind (see core/rewind-turn-anchors.ts).
231
+ try {
232
+ recordTurnAnchor(runtime.sessionId, {
233
+ turnId: evt.turnId,
234
+ label: summarizeTurnLabel(conversation.getLastUserMessage()),
235
+ messageCount: conversation.getMessageCount(),
236
+ at: _clock(),
237
+ });
238
+ // Mirror the anchors to the session's sidecar so message-anchored /rewind
239
+ // survives a resume (the in-memory registry alone is process-local).
240
+ persistTurnAnchors(runtime.sessionId, workingDir);
241
+ } catch { /* best-effort; a rewind-anchor miss must never break the turn */ }
201
242
  // Auto-compaction is owned by the SDK Orchestrator's post-turn maintenance
202
243
  // (handlePostTurnContextMaintenance), which runs on every turn against the
203
244
  // resolved context window (getContextWindowForModel) via the SDK's
@@ -206,6 +247,16 @@ export function wireTurnEventHandlers(
206
247
  refreshGit();
207
248
  }));
208
249
 
250
+ // In-terminal (OSC 9) agent-blocked notification: a delegated agent parked
251
+ // waiting for a human message (AGENT_AWAITING_MESSAGE) is "blocked on you".
252
+ // Gated by the notifier's own per-signal config + focus rule. PRIVACY: the
253
+ // short agent id only — never task text.
254
+ if (terminalNotifier) {
255
+ unsubs.push(events.agents.on('AGENT_AWAITING_MESSAGE', (payload) => {
256
+ terminalNotifier.notify('agent-blocked', `agent ${payload.agentId.slice(0, 8)} is waiting for your input`);
257
+ }));
258
+ }
259
+
209
260
  unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
210
261
  refreshGit();
211
262
  }));
@@ -249,5 +300,33 @@ export function wireTurnEventHandlers(
249
300
  }));
250
301
  }
251
302
 
303
+ // Live footer permission-mode pill: re-render whenever the SDK config
304
+ // surface reports permissions.mode changed. Mode changes are made through
305
+ // configManager.set('permissions.mode', ...) (the SDK-owned surface the
306
+ // PermissionManager reads), so this covers our own Shift+Tab / /plan toggles
307
+ // as well as any other in-process writer. No polling.
308
+ if (typeof configManager.subscribe === 'function') {
309
+ unsubs.push(configManager.subscribe('permissions.mode', () => { render(); }));
310
+ }
311
+
312
+ // Post-compaction receipt (never-silent): the SDK emits a mandatory
313
+ // COMPACTION_RECEIPT on the 'compaction' domain after every automatic (and
314
+ // the manual) compaction. That domain is NOT in the grouped UiRuntimeEvents
315
+ // feed, so we subscribe on the raw bus and render the receipt as a distinct
316
+ // [Compaction] block. Automatic compaction previously left no transcript
317
+ // trace; this makes the audit visible.
318
+ if (runtimeBus) {
319
+ unsubs.push(runtimeBus.on('COMPACTION_RECEIPT', (envelope) => {
320
+ try {
321
+ const payload = envelope.payload;
322
+ if (payload.type !== 'COMPACTION_RECEIPT') return;
323
+ systemMessageRouter.high(buildCompactionReceiptBlock(payload));
324
+ render();
325
+ } catch (err) {
326
+ logger.debug('turn-event-wiring: compaction-receipt render error', { error: String(err) });
327
+ }
328
+ }));
329
+ }
330
+
252
331
  return { refreshGit, unsubs, transcriptJournal };
253
332
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * command-reference.ts — pure renderer for the generated slash-command
3
+ * reference (docs/commands-reference.md).
4
+ *
5
+ * The reference is generated from the live command registry (via
6
+ * {@link categorizeBuiltinCommands} in ./commands.ts), never hand-maintained.
7
+ * This module is deliberately side-effect free so both the generator
8
+ * (scripts/project-surfaces.ts) and the drift-check test import the same
9
+ * rendering logic — the committed file and a fresh render must be byte-identical
10
+ * or the gate fails.
11
+ */
12
+ import type { SlashCommand } from './command-registry.ts';
13
+
14
+ export interface CategorizedCommand {
15
+ readonly command: SlashCommand;
16
+ readonly category: string;
17
+ }
18
+
19
+ /** Escape the pipe and backslash characters that would break a markdown table cell. */
20
+ function escapeCell(text: string): string {
21
+ return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
22
+ }
23
+
24
+ /** Stable anchor slug for a category heading (GitHub-style). */
25
+ function slug(text: string): string {
26
+ return text
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9\s-]/g, '')
29
+ .trim()
30
+ .replace(/\s+/g, '-');
31
+ }
32
+
33
+ /**
34
+ * renderCommandReferenceMarkdown — deterministic markdown for the full command
35
+ * set. Categories are sorted alphabetically and commands alphabetically by
36
+ * primary name within each category, so the output depends only on the command
37
+ * set and their metadata — not on registration order — which keeps the drift
38
+ * check meaningful (a genuinely new/renamed command moves the bytes; a pure
39
+ * reordering does not).
40
+ */
41
+ export function renderCommandReferenceMarkdown(entries: readonly CategorizedCommand[]): string {
42
+ const byCategory = new Map<string, SlashCommand[]>();
43
+ for (const { command, category } of entries) {
44
+ const list = byCategory.get(category) ?? [];
45
+ list.push(command);
46
+ byCategory.set(category, list);
47
+ }
48
+
49
+ const categories = Array.from(byCategory.keys()).sort((a, b) => a.localeCompare(b));
50
+ const total = entries.length;
51
+
52
+ const lines: string[] = [];
53
+ lines.push('<!-- GENERATED FILE — DO NOT EDIT BY HAND.');
54
+ lines.push(' Regenerate with `bun run docs:commands`.');
55
+ lines.push(' Source of truth: the slash-command registry (src/input/commands.ts).');
56
+ lines.push(' A drift check (src/test/release-gates/command-reference-gate.test.ts)');
57
+ lines.push(' fails CI if this file is stale. -->');
58
+ lines.push('');
59
+ lines.push('# Command Reference');
60
+ lines.push('');
61
+ lines.push(
62
+ `GoodVibes ships **${total}** built-in slash commands across **${categories.length}** categories. ` +
63
+ 'Every command below is generated directly from the command registry, so this list is always ' +
64
+ 'complete and current. Type a command in the composer prefixed with `/`, or press `Ctrl+K` (or ' +
65
+ 'run `/palette`) to search them all in the command palette.',
66
+ );
67
+ lines.push('');
68
+ lines.push('## Categories');
69
+ lines.push('');
70
+ for (const category of categories) {
71
+ const count = byCategory.get(category)?.length ?? 0;
72
+ lines.push(`- [${escapeCell(category)}](#${slug(category)}) — ${count}`);
73
+ }
74
+ lines.push('');
75
+
76
+ for (const category of categories) {
77
+ const commands = (byCategory.get(category) ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
78
+ lines.push(`## ${category}`);
79
+ lines.push('');
80
+ lines.push('| Command | Aliases | Usage | Description |');
81
+ lines.push('| --- | --- | --- | --- |');
82
+ for (const cmd of commands) {
83
+ const name = `\`/${escapeCell(cmd.name)}\``;
84
+ const aliases = (cmd.aliases ?? []).length > 0
85
+ ? (cmd.aliases ?? []).map((a) => `\`/${escapeCell(a)}\``).join(', ')
86
+ : '—';
87
+ const usageText = cmd.usage ?? cmd.argsHint ?? '';
88
+ const usage = usageText ? `\`${escapeCell(usageText)}\`` : '—';
89
+ const description = cmd.description ? escapeCell(cmd.description) : '—';
90
+ lines.push(`| ${name} | ${aliases} | ${usage} | ${description} |`);
91
+ }
92
+ lines.push('');
93
+ }
94
+
95
+ return `${lines.join('\n').trimEnd()}\n`;
96
+ }
@@ -8,6 +8,7 @@ import type { PermissionRequestHandler } from '@pellux/goodvibes-sdk/platform/pe
8
8
  import type { SelectionItem, SelectionResult, SelectionAction } from './selection-modal.ts';
9
9
  import type { FileUndoManager } from '@pellux/goodvibes-sdk/platform/state';
10
10
  import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
11
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
11
12
  import type { PanelManager, PanelDeepLinkTarget } from '../panels/panel-manager.ts';
12
13
  import type { KeybindingsManager } from './keybindings.ts';
13
14
  import type { OnboardingWizardMode } from './onboarding/onboarding-wizard.ts';
@@ -18,10 +19,12 @@ import type { HookApi } from '@pellux/goodvibes-sdk/platform/hooks';
18
19
  import type { McpApi } from '@pellux/goodvibes-sdk/platform/mcp';
19
20
  import type { ProviderApi } from '@pellux/goodvibes-sdk/platform/providers';
20
21
  import type { OpsApi } from '@/runtime/index.ts';
22
+ import type { FeatureFlagManager } from '@/runtime/index.ts';
21
23
  import type { OperatorClient } from '@/runtime/index.ts';
22
24
  import type { PeerClient } from '@/runtime/index.ts';
23
25
  import type { DirectTransport } from '@/runtime/index.ts';
24
26
  import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
27
+ import type { UiPlatformServices } from '../runtime/ui-services.ts';
25
28
  import type {
26
29
  CommandWorkspaceShellServices,
27
30
  } from '@/runtime/index.ts';
@@ -67,6 +70,8 @@ export interface CommandUiActions {
67
70
  renderRequest: () => void;
68
71
  print: (text: string) => void;
69
72
  exit: () => void;
73
+ /** Open the current composer draft in the user's $EDITOR and load back the edited text. */
74
+ openComposerEditor?: () => void;
70
75
  submitInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers').ContentPart[]) => void;
71
76
  submitSpokenInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers').ContentPart[]) => void;
72
77
  stopSpokenOutput?: () => void;
@@ -96,6 +101,12 @@ export interface CommandUiActions {
96
101
  */
97
102
  completeEmbeddingProviderSelection?: (providerId: string) => void;
98
103
  clearScreen?: () => void;
104
+ /**
105
+ * Begin one line of concealed (masked) composer input for a password-like
106
+ * secret. The typed text is masked in the composer and kept out of input
107
+ * history and the transcript; the plaintext reaches only request.onSubmit.
108
+ */
109
+ beginConcealedInput?: (request: import('./concealed-input.ts').ConcealedInputRequest) => void;
99
110
  activatePlan?: (planId: string, task: string) => void;
100
111
  requestPermission?: PermissionRequestHandler;
101
112
  /**
@@ -139,6 +150,12 @@ export interface CommandShellUiOpeners {
139
150
  openShortcutsOverlay?: () => void;
140
151
  getScrollTop?: () => number;
141
152
  openPanelPicker?: () => void;
153
+ /**
154
+ * Open the fuzzy command palette — a searchable picker over every registered
155
+ * slash command (generated live from the registry). Bound to Ctrl+K and the
156
+ * /palette command. Selecting a command pre-fills it into the composer.
157
+ */
158
+ openCommandPalette?: () => void;
142
159
  /**
143
160
  * Open (and optionally focus) a panel. UX-C focus rule: the command path is
144
161
  * "the user is mid-command-flow" — opening a panel this way leaves keyboard
@@ -226,6 +243,10 @@ export interface CommandWorkspaceUiServices {
226
243
  keybindingsManager?: KeybindingsManager;
227
244
  fileUndoManager?: FileUndoManager;
228
245
  workspaceCheckpointManager?: WorkspaceCheckpointManager;
246
+ /** In-process gateway catalog — the /review hunk-revert flow invokes checkpoints.revertHunk(Preview) over it. */
247
+ gatewayMethods?: GatewayMethodCatalog;
248
+ workspaceTrustManager?: import('../runtime/trust/workspace-trust.ts').WorkspaceTrustManager;
249
+ workspaceRegistrationManager?: import('../runtime/trust/workspace-registration.ts').WorkspaceRegistrationManager;
229
250
  panelManager?: PanelManager;
230
251
  profileManager?: import('@pellux/goodvibes-sdk/platform/profiles').ProfileManager;
231
252
  bookmarkManager?: import('@pellux/goodvibes-sdk/platform/bookmarks').BookmarkManager;
@@ -241,6 +262,8 @@ export interface CommandWorkspaceServices
241
262
  export interface CommandPlatformConfigServices {
242
263
  readonly config: DeepReadonly<GoodVibesConfig>;
243
264
  readonly configManager: ConfigManager;
265
+ /** Feature-flag runtime — backs the `/flags` command's listing and runtime toggling. */
266
+ readonly featureFlagManager?: FeatureFlagManager;
244
267
  readonly voiceProviderRegistry?: VoiceProviderRegistry;
245
268
  readonly voiceService?: VoiceService;
246
269
  /** Direct-command consumer (`/search`) alongside the existing agent-tool consumer. */
@@ -248,6 +271,8 @@ export interface CommandPlatformConfigServices {
248
271
  /** Direct-command consumer (`/image`) — first production caller of `.generate()`. */
249
272
  readonly mediaProviders?: import('@pellux/goodvibes-sdk/platform/media').MediaProviderRegistry;
250
273
  readonly artifactStore?: import('@pellux/goodvibes-sdk/platform/artifacts').ArtifactStore;
274
+ /** Background daemon/HTTP-listener/relay controller. Mutable: main.ts patches it in post-bootstrap (see bootstrap-command-parts.ts ordering note). */
275
+ externalServices?: UiPlatformServices['externalServices'];
251
276
  }
252
277
 
253
278
  export interface CommandPlatformServices
@@ -0,0 +1,245 @@
1
+ /**
2
+ * channel-pairing.ts — guided `/channel pair` flow.
3
+ *
4
+ * Adapters and their credential requirements are driven from the SDK's
5
+ * getBuiltinSetupSchema — the same setup-schema source every GoodVibes
6
+ * consumer (TUI, webui, agent) reads, not a hand-maintained list here.
7
+ * Secret (masked) credentials are stored the same secure way onboarding
8
+ * stores them, via persistSecretBackedConfigValue (secret manager + a
9
+ * config reference), so the plaintext never lands in config or the
10
+ * transcript.
11
+ *
12
+ * /channel pair → list adapters + how many credentials are set
13
+ * /channel pair <surface> → show the adapter's declared credentials and
14
+ * prompt (masked) for each missing secret
15
+ * /channel pair <surface> verify → a REAL round-trip: send a test message
16
+ * through the daemon (channels.test.send)
17
+ * and report the actual delivered/error
18
+ * outcome, never a fabricated success.
19
+ */
20
+ import { getBuiltinSetupSchema } from '@pellux/goodvibes-sdk/platform/channels';
21
+ import type { ChannelSetupFieldDescriptor, ChannelSetupSchema, ChannelSurface } from '@pellux/goodvibes-sdk/platform/channels';
22
+ import type { CommandContext } from '../command-registry.ts';
23
+ import type { ConfigManager, ConfigKey } from '../../config/index.ts';
24
+ import { persistSecretBackedConfigValue } from '../../config/secret-config.ts';
25
+ import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
26
+
27
+ /**
28
+ * Surfaces pairable through /channel pair — every ChannelSurface except the
29
+ * two internal render surfaces ('tui', 'web'), which are not externally
30
+ * paired channels. The `satisfies Record<...>` shape makes this exhaustive:
31
+ * a new ChannelSurface value that isn't listed here fails to compile.
32
+ */
33
+ type PairableSurface = Exclude<ChannelSurface, 'tui' | 'web'>;
34
+ const PAIRABLE_SURFACE_SET = {
35
+ slack: true,
36
+ discord: true,
37
+ ntfy: true,
38
+ webhook: true,
39
+ homeassistant: true,
40
+ telegram: true,
41
+ 'google-chat': true,
42
+ signal: true,
43
+ whatsapp: true,
44
+ telephony: true,
45
+ imessage: true,
46
+ msteams: true,
47
+ bluebubbles: true,
48
+ mattermost: true,
49
+ matrix: true,
50
+ } satisfies Record<PairableSurface, true>;
51
+ const PAIRABLE_SURFACES: readonly PairableSurface[] = Object.keys(PAIRABLE_SURFACE_SET) as PairableSurface[];
52
+
53
+ function findSchema(surface: string): ChannelSetupSchema | undefined {
54
+ return PAIRABLE_SURFACES.includes(surface as PairableSurface) ? getBuiltinSetupSchema(surface as PairableSurface) : undefined;
55
+ }
56
+
57
+ /** The always-present 'enabled' boolean field's config key, if declared. */
58
+ function enabledConfigKey(schema: ChannelSetupSchema): ConfigKey | undefined {
59
+ return schema.fields.find((f) => f.id === 'enabled' && f.kind === 'boolean')?.configKey as ConfigKey | undefined;
60
+ }
61
+
62
+ function fieldValue(field: ChannelSetupFieldDescriptor, configManager: ConfigManager): string {
63
+ if (!field.configKey) return '';
64
+ const raw = configManager.get(field.configKey as ConfigKey);
65
+ return raw === undefined || raw === null ? '' : String(raw);
66
+ }
67
+
68
+ /** A field counts as configured once it has any stored value; 'select' fields always carry a default. */
69
+ function fieldConfigured(field: ChannelSetupFieldDescriptor, configManager: ConfigManager): boolean {
70
+ if (field.kind === 'select' || field.kind === 'boolean') return true;
71
+ return fieldValue(field, configManager).length > 0;
72
+ }
73
+
74
+ /** Credential fields the user must supply — secret (masked) and plain text; booleans/selects are defaulted. */
75
+ function credentialFields(schema: ChannelSetupSchema): ChannelSetupFieldDescriptor[] {
76
+ return schema.fields.filter((field) => field.kind === 'secret' || field.kind === 'string' || field.kind === 'url' || field.kind === 'number');
77
+ }
78
+
79
+ /** A field-level hint: the matching secretTarget's detail for secrets, else the placeholder, else the label. */
80
+ function fieldHint(field: ChannelSetupFieldDescriptor, schema: ChannelSetupSchema): string {
81
+ if (field.kind === 'secret' && field.secretTargetId) {
82
+ const target = schema.secretTargets.find((t) => t.id === field.secretTargetId);
83
+ if (target?.detail) return target.envKeys?.length ? `${target.detail} (env: ${target.envKeys.join(', ')})` : target.detail;
84
+ }
85
+ return field.placeholder ? `e.g. ${field.placeholder}` : field.label;
86
+ }
87
+
88
+ function listAdapters(ctx: CommandContext): void {
89
+ const configManager = ctx.platform.configManager;
90
+ const lines: string[] = ['Channel adapters — pair with /channel pair <surface>:', ''];
91
+ for (const surface of PAIRABLE_SURFACES) {
92
+ const schema = getBuiltinSetupSchema(surface);
93
+ const creds = credentialFields(schema);
94
+ const set = creds.filter((f) => fieldConfigured(f, configManager)).length;
95
+ const enabledKey = enabledConfigKey(schema);
96
+ const enabled = enabledKey !== undefined && configManager.get(enabledKey) === true;
97
+ const state = creds.length === 0 ? 'no credentials' : `${set}/${creds.length} set`;
98
+ lines.push(` ${surface.padEnd(14)} ${schema.label.padEnd(22)} ${state.padEnd(12)} ${enabled ? '(enabled)' : ''}`);
99
+ }
100
+ lines.push('');
101
+ lines.push('Run /channel pair <surface> to see and enter its declared credentials.');
102
+ ctx.print(lines.join('\n'));
103
+ }
104
+
105
+ function renderSurface(schema: ChannelSetupSchema, configManager: ConfigManager): string[] {
106
+ const lines = [`${schema.label} (${schema.surface})`, schema.description, '', `Setup mode: ${schema.setupMode}`, '', 'Declared credentials:'];
107
+ for (const field of credentialFields(schema)) {
108
+ const configured = fieldConfigured(field, configManager);
109
+ const kindTag = field.kind === 'secret' ? 'secret' : 'value';
110
+ const status = configured ? 'set' : 'not set';
111
+ lines.push(` - ${field.label} (${kindTag}): ${status} — ${fieldHint(field, schema)}`);
112
+ }
113
+ if (schema.externalSteps.length > 0) {
114
+ lines.push('', 'External setup steps:');
115
+ for (const step of schema.externalSteps) lines.push(` - ${step}`);
116
+ }
117
+ return lines;
118
+ }
119
+
120
+ /** Chain a masked prompt for each missing secret field, storing each securely. */
121
+ function promptMissingSecrets(
122
+ ctx: CommandContext,
123
+ schema: ChannelSetupSchema,
124
+ missing: readonly ChannelSetupFieldDescriptor[],
125
+ index: number,
126
+ ): void {
127
+ const configManager = ctx.platform.configManager;
128
+ if (index >= missing.length) {
129
+ const enabledKey = enabledConfigKey(schema);
130
+ if (enabledKey !== undefined) configManager.setDynamic(enabledKey, true);
131
+ ctx.print(`\n${schema.label} credentials stored${enabledKey !== undefined ? ' and the surface is enabled' : ''}. Verify with: /channel pair ${schema.surface} verify`);
132
+ ctx.renderRequest();
133
+ return;
134
+ }
135
+ const field = missing[index]!;
136
+ if (!field.configKey) {
137
+ ctx.print(`[pair] ${field.label} has no config key declared; skipping.`);
138
+ promptMissingSecrets(ctx, schema, missing, index + 1);
139
+ return;
140
+ }
141
+ const configKey = field.configKey as ConfigKey;
142
+ if (!ctx.beginConcealedInput) {
143
+ ctx.print('[pair] Concealed input is unavailable on this surface; set credentials via /config and re-run verify.');
144
+ return;
145
+ }
146
+ ctx.print(`[pair] Enter ${field.label} (${field.placeholder || 'value'}) — masked; Enter to store, Esc to stop.`);
147
+ ctx.beginConcealedInput({
148
+ label: field.label,
149
+ onSubmit: (value) => {
150
+ if (value.length === 0) {
151
+ ctx.print(`[pair] ${field.label} left unset.`);
152
+ promptMissingSecrets(ctx, schema, missing, index + 1);
153
+ return;
154
+ }
155
+ void persistSecretBackedConfigValue(configManager, ctx.platform.secretsManager, configKey, value)
156
+ .then(() => {
157
+ ctx.print(`[pair] ${field.label} stored securely (hidden).`);
158
+ promptMissingSecrets(ctx, schema, missing, index + 1);
159
+ })
160
+ .catch((error: unknown) => {
161
+ ctx.print(`[pair] Failed to store ${field.label}: ${error instanceof Error ? error.message : String(error)}`);
162
+ promptMissingSecrets(ctx, schema, missing, index + 1);
163
+ });
164
+ },
165
+ onCancel: () => {
166
+ ctx.print(`[pair] Stopped. Re-run /channel pair ${schema.surface} to finish the remaining credentials.`);
167
+ },
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Verify a surface: report which declared credentials resolve locally, then
173
+ * perform a REAL round-trip through the daemon (channels.test.send) and
174
+ * report the actual delivered/error outcome — never a fabricated success.
175
+ */
176
+ async function verifySurface(ctx: CommandContext, schema: ChannelSetupSchema): Promise<void> {
177
+ const configManager = ctx.platform.configManager;
178
+ const creds = credentialFields(schema);
179
+ const lines: string[] = [`Verifying ${schema.label} (${schema.surface}):`];
180
+ const missing: ChannelSetupFieldDescriptor[] = [];
181
+ for (const field of creds) {
182
+ const ok = fieldConfigured(field, configManager);
183
+ if (!ok) missing.push(field);
184
+ lines.push(` ${ok ? '[ok] ' : '[fail]'} ${field.label}${ok ? '' : ' — missing'}`);
185
+ }
186
+ lines.push('');
187
+ if (missing.length === 0) {
188
+ lines.push('All declared credentials resolve.');
189
+ } else {
190
+ lines.push(`${missing.length} credential(s) still missing: ${missing.map((f) => f.label).join(', ')}.`);
191
+ lines.push(`Run /channel pair ${schema.surface} to enter them.`);
192
+ }
193
+ ctx.print(lines.join('\n'));
194
+
195
+ const rpc = getOperatorRpc(ctx);
196
+ if (!rpc.available) {
197
+ ctx.print(`\n[verify] Cannot perform a live round-trip: ${rpc.reason}`);
198
+ return;
199
+ }
200
+ ctx.print(`\n[verify] Sending a real test message through ${schema.surface}...`);
201
+ try {
202
+ const result = await rpc.sdk.operator.invoke('channels.test.send', { surface: schema.surface });
203
+ if (result.delivered) {
204
+ ctx.print(`[verify] delivered:true${result.responseId ? ` (responseId: ${result.responseId})` : ''}${result.address ? ` → ${result.address}` : ''}`);
205
+ } else {
206
+ ctx.print(`[verify] delivered:false — ${result.error ?? 'the daemon returned no error detail'}`);
207
+ }
208
+ } catch (error) {
209
+ ctx.print(`[verify] round-trip request failed: ${describeOperatorRpcError(error)}`);
210
+ }
211
+ }
212
+
213
+ /** Entry point for `/channel pair [...]`. */
214
+ export async function runChannelPairing(args: readonly string[], ctx: CommandContext): Promise<void> {
215
+ const surface = args[0];
216
+ if (!surface) {
217
+ listAdapters(ctx);
218
+ return;
219
+ }
220
+ const schema = findSchema(surface);
221
+ if (!schema) {
222
+ ctx.print(`Unknown channel "${surface}". Available: ${PAIRABLE_SURFACES.join(', ')}`);
223
+ return;
224
+ }
225
+ const action = (args[1] ?? '').toLowerCase();
226
+ if (action === 'verify' || action === 'test') {
227
+ await verifySurface(ctx, schema);
228
+ return;
229
+ }
230
+
231
+ const configManager = ctx.platform.configManager;
232
+ ctx.print(renderSurface(schema, configManager).join('\n'));
233
+ const creds = credentialFields(schema);
234
+ const missingSecrets = creds.filter((f) => f.kind === 'secret' && !fieldConfigured(f, configManager));
235
+ const missingText = creds.filter((f) => f.kind !== 'secret' && !fieldConfigured(f, configManager));
236
+ if (missingText.length > 0) {
237
+ ctx.print(`\nNon-secret values to set with /config (or accept defaults): ${missingText.map((f) => `${f.label} → ${f.configKey}`).join('; ')}`);
238
+ }
239
+ if (missingSecrets.length === 0) {
240
+ ctx.print(`\nNo missing secrets. Verify with: /channel pair ${schema.surface} verify`);
241
+ return;
242
+ }
243
+ ctx.print(`\nEntering ${missingSecrets.length} secret credential(s) — Esc to stop.`);
244
+ promptMissingSecrets(ctx, schema, missingSecrets, 0);
245
+ }