@pellux/goodvibes-tui 1.16.1 → 1.17.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 (219) hide show
  1. package/CHANGELOG.md +89 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1091 -271
  4. package/package.json +7 -7
  5. package/src/cli/completions/generate.ts +5 -4
  6. package/src/cli/config-overrides.ts +51 -11
  7. package/src/cli/doctor.ts +4 -5
  8. package/src/cli/entrypoint.ts +13 -8
  9. package/src/cli/help.ts +7 -6
  10. package/src/cli/launch-auto-update.ts +214 -0
  11. package/src/cli/parser.ts +5 -0
  12. package/src/cli/status.ts +13 -0
  13. package/src/cli/surface-command.ts +5 -5
  14. package/src/cli/tui-startup.ts +60 -97
  15. package/src/cli/types.ts +2 -0
  16. package/src/config/index.ts +1 -1
  17. package/src/config/tui-extension-settings.ts +33 -0
  18. package/src/core/composer-state.ts +1 -1
  19. package/src/core/conversation-line-cache.ts +3 -3
  20. package/src/core/conversation-rendering.ts +4 -4
  21. package/src/core/conversation.ts +2 -2
  22. package/src/core/long-task-notifier.ts +7 -7
  23. package/src/core/stream-event-wiring.ts +8 -6
  24. package/src/core/system-message-noise.ts +1 -1
  25. package/src/core/system-message-router.ts +6 -6
  26. package/src/core/turn-cancellation.ts +1 -1
  27. package/src/core/turn-event-wiring.ts +8 -8
  28. package/src/daemon/cli.ts +20 -5
  29. package/src/daemon/lifecycle.ts +41 -0
  30. package/src/daemon/service-commands.ts +5 -5
  31. package/src/export/cost-utils.ts +128 -6
  32. package/src/input/autocomplete.ts +2 -2
  33. package/src/input/command-registry.ts +34 -9
  34. package/src/input/commands/ci-runtime.ts +5 -1
  35. package/src/input/commands/codebase-runtime.ts +6 -6
  36. package/src/input/commands/cost-runtime.ts +1 -1
  37. package/src/input/commands/health-runtime.ts +2 -2
  38. package/src/input/commands/intelligence-runtime.ts +1 -1
  39. package/src/input/commands/local-auth-runtime.ts +1 -1
  40. package/src/input/commands/local-provider-runtime.ts +49 -10
  41. package/src/input/commands/local-runtime.ts +53 -5
  42. package/src/input/commands/memory.ts +2 -2
  43. package/src/input/commands/operator-panel-runtime.ts +5 -5
  44. package/src/input/commands/permissions-provenance.ts +1 -1
  45. package/src/input/commands/permissions-runtime.ts +99 -8
  46. package/src/input/commands/planning-runtime.ts +3 -3
  47. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  48. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  49. package/src/input/commands/provider.ts +7 -5
  50. package/src/input/commands/recall-files-git.ts +13 -51
  51. package/src/input/commands/recall-files.ts +8 -11
  52. package/src/input/commands/recall-review.ts +2 -2
  53. package/src/input/commands/relay-runtime.ts +1 -1
  54. package/src/input/commands/remote-runtime.ts +1 -1
  55. package/src/input/commands/runtime-services.ts +4 -4
  56. package/src/input/commands/schedule-runtime.ts +14 -7
  57. package/src/input/commands/services-runtime.ts +1 -1
  58. package/src/input/commands/session-workflow.ts +2 -2
  59. package/src/input/commands/settings-sync-runtime.ts +2 -2
  60. package/src/input/commands/share-runtime.ts +1 -1
  61. package/src/input/commands/shell-core.ts +46 -68
  62. package/src/input/commands/subscription-runtime.ts +1 -1
  63. package/src/input/commands/update-runtime.ts +153 -96
  64. package/src/input/commands/work-plan-runtime.ts +1 -1
  65. package/src/input/commands/workstream-runtime.ts +2 -2
  66. package/src/input/commands.ts +0 -2
  67. package/src/input/config-modal-types.ts +3 -3
  68. package/src/input/config-modal.ts +10 -11
  69. package/src/input/feature-unit-layout.ts +93 -82
  70. package/src/input/feed-context-factory.ts +2 -2
  71. package/src/input/handler-command-route.ts +2 -2
  72. package/src/input/handler-content-actions.ts +1 -1
  73. package/src/input/handler-feed-routes.ts +7 -7
  74. package/src/input/handler-feed.ts +4 -4
  75. package/src/input/handler-interactions.ts +3 -2
  76. package/src/input/handler-modal-routes.ts +12 -4
  77. package/src/input/handler-modal-token-routes.ts +1 -1
  78. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  79. package/src/input/handler-onboarding.ts +3 -2
  80. package/src/input/handler-picker-routes.ts +26 -13
  81. package/src/input/handler-shortcuts.ts +6 -6
  82. package/src/input/handler.ts +2 -2
  83. package/src/input/keybindings.ts +2 -2
  84. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  85. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  86. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  87. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  88. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  89. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  90. package/src/input/panel-integration-actions.ts +2 -2
  91. package/src/input/panel-mouse-geometry.ts +1 -1
  92. package/src/input/panel-paste-flood-guard.ts +1 -1
  93. package/src/input/provider-key-intake.ts +167 -0
  94. package/src/input/sandbox-exec-config.ts +2 -2
  95. package/src/input/selection-modal.ts +1 -1
  96. package/src/input/settings-modal-activation.ts +5 -0
  97. package/src/input/settings-modal-adjustment.ts +4 -31
  98. package/src/input/settings-modal-data.ts +22 -21
  99. package/src/input/settings-modal-mutations.ts +12 -68
  100. package/src/input/settings-modal-types.ts +23 -16
  101. package/src/input/settings-modal.ts +74 -55
  102. package/src/main.ts +48 -48
  103. package/src/panels/agent-inspector-shared.ts +4 -4
  104. package/src/panels/base-panel.ts +2 -2
  105. package/src/panels/builtin/agent.ts +4 -4
  106. package/src/panels/builtin/development.ts +4 -4
  107. package/src/panels/builtin/knowledge.ts +1 -1
  108. package/src/panels/builtin/operations.ts +29 -10
  109. package/src/panels/builtin/session.ts +5 -5
  110. package/src/panels/builtin/shared.ts +4 -4
  111. package/src/panels/builtin-modals.ts +10 -10
  112. package/src/panels/builtin-panels.ts +1 -1
  113. package/src/panels/cost-tracker-panel.ts +124 -7
  114. package/src/panels/diff-panel.ts +5 -5
  115. package/src/panels/eval-registry.ts +2 -2
  116. package/src/panels/fleet-deep-link.ts +1 -1
  117. package/src/panels/fleet-panel-format.ts +44 -8
  118. package/src/panels/fleet-panel.ts +16 -16
  119. package/src/panels/fleet-read-model.ts +52 -13
  120. package/src/panels/fleet-steer.ts +2 -2
  121. package/src/panels/fleet-stop.ts +3 -3
  122. package/src/panels/fleet-tabs.ts +13 -14
  123. package/src/panels/fleet-transcript.ts +5 -5
  124. package/src/panels/git-panel.ts +2 -2
  125. package/src/panels/index.ts +4 -1
  126. package/src/panels/local-auth-panel.ts +1 -1
  127. package/src/panels/modals/hooks-modal.ts +1 -1
  128. package/src/panels/modals/keybindings-modal.ts +2 -2
  129. package/src/panels/modals/knowledge-modal.ts +1 -1
  130. package/src/panels/modals/marketplace-modal.ts +4 -4
  131. package/src/panels/modals/memory-modal.ts +4 -4
  132. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  133. package/src/panels/modals/modal-theme.ts +1 -1
  134. package/src/panels/modals/pairing-modal.ts +2 -2
  135. package/src/panels/modals/planning-modal.ts +2 -2
  136. package/src/panels/modals/plugins-modal.ts +1 -1
  137. package/src/panels/modals/policy-modal.ts +1 -1
  138. package/src/panels/modals/security-modal.ts +1 -1
  139. package/src/panels/modals/skills-modal.ts +1 -1
  140. package/src/panels/modals/work-plan-modal.ts +3 -3
  141. package/src/panels/notifications-feed.ts +127 -0
  142. package/src/panels/notifications-panel.ts +160 -0
  143. package/src/panels/panel-manager.ts +9 -9
  144. package/src/panels/plugins-panel.ts +4 -4
  145. package/src/panels/project-planning-answer-actions.ts +1 -1
  146. package/src/panels/provider-health-routes.ts +2 -2
  147. package/src/panels/skills-panel.ts +4 -5
  148. package/src/panels/token-budget-panel.ts +11 -3
  149. package/src/panels/types.ts +5 -5
  150. package/src/permissions/broker-approval-card.ts +201 -0
  151. package/src/permissions/hunk-selection.ts +28 -4
  152. package/src/permissions/prompt.ts +244 -29
  153. package/src/renderer/autocomplete-overlay.ts +107 -54
  154. package/src/renderer/code-block.ts +2 -2
  155. package/src/renderer/compaction-history-modal.ts +1 -1
  156. package/src/renderer/compaction-preview.ts +1 -1
  157. package/src/renderer/compaction-quality.ts +1 -1
  158. package/src/renderer/compositor.ts +2 -2
  159. package/src/renderer/config-modal.ts +1 -1
  160. package/src/renderer/conversation-overlays.ts +1 -1
  161. package/src/renderer/footer-tips.ts +3 -3
  162. package/src/renderer/hint-grammar.ts +1 -1
  163. package/src/renderer/modal-factory.ts +1 -1
  164. package/src/renderer/model-picker-overlay.ts +1 -1
  165. package/src/renderer/model-workspace.ts +7 -2
  166. package/src/renderer/panel-composite.ts +2 -2
  167. package/src/renderer/panel-workspace-bar.ts +1 -1
  168. package/src/renderer/process-indicator.ts +1 -1
  169. package/src/renderer/selection-modal-overlay.ts +1 -1
  170. package/src/renderer/session-picker-modal.ts +2 -2
  171. package/src/renderer/settings-modal-helpers.ts +14 -5
  172. package/src/renderer/settings-modal.ts +127 -39
  173. package/src/renderer/shell-surface.ts +3 -0
  174. package/src/renderer/terminal-bg-probe.ts +1 -1
  175. package/src/renderer/theme.ts +3 -3
  176. package/src/renderer/tool-call.ts +1 -1
  177. package/src/renderer/tool-result-summary.ts +1 -1
  178. package/src/renderer/turn-injection.ts +7 -6
  179. package/src/renderer/ui-factory.ts +6 -1
  180. package/src/runtime/bootstrap-command-context.ts +4 -1
  181. package/src/runtime/bootstrap-command-parts.ts +6 -4
  182. package/src/runtime/bootstrap-core.ts +35 -35
  183. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  184. package/src/runtime/bootstrap-shell.ts +6 -5
  185. package/src/runtime/bootstrap.ts +34 -32
  186. package/src/runtime/channel-composition.ts +46 -0
  187. package/src/runtime/code-index-services.ts +3 -4
  188. package/src/runtime/daemon-attach-notices.ts +120 -0
  189. package/src/runtime/durability-services.ts +55 -0
  190. package/src/runtime/embedded-service-factories.ts +40 -0
  191. package/src/runtime/feature-settings.ts +147 -0
  192. package/src/runtime/index.ts +4 -21
  193. package/src/runtime/legacy-daemon-migration.ts +29 -19
  194. package/src/runtime/memory-fold.ts +1 -1
  195. package/src/runtime/notification-dispatch.ts +110 -0
  196. package/src/runtime/onboarding/apply.ts +3 -29
  197. package/src/runtime/onboarding/fast-path.ts +39 -6
  198. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  199. package/src/runtime/onboarding/snapshot.ts +0 -1
  200. package/src/runtime/onboarding/types.ts +9 -3
  201. package/src/runtime/provider-fallback.ts +55 -0
  202. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  203. package/src/runtime/release-artifacts.ts +19 -103
  204. package/src/runtime/resume-notice.ts +1 -1
  205. package/src/runtime/services.ts +81 -82
  206. package/src/runtime/session-continuity-hints.ts +38 -0
  207. package/src/runtime/surface-feature-flags.ts +40 -9
  208. package/src/runtime/terminal-output-guard.ts +2 -2
  209. package/src/runtime/trust/workspace-registration.ts +10 -2
  210. package/src/runtime/trust/workspace-trust.ts +58 -31
  211. package/src/runtime/ui-services.ts +3 -1
  212. package/src/runtime/update-check.ts +15 -69
  213. package/src/runtime/workstream-services.ts +2 -2
  214. package/src/shell/blocking-input.ts +102 -6
  215. package/src/shell/ui-openers.ts +38 -6
  216. package/src/utils/format-duration.ts +1 -1
  217. package/src/verification/verification-ledger.ts +21 -4
  218. package/src/version.ts +1 -1
  219. package/src/input/commands/flags-runtime.ts +0 -328
@@ -10,6 +10,7 @@ import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automatio
10
10
  import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
11
11
  import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
+ import { buildAutomationEmptyState } from '@pellux/goodvibes-sdk/platform/runtime/feature-announcements';
13
14
  import { parseNaturalLanguageSchedule } from './schedule-nl.ts';
14
15
  import type {
15
16
  AutomationExecutionPolicy,
@@ -106,13 +107,19 @@ export function registerScheduleRuntimeCommands(registry: CommandRegistry): void
106
107
  if (!sub || sub === 'list') {
107
108
  const jobs = manager.listJobs();
108
109
  if (jobs.length === 0) {
109
- ctx.print(
110
- 'No automation jobs.\n'
111
- + 'Use:\n'
112
- + ' /schedule add cron "*/30 * * * *" "check build status"\n'
113
- + ' /schedule add every 15m "summarize open PRs"\n'
114
- + ' /schedule add at 2026-04-10T09:00:00 "send release reminder"'
115
- );
110
+ // The SDK owns the presence + title of the how-to-create-your-first-
111
+ // routine empty state (shown while automation is on and idle). Its
112
+ // body points at a command name this registry doesn't have, so we
113
+ // render its title with THIS surface's real /schedule commands — a
114
+ // command pointer that actually exists in the registry.
115
+ const emptyState = buildAutomationEmptyState({ enabled: true, routineCount: jobs.length });
116
+ ctx.print([
117
+ emptyState ? emptyState.title : 'No automation jobs.',
118
+ 'Automation is on and idle. Create your first routine with:',
119
+ ' /schedule add cron "*/30 * * * *" "check build status"',
120
+ ' /schedule add every 15m "summarize open PRs"',
121
+ ' /schedule add at 2026-04-10T09:00:00 "send release reminder"',
122
+ ].join('\n'));
116
123
  return;
117
124
  }
118
125
  const lines = ['Automation jobs:', ''];
@@ -16,7 +16,7 @@ export function registerServicesRuntimeCommands(registry: CommandRegistry): void
16
16
  const sub = args[0] ?? 'open';
17
17
  const shellPaths = requireShellPaths(ctx);
18
18
  if (sub === 'open' || sub === 'panel') {
19
- ctx.openModal?.('services-modal'); // W6.1: services panel -> config modal
19
+ ctx.openModal?.('services-modal'); // services panel -> config modal
20
20
  return;
21
21
  }
22
22
  const svcRegistry = requireServiceRegistry(ctx);
@@ -89,7 +89,7 @@ export function reopenPanelsFromReturnContext(ctx: CommandContext, summary: Sess
89
89
  const reopened: string[] = [];
90
90
  const movedToModal: string[] = [];
91
91
  for (const panelId of summary.openPanels.slice(0, 4)) {
92
- // W6.1 (the purge): a MIGRATE-TO-MODAL id has no panel to restore — a modal
92
+ // (the purge): a MIGRATE-TO-MODAL id has no panel to restore — a modal
93
93
  // is not part of the saved panel layout. Skip it (don't pop a modal
94
94
  // mid-resume) and note it once, rather than firing openModal + revealing an
95
95
  // empty workspace during resume.
@@ -283,7 +283,7 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
283
283
  },
284
284
  });
285
285
  // Hydrate the footer's token counters from the resumed (+ journal-replayed)
286
- // history now, before ctx.renderRequest() below (W0.9).
286
+ // history now, before ctx.renderRequest() below.
287
287
  ctx.session.hydrateSessionUsage?.();
288
288
 
289
289
  if (meta.model) {
@@ -32,7 +32,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
32
32
  const shellPaths = requireShellPaths(ctx);
33
33
  const controlPlaneConfigDir = ctx.platform.configManager.getControlPlaneConfigDir();
34
34
  const sub = (args[0] ?? '').toLowerCase();
35
- // DEBT-5 item 3 (report-vs-modal front doors): settings-sync has a full
35
+ // item 3 (report-vs-modal front doors): settings-sync has a full
36
36
  // modal surface (settings-sync-modal), so the bare command now opens it
37
37
  // — the old bare/`review` transcript report moved to an explicit
38
38
  // `report` subcommand (scriptability preserved: /settings-sync report).
@@ -41,7 +41,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
41
41
  return;
42
42
  }
43
43
  if (sub === 'panel' || sub === 'open') {
44
- ctx.openModal?.('settings-sync-modal'); // W6.1: settings-sync panel -> config modal
44
+ ctx.openModal?.('settings-sync-modal'); // settings-sync panel -> config modal
45
45
  return;
46
46
  }
47
47
  if (sub === 'report' || sub === 'review') {
@@ -115,7 +115,7 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
115
115
  );
116
116
  // The exported document has no field for "this cost is a placeholder" —
117
117
  // omit `cost` entirely rather than embed a misleading 0 when the model
118
- // never resolved to a real price (WO-315).
118
+ // never resolved to a real price.
119
119
  const costIsUnpriced = !isModelPriced(sessionModel);
120
120
  if (costIsUnpriced) {
121
121
  ctx.print(`Note: cost omitted from export — no pricing data for model "${sessionModel}".`);
@@ -6,6 +6,7 @@ import { executeWriteQuit } from './quit-shared.ts';
6
6
  import { compactConversation, requireKeybindingsManager, requireProviderApi, requireSessionMemoryStore } from './runtime-services.ts';
7
7
  import { buildCompactionPreview, buildCompactionAfterNotice, buildPinUsageText, buildPinSuccessText } from '../../renderer/compaction-preview.ts';
8
8
  import { buildCompactionHistoryText } from '../../renderer/compaction-history-modal.ts';
9
+ import { ensureProviderKeyThenSelect } from '../provider-key-intake.ts';
9
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
11
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
11
12
 
@@ -32,11 +33,15 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
32
33
  const modelId = args[0];
33
34
  try {
34
35
  const selected = await providerApi.selectModel(modelId);
35
- ctx.session.runtime.model = selected.registryKey;
36
- ctx.session.runtime.provider = selected.providerId;
37
- ctx.platform.configManager.set('provider.model', selected.registryKey);
38
- ctx.print(`Switched to model: ${selected.displayName} (${selected.providerId})`);
39
- void providerApi.recordModelUsage(selected.registryKey).catch((err) => { logger.debug('model usage record failed', { err }); });
36
+ // Capture the key for an unconfigured provider inline before the
37
+ // selection is announced, so the switch lands ready to chat.
38
+ ensureProviderKeyThenSelect(ctx, selected.providerId, () => {
39
+ ctx.session.runtime.model = selected.registryKey;
40
+ ctx.session.runtime.provider = selected.providerId;
41
+ ctx.platform.configManager.set('provider.model', selected.registryKey);
42
+ ctx.print(`Switched to model: ${selected.displayName} (${selected.providerId})`);
43
+ void providerApi.recordModelUsage(selected.registryKey).catch((err) => { logger.debug('model usage record failed', { err }); });
44
+ });
40
45
  } catch (e) {
41
46
  ctx.print(`Error: ${summarizeError(e)}`);
42
47
  }
@@ -112,75 +117,48 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
112
117
  registry.register({
113
118
  name: 'help',
114
119
  aliases: ['h', '?'],
115
- description: 'Show available commands and keyboard shortcuts',
116
- argsHint: '[command]',
120
+ description: 'Browse every command with its description; picking one runs it',
117
121
  handler(_args, ctx) {
122
+ // Generated live from the command registry — never a hand-maintained
123
+ // list — so /help can never drift from the real command set. Category
124
+ // labels come from the same single source of truth as the generated
125
+ // reference and the palette (ctx.getCommandCategories; see its doc for
126
+ // why it is threaded through the context rather than imported here).
127
+ const commands = registry
128
+ .getAll()
129
+ .slice()
130
+ .sort((a, b) => a.name.localeCompare(b.name));
118
131
  if (ctx.openSelection) {
119
- const items: SelectionItem[] = [
120
- { id: '/model', label: '/model [id]', detail: 'Select LLM model', category: 'Model & Provider' },
121
- { id: '/provider', label: '/provider [name]', detail: 'Switch provider', category: 'Model & Provider' },
122
- { id: '/effort', label: '/effort [level]', detail: 'Reasoning effort (instant/low/medium/high)', category: 'Model & Provider' },
123
- { id: '/context window', label: '/context window [<size>|clear]', detail: 'Show, set, or clear a custom context window for the current model', category: 'Model & Provider' },
124
- { id: '/config', label: '/config [category|key]', detail: 'Open fullscreen configuration workspace', category: 'Config & Display' },
125
- { id: '/debug', label: '/debug', detail: 'Toggle debug mode', category: 'Config & Display' },
126
- { id: '/expand', label: '/expand [type]', detail: 'Expand blocks (all|thinking|tool|code)', category: 'Config & Display' },
127
- { id: '/collapse', label: '/collapse [type]', detail: 'Collapse blocks', category: 'Config & Display' },
128
- { id: '/bookmarks', label: '/bookmarks', detail: 'List bookmarked blocks', category: 'Config & Display' },
129
- { id: '/clear', label: '/clear', detail: 'Clear display (keep context)', category: 'Conversation' },
130
- { id: '/reset', label: '/reset', detail: 'Clear display + context', category: 'Conversation' },
131
- { id: '/compact', label: '/compact', detail: 'Summarize to free context', category: 'Conversation' },
132
- { id: '/export', label: '/export [file]', detail: 'Export as markdown', category: 'Conversation' },
133
- { id: '/title', label: '/title [text]', detail: 'Show or set title', category: 'Conversation' },
134
- { id: '/save', label: '/save [name]', detail: 'Save session', category: 'Conversation' },
135
- { id: '/load', label: '/load <name>', detail: 'Load session', category: 'Conversation' },
136
- { id: '/sessions', label: '/sessions', detail: 'List saved sessions', category: 'Conversation' },
137
- { id: '/session', label: '/session', detail: 'Current session info', category: 'Conversation' },
138
- { id: '/session list', label: '/session list', detail: 'List all sessions', category: 'Conversation' },
139
- { id: '/session rename', label: '/session rename <name>', detail: 'Rename current session', category: 'Conversation' },
140
- { id: '/resume', label: '/resume [id]', detail: 'Resume a previous session (picker when no id)', category: 'Conversation' },
141
- { id: '/session resume', label: '/session resume <id>', detail: 'Load and resume a session', category: 'Conversation' },
142
- { id: '/session fork', label: '/session fork [name]', detail: 'Fork current session to new ID', category: 'Conversation' },
143
- { id: '/session save', label: '/session save [name]', detail: 'Force-save current session', category: 'Conversation' },
144
- { id: '/session info', label: '/session info [id]', detail: 'Show session details', category: 'Conversation' },
145
- { id: '/session export', label: '/session export <id> [format]', detail: 'Export session as markdown/text', category: 'Conversation' },
146
- { id: '/session search', label: '/session search <query>', detail: 'Search across all sessions', category: 'Conversation' },
147
- { id: '/session delete', label: '/session delete <id>', detail: 'Delete a session', category: 'Conversation' },
148
- { id: '/undo', label: '/undo', detail: 'Remove last turn', category: 'Conversation' },
149
- { id: '/redo', label: '/redo', detail: 'Restore undone turn', category: 'Conversation' },
150
- { id: '/retry', label: '/retry [text]', detail: 'Re-send last message', category: 'Conversation' },
151
- { id: '/fork', label: '/fork [name]', detail: 'Snapshot conversation as a named branch', category: 'Conversation' },
152
- { id: '/branch', label: '/branch [name]', detail: 'List branches or switch to one', category: 'Conversation' },
153
- { id: '/merge', label: '/merge <name>', detail: 'Append messages from a branch', category: 'Conversation' },
154
- { id: '/template', label: '/template', detail: 'Browse templates', category: 'Templates' },
155
- { id: '/template save', label: '/template save <name>', detail: 'Save prompt as template', category: 'Templates' },
156
- { id: '/template use', label: '/template use <name>', detail: 'Execute template', category: 'Templates' },
157
- { id: '/tools', label: '/tools', detail: 'List available tools', category: 'Tools & System' },
158
- { id: '/search', label: '/search <query> [--limit <n>]', detail: 'Search the web and show ranked results', category: 'Tools & System' },
159
- { id: '/imagine', label: '/imagine <prompt>', detail: 'Generate an image from a prompt', category: 'Tools & System' },
160
- { id: '/paste', label: '/paste', detail: 'Insert clipboard text or image into the prompt', category: 'Tools & System' },
161
- { id: '/shortcuts', label: '/shortcuts', detail: 'View keyboard shortcuts reference', category: 'Tools & System' },
162
- { id: '/commands', label: '/commands', detail: 'Browse all commands in a scrollable list', category: 'Tools & System' },
163
- { id: '/secrets', label: '/secrets set|link|get|test|list|delete', detail: 'Manage encrypted and provider-backed secrets', category: 'Tools & System' },
164
- { id: '/help', label: '/help', detail: 'This help', category: 'Tools & System' },
165
- { id: '/quit', label: '/quit', detail: 'Exit', category: 'Tools & System' },
166
- { id: '/wq', label: '/wq', detail: 'Commit all git changes and then exit', category: 'Tools & System' },
167
- ];
168
- // UX-C: every item here is a command, and picking one RUNS it — label
132
+ const categoryByName = ctx.getCommandCategories?.() ?? new Map<string, string>();
133
+ const items: SelectionItem[] = commands.map((cmd) => {
134
+ const argsHint = cmd.argsHint ?? cmd.usage;
135
+ const aliases = cmd.aliases ?? [];
136
+ return {
137
+ id: cmd.name,
138
+ label: argsHint ? `/${cmd.name} ${argsHint}` : `/${cmd.name}`,
139
+ detail: aliases.length > 0 ? `${cmd.description} [aka ${aliases.map((a) => `/${a}`).join(', ')}]` : cmd.description,
140
+ category: categoryByName.get(cmd.name) ?? 'Other',
141
+ };
142
+ });
143
+ // every item here is a command, and picking one RUNS it — label
169
144
  // the verb "Run" (matching the slash-command palette) instead of the
170
145
  // generic "Select" default.
171
146
  ctx.openSelection('Help — Commands', items, { allowSearch: true, primaryVerbLabel: 'Run' }, (result) => {
172
147
  if (!result) return;
173
- const command = result.item.id;
174
- if (command.startsWith('/')) {
175
- const parts = command.slice(1).trim().split(/\s+/);
176
- const name = parts[0];
177
- const cmdArgs = parts.slice(1);
178
- void (ctx.executeCommand?.(name, cmdArgs) ?? registry.execute(name, cmdArgs, ctx));
179
- }
148
+ const name = result.item.id;
149
+ void (ctx.executeCommand?.(name, []) ?? registry.execute(name, [], ctx));
180
150
  });
181
151
  return;
182
152
  }
183
- ctx.print('Use /help to open the help modal. Commands: /model, /provider, /config, /template, /tools, /paste, /sessions, /bookmarks, /save, /load, /undo, /redo, /retry, /clear, /reset, /compact, /export, /title, /effort, /expand, /collapse, /debug, /quit, /wq');
153
+ ctx.print(
154
+ [
155
+ 'Commands:',
156
+ ...commands.map((cmd) => {
157
+ const argsHint = cmd.argsHint ?? cmd.usage;
158
+ return ` /${cmd.name}${argsHint ? ` ${argsHint}` : ''} — ${cmd.description}`;
159
+ }),
160
+ ].join('\n'),
161
+ );
184
162
  },
185
163
  });
186
164
 
@@ -214,7 +192,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
214
192
  description: 'Summarize conversation to free context window',
215
193
  async handler(_args, ctx) {
216
194
  const messages = ctx.session.conversationManager.getMessagesForLLM();
217
- // W5.4 (wo803): contextWindow IS reachable from CommandContext — the same
195
+ // contextWindow IS reachable from CommandContext — the same
218
196
  // call compactConversation() (runtime-services.ts) already makes to scale
219
197
  // its own behaviour by window size. Previously hardcoded to 0 here, which
220
198
  // silently suppressed buildCompactionPreview()'s capacity-% clause even
@@ -230,7 +208,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
230
208
  const result = await compactConversation(ctx);
231
209
  if (result) {
232
210
  // Post-compact notice: uses real CompactionEvent figures, plus an
233
- // out-of-band quality-score grade line when one was computed (W5.4/B28).
211
+ // out-of-band quality-score grade line when one was computed ().
234
212
  ctx.print(buildCompactionAfterNotice({ event: result.event, pinnedMemoryCount, qualityScore: result.qualityScore }));
235
213
  } else {
236
214
  ctx.print('[Context] Compact complete.');
@@ -300,7 +278,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
300
278
  ctx.session.runtime.debugMode = !ctx.session.runtime.debugMode;
301
279
  ctx.print(`Debug mode: ${ctx.session.runtime.debugMode ? 'ON' : 'OFF'}`);
302
280
  // Honest quiet counter for direct terminal writes the guard intercepted
303
- // (routed here instead of spamming the transcript). (UX-B item 1a.)
281
+ // (routed here instead of spamming the transcript). (item 1a.)
304
282
  const intercepted = ctx.session.runtime.terminalWritesIntercepted ?? 0;
305
283
  if (intercepted > 0) {
306
284
  ctx.print(`Terminal writes intercepted this session: ${intercepted} (details in the activity log)`);
@@ -80,7 +80,7 @@ export function registerSubscriptionRuntimeCommands(registry: CommandRegistry):
80
80
  async handler(args, ctx) {
81
81
  const shellPaths = requireShellPaths(ctx);
82
82
  if (args.length === 0 && ctx.openModal) {
83
- ctx.openModal('subscription-modal'); // W6.1: subscription panel -> config modal
83
+ ctx.openModal('subscription-modal'); // subscription panel -> config modal
84
84
  return;
85
85
  }
86
86
  const sub = (args[0] ?? 'review').toLowerCase();
@@ -1,8 +1,11 @@
1
1
  /**
2
- * `/update` — a real self-update path for binary installs, mirroring
3
- * scripts/install.sh's download-verify-swap semantics (that file is the
4
- * tested reference for release-asset naming and checksum verification; keep
5
- * this in lockstep with it).
2
+ * `/update` — a real self-update path for binary installs. The
3
+ * download-verify-swap mechanics are the SDK's canonical update policy
4
+ * module (platform/runtime/self-update — hoisted from this file's
5
+ * semantics), the same mechanism the daemon's hourly loop and
6
+ * scripts/install.sh follow: one update mechanism everywhere. This file owns
7
+ * only the /update UX: install-kind gating, target selection, and the
8
+ * printed report.
6
9
  *
7
10
  * Subcommands:
8
11
  * /update [check] — resolve the latest release tag and report
@@ -12,9 +15,17 @@
12
15
  * app and daemon binaries, and refresh the
13
16
  * sqlite-vec native addon in lockstep so the
14
17
  * vector index never goes stale beside a new
15
- * binary. For any other install kind, prints the
16
- * exact command to run instead — it never
17
- * attempts a swap it can't do safely.
18
+ * binary. Every swap parks the outgoing file
19
+ * at `<path>.previous`, so the replaced
20
+ * version is always kept. For any other
21
+ * install kind, prints the exact command to
22
+ * run instead — it never attempts a swap it
23
+ * can't do safely.
24
+ * /update rollback — exchange each installed file with its kept
25
+ * `.previous` counterpart: one command back to
26
+ * the version that ran before the last update
27
+ * (and, being an exchange, one more command
28
+ * forward again).
18
29
  * /update review — install/subscription/sandbox posture,
19
30
  * unrelated to the update mechanics above.
20
31
  * /update bundle export|inspect <path> — portable posture bundle, as before.
@@ -26,9 +37,16 @@
26
37
  * (scripts/release.ts) does not publish separate stable/preview channels,
27
38
  * so there is no real channel selection to wire it to.
28
39
  */
29
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
40
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
30
41
  import { dirname, join } from 'node:path';
31
42
  import { spawnSync } from 'node:child_process';
43
+ import {
44
+ applyVerifiedUpdate,
45
+ realUpdateFileIo,
46
+ rollbackKeptPrevious,
47
+ type UpdateFileIo,
48
+ type UpdateTarget,
49
+ } from '@pellux/goodvibes-sdk/platform/runtime/self-update';
32
50
  import type { CommandRegistry } from '../command-registry.ts';
33
51
  import { VERSION } from '../../version.ts';
34
52
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
@@ -37,8 +55,6 @@ import {
37
55
  parseChecksumFile,
38
56
  resolveArtifactNames,
39
57
  resolveSqliteVecAsset,
40
- sha256,
41
- verifyChecksum,
42
58
  } from '../../runtime/release-artifacts.ts';
43
59
  import {
44
60
  compareVersions,
@@ -66,47 +82,13 @@ async function downloadText(fetchImpl: UpdateFetchLike, url: string): Promise<st
66
82
  return await response.text();
67
83
  }
68
84
 
69
- async function downloadBuffer(fetchImpl: UpdateFetchLike, url: string): Promise<Buffer> {
70
- const response = await fetchImpl(url);
71
- if (!response.ok) {
72
- throw new Error(`download failed (${response.status}) for ${url}`);
73
- }
74
- return Buffer.from(await response.arrayBuffer());
75
- }
76
-
77
85
  /**
78
- * Writes the new binary next to the target, then renames over it — an
79
- * atomic replace on the same filesystem, so a currently-running process
80
- * that already opened the old file keeps its old inode (POSIX unlink
81
- * semantics) instead of executing a half-written file. Same approach as
82
- * scripts/install.sh's `mv -f "$WORKDIR/$artifact" "$target"`.
86
+ * Suffix under which every swap keeps the file it replaced — re-exported
87
+ * from the SDK's canonical update policy module so rollback and swap share
88
+ * one definition everywhere.
83
89
  */
84
- function swapBinaryAtomically(targetPath: string, buffer: Buffer): void {
85
- const tempPath = `${targetPath}.update-download`;
86
- writeFileSync(tempPath, buffer);
87
- if (process.platform !== 'win32') {
88
- chmodSync(tempPath, 0o755);
89
- }
90
- renameSync(tempPath, targetPath);
91
- }
92
-
93
- /**
94
- * Writes the sqlite-vec native addon into place with the same temp-write +
95
- * atomic-rename discipline as the binaries, so a running process never
96
- * dlopen()s a half-written extension. The addon's parent directory
97
- * (`<execDir>/lib/sqlite-vec-<os>-<arch>/`) is created first because a fresh
98
- * install may not have a `lib/` tree yet. A shared library needs no execute
99
- * bit, so this uses 0o644 rather than the binaries' 0o755.
100
- */
101
- function writeAddonAtomically(targetPath: string, buffer: Buffer): void {
102
- mkdirSync(dirname(targetPath), { recursive: true });
103
- const tempPath = `${targetPath}.update-download`;
104
- writeFileSync(tempPath, buffer);
105
- if (process.platform !== 'win32') {
106
- chmodSync(tempPath, 0o644);
107
- }
108
- renameSync(tempPath, targetPath);
109
- }
90
+ export { PREVIOUS_FILE_SUFFIX } from '@pellux/goodvibes-sdk/platform/runtime/self-update';
91
+ import { PREVIOUS_FILE_SUFFIX } from '@pellux/goodvibes-sdk/platform/runtime/self-update';
110
92
 
111
93
  export interface DaemonServiceRestartInfo {
112
94
  readonly managed: boolean;
@@ -169,19 +151,19 @@ export interface ApplyUpdateOptions {
169
151
  readonly print: (line: string) => void;
170
152
  readonly configManager: { get(key: string): unknown };
171
153
  readonly runCommand?: RunCommand;
172
- /** Injectable so tests can observe/skip the actual filesystem swap. */
173
- readonly swap?: (targetPath: string, buffer: Buffer) => void;
174
- /** Injectable so tests can observe/skip writing the sqlite-vec addon. */
175
- readonly writeAddon?: (targetPath: string, buffer: Buffer) => void;
176
- readonly fileExists?: (path: string) => boolean;
154
+ /** Injectable filesystem seam (the SDK's UpdateFileIo) so tests observe swaps in memory. */
155
+ readonly io?: UpdateFileIo;
177
156
  }
178
157
 
179
158
  /**
180
- * The real self-update path. For a binary install: resolve the latest tag,
181
- * compare to the running version, and if newer, download + verify both
182
- * artifacts BEFORE swapping either one (so a checksum failure on the second
183
- * artifact never leaves a mismatched pair installed), then atomically swap
184
- * each in place. For any other install kind, never attempts a swap — it
159
+ * The real self-update path, delegating the download-verify-swap mechanics
160
+ * to the SDK's canonical update policy module (applyVerifiedUpdate — the
161
+ * same mechanism the daemon's hourly loop uses). For a binary install:
162
+ * resolve the latest tag, compare to the running version, and if newer,
163
+ * download + checksum-verify EVERY artifact before swapping any one (so a
164
+ * checksum failure never leaves a mismatched pair installed), then
165
+ * atomically swap each in place with the outgoing file kept at
166
+ * `<path>.previous`. For any other install kind, never attempts a swap — it
185
167
  * prints the exact command for that install method instead.
186
168
  */
187
169
  export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
@@ -211,22 +193,10 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
211
193
  options.print(`Update available: ${latestTag} (running v${normalizeVersion(options.currentVersion)}). Downloading and verifying...`);
212
194
 
213
195
  const baseUrl = releaseDownloadBaseUrl(latestTag);
214
- const checksumText = await downloadText(options.fetchImpl, `${baseUrl}/${CHECKSUM_MANIFEST_NAME}`);
215
- const checksums = parseChecksumFile(checksumText);
216
-
196
+ const io = options.io ?? realUpdateFileIo;
217
197
  const appBinaryPath = options.execPath;
218
198
  const daemonBinaryPath = join(dirname(appBinaryPath), 'goodvibes-daemon');
219
- const fileExists = options.fileExists ?? existsSync;
220
- const daemonBinaryPresent = fileExists(daemonBinaryPath);
221
-
222
- const appBuffer = await downloadBuffer(options.fetchImpl, `${baseUrl}/${artifacts.app}`);
223
- verifyChecksum(artifacts.app, sha256(appBuffer), checksums.get(artifacts.app));
224
-
225
- let daemonBuffer: Buffer | null = null;
226
- if (daemonBinaryPresent) {
227
- daemonBuffer = await downloadBuffer(options.fetchImpl, `${baseUrl}/${artifacts.daemon}`);
228
- verifyChecksum(artifacts.daemon, sha256(daemonBuffer), checksums.get(artifacts.daemon));
229
- }
199
+ const daemonBinaryPresent = io.exists(daemonBinaryPath);
230
200
 
231
201
  // The sqlite-vec native addon travels with the binaries: refresh it in the
232
202
  // same download-verify-swap pass so /update never leaves a new binary beside a
@@ -237,28 +207,36 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
237
207
  // swap), while an absent entry means the target predates the addon and is
238
208
  // skipped rather than blocking an otherwise-valid binary update. On macOS the
239
209
  // file is refreshed for consistency even though the platform blocks extension
240
- // loading.
210
+ // loading. This inclusion decision needs one manifest pre-read; the swap
211
+ // itself re-verifies every included artifact inside applyVerifiedUpdate.
212
+ const checksumText = await downloadText(options.fetchImpl, `${baseUrl}/${CHECKSUM_MANIFEST_NAME}`);
213
+ const checksums = parseChecksumFile(checksumText);
241
214
  const addon = resolveSqliteVecAsset(options.platform, options.arch);
242
- const addonExpected = addon ? checksums.get(addon.assetName) : undefined;
243
- let addonBuffer: Buffer | null = null;
244
- if (addon && addonExpected !== undefined) {
245
- addonBuffer = await downloadBuffer(options.fetchImpl, `${baseUrl}/${addon.assetName}`);
246
- verifyChecksum(addon.assetName, sha256(addonBuffer), addonExpected);
247
- }
248
-
249
- // All downloads verified before any write — an update must not apply partially.
250
- const swap = options.swap ?? swapBinaryAtomically;
251
- swap(appBinaryPath, appBuffer);
252
- if (daemonBuffer) {
253
- swap(daemonBinaryPath, daemonBuffer);
254
- }
255
- const addonTargetPath = addon && addonBuffer
215
+ const addonIncluded = addon !== null && checksums.get(addon.assetName) !== undefined;
216
+ const addonTargetPath = addon && addonIncluded
256
217
  ? join(dirname(appBinaryPath), 'lib', addon.dirName, addon.fileName)
257
218
  : null;
258
- if (addonBuffer && addonTargetPath) {
259
- const writeAddon = options.writeAddon ?? writeAddonAtomically;
260
- writeAddon(addonTargetPath, addonBuffer);
261
- }
219
+
220
+ const targets: UpdateTarget[] = [
221
+ { label: 'app binary', path: appBinaryPath, assetName: artifacts.app, executable: true },
222
+ ...(daemonBinaryPresent
223
+ ? [{ label: 'daemon binary', path: daemonBinaryPath, assetName: artifacts.daemon, executable: true }]
224
+ : []),
225
+ ...(addon && addonTargetPath
226
+ ? [{ label: 'vector addon', path: addonTargetPath, assetName: addon.assetName, executable: false }]
227
+ : []),
228
+ ];
229
+
230
+ // One mechanism everywhere: downloads + verifies ALL targets before any
231
+ // write, then swaps each atomically with the outgoing file kept at
232
+ // `<path>.previous`.
233
+ await applyVerifiedUpdate({
234
+ fetchImpl: options.fetchImpl,
235
+ downloadBaseUrl: baseUrl,
236
+ targets,
237
+ io,
238
+ platform: options.platform,
239
+ });
262
240
 
263
241
  const serviceInfo = detectDaemonServiceManaged(options.platform, options.configManager, options.runCommand);
264
242
 
@@ -279,6 +257,70 @@ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
279
257
  );
280
258
  }
281
259
 
260
+ export interface RollbackUpdateOptions {
261
+ readonly execPath: string;
262
+ readonly platform: NodeJS.Platform;
263
+ readonly arch: string;
264
+ readonly print: (line: string) => void;
265
+ readonly configManager: { get(key: string): unknown };
266
+ readonly runCommand?: RunCommand;
267
+ /** Injectable filesystem seam (the SDK's UpdateFileIo) so tests observe renames in memory. */
268
+ readonly io?: UpdateFileIo;
269
+ }
270
+
271
+ /**
272
+ * One-command rollback to the version that ran before the last update,
273
+ * delegating the exchange mechanics to the SDK's rollbackKeptPrevious (the
274
+ * same module the swap uses): every installed file (app binary, daemon
275
+ * binary, vector addon) that has a kept `.previous` counterpart is EXCHANGED
276
+ * with it — the previous version becomes live, and the version being rolled
277
+ * back is itself kept at `.previous`, so a second `/update rollback` rolls
278
+ * forward again. Files without a kept counterpart are reported and left
279
+ * untouched; nothing is downloaded.
280
+ */
281
+ export function rollbackUpdate(options: RollbackUpdateOptions): void {
282
+ const installKind: InstallKind = detectInstallKind(options.execPath);
283
+ if (installKind !== 'binary') {
284
+ options.print(
285
+ [
286
+ `This install is not a self-updatable binary install (detected: ${installKind === 'bun-global-package' ? 'bun/npm package install' : 'running from source'}), so there is no kept previous binary to roll back to.`,
287
+ `Install a specific version with your package manager instead, e.g.: ${fallbackUpdateCommand(installKind)}`,
288
+ ].join('\n'),
289
+ );
290
+ return;
291
+ }
292
+
293
+ const io = options.io ?? realUpdateFileIo;
294
+ const addon = resolveSqliteVecAsset(options.platform, options.arch);
295
+ const targets = [
296
+ { label: 'app binary', path: options.execPath },
297
+ { label: 'daemon binary', path: join(dirname(options.execPath), 'goodvibes-daemon') },
298
+ ...(addon ? [{ label: 'vector addon', path: join(dirname(options.execPath), 'lib', addon.dirName, addon.fileName) }] : []),
299
+ ];
300
+
301
+ const result = rollbackKeptPrevious(targets, io);
302
+ if (result.restored.length === 0) {
303
+ options.print(
304
+ `No previous version is kept beside this install (nothing at ${options.execPath}${PREVIOUS_FILE_SUFFIX}). ` +
305
+ 'The previous version is kept from the next update onward.',
306
+ );
307
+ return;
308
+ }
309
+
310
+ const serviceInfo = detectDaemonServiceManaged(options.platform, options.configManager, options.runCommand);
311
+ options.print(
312
+ [
313
+ 'Rolled back to the previously installed version.',
314
+ ...result.restored.map((target) => ` ${target.label}: ${target.path} (the replaced version is kept at ${target.path}${PREVIOUS_FILE_SUFFIX})`),
315
+ '',
316
+ 'Restart goodvibes to run the restored version.',
317
+ serviceInfo.managed
318
+ ? `The daemon is managed by systemd — restart it with: ${serviceInfo.restartCommand}`
319
+ : 'The daemon restarts automatically the next time goodvibes launches.',
320
+ ].join('\n'),
321
+ );
322
+ }
323
+
282
324
  interface UpdateBundle {
283
325
  readonly version: 1;
284
326
  readonly exportedAt: number;
@@ -304,8 +346,8 @@ export function registerUpdateCommand(registry: CommandRegistry): void {
304
346
  registry.register({
305
347
  name: 'update',
306
348
  aliases: ['upgrade'],
307
- description: 'Check for a newer GoodVibes release and, for binary installs, download/verify/apply it',
308
- usage: '[check|apply|review|bundle export <path>|bundle inspect <path>]',
349
+ description: 'Check for a newer GoodVibes release and, for binary installs, download/verify/apply it or roll back to the kept previous version',
350
+ usage: '[check|apply|rollback|review|bundle export <path>|bundle inspect <path>]',
309
351
  async handler(args, ctx) {
310
352
  const sub = args[0] ?? 'check';
311
353
 
@@ -340,6 +382,21 @@ export function registerUpdateCommand(registry: CommandRegistry): void {
340
382
  return;
341
383
  }
342
384
 
385
+ if (sub === 'rollback') {
386
+ try {
387
+ rollbackUpdate({
388
+ execPath: process.execPath,
389
+ platform: process.platform,
390
+ arch: process.arch,
391
+ print: ctx.print,
392
+ configManager: ctx.platform.configManager,
393
+ });
394
+ } catch (error) {
395
+ ctx.print(`Rollback failed: ${error instanceof Error ? error.message : String(error)}`);
396
+ }
397
+ return;
398
+ }
399
+
343
400
  if (sub === 'review') {
344
401
  const installKind = detectInstallKind(process.execPath);
345
402
  const subscriptions = requireSubscriptionManager(ctx);
@@ -358,7 +415,7 @@ export function registerUpdateCommand(registry: CommandRegistry): void {
358
415
  ` built-in subscription providers: ${builtinProviders.length}${builtinProviders.length > 0 ? ` (${builtinProviders.join(', ')})` : ''}`,
359
416
  ` active subscriptions: ${activeSubscriptions.length}${activeSubscriptions.length > 0 ? ` (${activeSubscriptions.join(', ')})` : ''}`,
360
417
  ` sandbox profile: ${sandboxProfile}`,
361
- ' use /update check to look for a newer release, /update apply to install it',
418
+ ' use /update check to look for a newer release, /update apply to install it, /update rollback to return to the kept previous version',
362
419
  ].join('\n'),
363
420
  );
364
421
  return;
@@ -406,7 +463,7 @@ export function registerUpdateCommand(registry: CommandRegistry): void {
406
463
  }
407
464
  }
408
465
 
409
- ctx.print('Usage: /update [check|apply|review|bundle export <path>|bundle inspect <path>]');
466
+ ctx.print('Usage: /update [check|apply|rollback|review|bundle export <path>|bundle inspect <path>]');
410
467
  },
411
468
  });
412
469
  }
@@ -25,7 +25,7 @@ function getStore(ctx: import('../command-registry.ts').CommandContext): WorkPla
25
25
  }
26
26
 
27
27
  function openPanel(ctx: import('../command-registry.ts').CommandContext): void {
28
- // W6.1: work-plan migrated to the 'work-plan' modal — open it via the seam.
28
+ // work-plan migrated to the 'work-plan' modal — open it via the seam.
29
29
  openModalCommand(ctx, 'work-plan-modal');
30
30
  }
31
31