@pellux/goodvibes-tui 1.13.1 → 1.15.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 +104 -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 +412 -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 +109 -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
@@ -8,6 +8,7 @@ import { TemplateManager, parseTemplateArgs } from '@pellux/goodvibes-sdk/platfo
8
8
  import { requireSessionManager, requireSessionMemoryStore, requireShellPaths } from './runtime-services.ts';
9
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
10
  import { sessionCommand } from './session.ts';
11
+ import { undoLastRewind, redoLastRewind } from './rewind-runtime.ts';
11
12
 
12
13
  export function registerSessionContentCommands(registry: CommandRegistry): void {
13
14
  registry.register({
@@ -163,10 +164,15 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
163
164
  registry.register({
164
165
  name: 'undo',
165
166
  aliases: [],
166
- description: 'Undo last action. /undo file — revert last file write/edit. /undo — remove last conversation turn',
167
- usage: '[file]',
168
- argsHint: '[file]',
169
- handler(args, ctx) {
167
+ description: 'Undo last action. /undo rewind — reverse the last /rewind. /undo file — revert last file write/edit. /undo — remove last conversation turn',
168
+ usage: '[rewind|file]',
169
+ argsHint: '[rewind|file]',
170
+ async handler(args, ctx) {
171
+ if (args[0] === 'rewind') {
172
+ const result = await undoLastRewind(ctx);
173
+ ctx.print(result.message);
174
+ return;
175
+ }
170
176
  if (args[0] === 'file') {
171
177
  if (!ctx.workspace.fileUndoManager) {
172
178
  ctx.print('File undo not available.');
@@ -192,10 +198,15 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
192
198
 
193
199
  registry.register({
194
200
  name: 'redo',
195
- description: 'Redo last undone action. /redo file — re-apply last reverted file. /redo — restore conversation turn',
196
- usage: '[file]',
197
- argsHint: '[file]',
198
- handler(args, ctx) {
201
+ description: 'Redo last undone action. /redo rewind — re-apply the last undone /rewind. /redo file — re-apply last reverted file. /redo — restore conversation turn',
202
+ usage: '[rewind|file]',
203
+ argsHint: '[rewind|file]',
204
+ async handler(args, ctx) {
205
+ if (args[0] === 'rewind') {
206
+ const result = await redoLastRewind(ctx);
207
+ ctx.print(result.message);
208
+ return;
209
+ }
199
210
  if (args[0] === 'file') {
200
211
  if (!ctx.workspace.fileUndoManager) {
201
212
  ctx.print('File redo not available.');
@@ -8,6 +8,7 @@ import type { SessionReturnContextSummary } from '@/runtime/index.ts';
8
8
  import { formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
9
9
  import { requirePanelManager, requireProviderApi, requireSessionManager, requireShellPaths } from './runtime-services.ts';
10
10
  import { replayJournalForSession } from '../../core/session-recovery.ts';
11
+ import { restoreTurnAnchors } from '../../core/rewind-turn-anchors.ts';
11
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
12
13
 
13
14
  function parseTranscriptKind(raw: string | undefined): TranscriptEventKind | 'all' {
@@ -257,6 +258,12 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
257
258
  ctx.session.conversationManager.rebuildHistory();
258
259
  ctx.session.runtime.sessionId = found.name;
259
260
 
261
+ // Restore the resumed session's message-anchored rewind anchors from its
262
+ // sidecar so /rewind works identically before and after the resume. The
263
+ // in-memory registry is process-local, so a fresh process would otherwise
264
+ // start with no anchors for the loaded session.
265
+ const restoredAnchorCount = restoreTurnAnchors(found.name, requireShellPaths(ctx).workingDirectory);
266
+
260
267
  // Journal replay: recover turns that post-date the loaded snapshot.
261
268
  const shellPaths = requireShellPaths(ctx);
262
269
  const journalReplay = replayJournalForSession({
@@ -293,6 +300,9 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
293
300
  ctx.renderRequest();
294
301
  const resumedMsgCount = ctx.session.conversationManager.getMessageCount();
295
302
  ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMsgCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
303
+ if (restoredAnchorCount > 0) {
304
+ ctx.print(` Rewind: restored ${restoredAnchorCount} turn anchor(s) — /rewind is available for this resumed session.`);
305
+ }
296
306
  if (journalReplay.replayed > 0) {
297
307
  ctx.print(` [Recovery] Replayed ${journalReplay.replayed} journal record(s) — restored turns since last snapshot.`);
298
308
  }
@@ -0,0 +1,412 @@
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).
6
+ *
7
+ * Subcommands:
8
+ * /update [check] — resolve the latest release tag and report
9
+ * whether this build is already current.
10
+ * /update apply — for a binary install (scripts/install.sh),
11
+ * download + verify + atomically swap the
12
+ * app and daemon binaries, and refresh the
13
+ * sqlite-vec native addon in lockstep so the
14
+ * 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
+ * /update review — install/subscription/sandbox posture,
19
+ * unrelated to the update mechanics above.
20
+ * /update bundle export|inspect <path> — portable posture bundle, as before.
21
+ *
22
+ * The former `/update channel <stable|preview>` subcommand wrote a
23
+ * release.channel config value that nothing downstream ever read — it only
24
+ * ever changed what a later `/update review` printed back to you. It has
25
+ * been removed rather than kept decorative; this repo's release process
26
+ * (scripts/release.ts) does not publish separate stable/preview channels,
27
+ * so there is no real channel selection to wire it to.
28
+ */
29
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
30
+ import { dirname, join } from 'node:path';
31
+ import { spawnSync } from 'node:child_process';
32
+ import type { CommandRegistry } from '../command-registry.ts';
33
+ import { VERSION } from '../../version.ts';
34
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
35
+ import {
36
+ CHECKSUM_MANIFEST_NAME,
37
+ parseChecksumFile,
38
+ resolveArtifactNames,
39
+ resolveSqliteVecAsset,
40
+ sha256,
41
+ verifyChecksum,
42
+ } from '../../runtime/release-artifacts.ts';
43
+ import {
44
+ compareVersions,
45
+ detectInstallKind,
46
+ fallbackUpdateCommand,
47
+ normalizeVersion,
48
+ resolveLatestReleaseTag,
49
+ type InstallKind,
50
+ type UpdateFetchLike,
51
+ } from '../../runtime/update-check.ts';
52
+ import { resolveConfiguredServiceName } from '../../runtime/legacy-daemon-migration.ts';
53
+ import { requireShellPaths, requireSubscriptionManager } from './runtime-services.ts';
54
+
55
+ const REPO_RELEASES_LATEST_URL = 'https://github.com/mgd34msu/goodvibes-tui/releases/latest';
56
+
57
+ function releaseDownloadBaseUrl(tag: string): string {
58
+ return `https://github.com/mgd34msu/goodvibes-tui/releases/download/${tag}`;
59
+ }
60
+
61
+ async function downloadText(fetchImpl: UpdateFetchLike, url: string): Promise<string> {
62
+ const response = await fetchImpl(url);
63
+ if (!response.ok) {
64
+ throw new Error(`download failed (${response.status}) for ${url}`);
65
+ }
66
+ return await response.text();
67
+ }
68
+
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
+ /**
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"`.
83
+ */
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
+ }
110
+
111
+ export interface DaemonServiceRestartInfo {
112
+ readonly managed: boolean;
113
+ readonly unitName: string;
114
+ readonly restartCommand: string;
115
+ }
116
+
117
+ /** Injectable so tests never shell out to a real systemctl. */
118
+ export type RunCommand = (command: string, args: string[]) => { readonly status: number | null; readonly stdout: string };
119
+
120
+ const defaultRunCommand: RunCommand = (command, args) => {
121
+ try {
122
+ const result = spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8' });
123
+ return { status: result.status, stdout: result.stdout ?? '' };
124
+ } catch {
125
+ return { status: null, stdout: '' };
126
+ }
127
+ };
128
+
129
+ /**
130
+ * Detects, honestly, whether the daemon is currently running as a systemd
131
+ * user service — so the post-update message can tell the user the real
132
+ * restart command instead of assuming one. Only Linux/systemd is checked
133
+ * (this repo's PlatformServiceManager also supports launchd on macOS and
134
+ * Scheduled Tasks on Windows, but those aren't restarted with `systemctl`,
135
+ * so on those platforms this honestly reports "not managed here" rather
136
+ * than printing a command that would not work).
137
+ */
138
+ export function detectDaemonServiceManaged(
139
+ platform: NodeJS.Platform,
140
+ configManager: { get(key: string): unknown },
141
+ runCommand: RunCommand = defaultRunCommand,
142
+ ): DaemonServiceRestartInfo {
143
+ const unitName = resolveConfiguredServiceName(configManager);
144
+ if (platform !== 'linux') {
145
+ return { managed: false, unitName, restartCommand: '' };
146
+ }
147
+ const result = runCommand('systemctl', ['--user', 'is-active', `${unitName}.service`]);
148
+ const managed = result.status === 0 && result.stdout.trim() === 'active';
149
+ return { managed, unitName, restartCommand: `systemctl --user restart ${unitName}.service` };
150
+ }
151
+
152
+ export interface CheckForUpdateResult {
153
+ readonly latestTag: string;
154
+ readonly isCurrent: boolean;
155
+ }
156
+
157
+ export async function checkForUpdate(fetchImpl: UpdateFetchLike, currentVersion: string): Promise<CheckForUpdateResult> {
158
+ const latestTag = await resolveLatestReleaseTag(fetchImpl, REPO_RELEASES_LATEST_URL);
159
+ const isCurrent = compareVersions(currentVersion, latestTag) >= 0;
160
+ return { latestTag, isCurrent };
161
+ }
162
+
163
+ export interface ApplyUpdateOptions {
164
+ readonly fetchImpl: UpdateFetchLike;
165
+ readonly execPath: string;
166
+ readonly platform: NodeJS.Platform;
167
+ readonly arch: string;
168
+ readonly currentVersion: string;
169
+ readonly print: (line: string) => void;
170
+ readonly configManager: { get(key: string): unknown };
171
+ 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;
177
+ }
178
+
179
+ /**
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
185
+ * prints the exact command for that install method instead.
186
+ */
187
+ export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
188
+ const installKind: InstallKind = detectInstallKind(options.execPath);
189
+ if (installKind !== 'binary') {
190
+ options.print(
191
+ [
192
+ `This install is not a self-updatable binary install (detected: ${installKind === 'bun-global-package' ? 'bun/npm package install' : 'running from source'}).`,
193
+ `Update with: ${fallbackUpdateCommand(installKind)}`,
194
+ ].join('\n'),
195
+ );
196
+ return;
197
+ }
198
+
199
+ const latestTag = await resolveLatestReleaseTag(options.fetchImpl, REPO_RELEASES_LATEST_URL);
200
+ if (compareVersions(options.currentVersion, latestTag) >= 0) {
201
+ options.print(`Already current: running v${normalizeVersion(options.currentVersion)}, latest release is ${latestTag}.`);
202
+ return;
203
+ }
204
+
205
+ const artifacts = resolveArtifactNames(options.platform, options.arch);
206
+ if (!artifacts) {
207
+ options.print(`No prebuilt binaries are published for ${options.platform}-${options.arch}; cannot self-update. Update with: ${fallbackUpdateCommand('source')}`);
208
+ return;
209
+ }
210
+
211
+ options.print(`Update available: ${latestTag} (running v${normalizeVersion(options.currentVersion)}). Downloading and verifying...`);
212
+
213
+ const baseUrl = releaseDownloadBaseUrl(latestTag);
214
+ const checksumText = await downloadText(options.fetchImpl, `${baseUrl}/${CHECKSUM_MANIFEST_NAME}`);
215
+ const checksums = parseChecksumFile(checksumText);
216
+
217
+ const appBinaryPath = options.execPath;
218
+ 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
+ }
230
+
231
+ // The sqlite-vec native addon travels with the binaries: refresh it in the
232
+ // same download-verify-swap pass so /update never leaves a new binary beside a
233
+ // stale addon. It lands at <execDir>/lib/sqlite-vec-<os>-<arch>/vec0.<suffix>,
234
+ // exactly where the SDK's loader resolves it. The manifest entry decides
235
+ // whether the target release ships it — an entry that IS present makes the
236
+ // download and checksum mandatory (a mismatch is fatal, verified before any
237
+ // swap), while an absent entry means the target predates the addon and is
238
+ // skipped rather than blocking an otherwise-valid binary update. On macOS the
239
+ // file is refreshed for consistency even though the platform blocks extension
240
+ // loading.
241
+ 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
256
+ ? join(dirname(appBinaryPath), 'lib', addon.dirName, addon.fileName)
257
+ : null;
258
+ if (addonBuffer && addonTargetPath) {
259
+ const writeAddon = options.writeAddon ?? writeAddonAtomically;
260
+ writeAddon(addonTargetPath, addonBuffer);
261
+ }
262
+
263
+ const serviceInfo = detectDaemonServiceManaged(options.platform, options.configManager, options.runCommand);
264
+
265
+ options.print(
266
+ [
267
+ `Updated to ${latestTag}.`,
268
+ ` app binary: ${appBinaryPath}`,
269
+ daemonBinaryPresent
270
+ ? ` daemon binary: ${daemonBinaryPath}`
271
+ : ` daemon binary: not found at ${daemonBinaryPath} — left untouched`,
272
+ ...(addonTargetPath ? [` vector addon: ${addonTargetPath}`] : []),
273
+ '',
274
+ 'Restart goodvibes to run the new version.',
275
+ serviceInfo.managed
276
+ ? `The daemon is managed by systemd — restart it with: ${serviceInfo.restartCommand}`
277
+ : 'The daemon restarts automatically the next time goodvibes launches.',
278
+ ].join('\n'),
279
+ );
280
+ }
281
+
282
+ interface UpdateBundle {
283
+ readonly version: 1;
284
+ readonly exportedAt: number;
285
+ readonly appVersion: string;
286
+ readonly installKind: InstallKind;
287
+ readonly subscriptionProviders: readonly string[];
288
+ readonly sandboxProfile: string;
289
+ readonly notes: readonly string[];
290
+ }
291
+
292
+ function inspectUpdateBundle(bundle: UpdateBundle): string {
293
+ return [
294
+ 'Update Bundle Review',
295
+ ` appVersion: ${bundle.appVersion}`,
296
+ ` installKind: ${bundle.installKind}`,
297
+ ` subscriptionProviders: ${bundle.subscriptionProviders.length}`,
298
+ ` sandboxProfile: ${bundle.sandboxProfile}`,
299
+ ` notes: ${bundle.notes.length}`,
300
+ ].join('\n');
301
+ }
302
+
303
+ export function registerUpdateCommand(registry: CommandRegistry): void {
304
+ registry.register({
305
+ name: 'update',
306
+ 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>]',
309
+ async handler(args, ctx) {
310
+ const sub = args[0] ?? 'check';
311
+
312
+ if (sub === 'check') {
313
+ try {
314
+ const result = await checkForUpdate(fetch, VERSION);
315
+ ctx.print(
316
+ result.isCurrent
317
+ ? `Already current: running v${normalizeVersion(VERSION)} (latest release is ${result.latestTag}).`
318
+ : `Update available: ${result.latestTag} (running v${normalizeVersion(VERSION)}). Run /update apply to install it.`,
319
+ );
320
+ } catch (error) {
321
+ ctx.print(`Could not check for updates: ${error instanceof Error ? error.message : String(error)}`);
322
+ }
323
+ return;
324
+ }
325
+
326
+ if (sub === 'apply') {
327
+ try {
328
+ await applyUpdate({
329
+ fetchImpl: fetch,
330
+ execPath: process.execPath,
331
+ platform: process.platform,
332
+ arch: process.arch,
333
+ currentVersion: VERSION,
334
+ print: ctx.print,
335
+ configManager: ctx.platform.configManager,
336
+ });
337
+ } catch (error) {
338
+ ctx.print(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
339
+ }
340
+ return;
341
+ }
342
+
343
+ if (sub === 'review') {
344
+ const installKind = detectInstallKind(process.execPath);
345
+ const subscriptions = requireSubscriptionManager(ctx);
346
+ const builtinProviders = listBuiltinSubscriptionProviders().map((entry) => entry.provider);
347
+ const activeSubscriptions = subscriptions.list().map((entry) => entry.provider);
348
+ const sandboxProfile = [
349
+ `${ctx.platform.configManager.get('sandbox.replIsolation')}`,
350
+ `${ctx.platform.configManager.get('sandbox.mcpIsolation')}`,
351
+ `${ctx.platform.configManager.get('sandbox.vmBackend')}`,
352
+ ].join('/');
353
+ ctx.print(
354
+ [
355
+ 'Update Review',
356
+ ` version: ${VERSION}`,
357
+ ` install kind: ${installKind}`,
358
+ ` built-in subscription providers: ${builtinProviders.length}${builtinProviders.length > 0 ? ` (${builtinProviders.join(', ')})` : ''}`,
359
+ ` active subscriptions: ${activeSubscriptions.length}${activeSubscriptions.length > 0 ? ` (${activeSubscriptions.join(', ')})` : ''}`,
360
+ ` sandbox profile: ${sandboxProfile}`,
361
+ ' use /update check to look for a newer release, /update apply to install it',
362
+ ].join('\n'),
363
+ );
364
+ return;
365
+ }
366
+
367
+ if (sub === 'bundle') {
368
+ const shellPaths = requireShellPaths(ctx);
369
+ const mode = args[1];
370
+ const pathArg = args[2];
371
+ if ((mode === 'export' || mode === 'inspect') && !pathArg) {
372
+ ctx.print(`Usage: /update bundle ${mode} <path>`);
373
+ return;
374
+ }
375
+ const targetPath = shellPaths.resolveWorkspacePath(pathArg!);
376
+ if (mode === 'export') {
377
+ const subscriptions = requireSubscriptionManager(ctx);
378
+ const builtinProviders = listBuiltinSubscriptionProviders().map((entry) => entry.provider);
379
+ const activeSubscriptions = subscriptions.list().map((entry) => entry.provider);
380
+ const sandboxProfile = [
381
+ `${ctx.platform.configManager.get('sandbox.replIsolation')}`,
382
+ `${ctx.platform.configManager.get('sandbox.mcpIsolation')}`,
383
+ `${ctx.platform.configManager.get('sandbox.vmBackend')}`,
384
+ ].join('/');
385
+ const bundle: UpdateBundle = {
386
+ version: 1,
387
+ exportedAt: Date.now(),
388
+ appVersion: VERSION,
389
+ installKind: detectInstallKind(process.execPath),
390
+ subscriptionProviders: [...new Set([...builtinProviders, ...activeSubscriptions])],
391
+ sandboxProfile,
392
+ notes: [
393
+ 'installKind reflects how THIS running process was launched, detected from process.execPath.',
394
+ 'OAuth-backed provider subscriptions survive updates and continue to apply to supported provider surfaces.',
395
+ ],
396
+ };
397
+ mkdirSync(dirname(targetPath), { recursive: true });
398
+ writeFileSync(targetPath, JSON.stringify(bundle, null, 2) + '\n', 'utf-8');
399
+ ctx.print(`Update bundle exported to ${targetPath}`);
400
+ return;
401
+ }
402
+ if (mode === 'inspect') {
403
+ const bundle = JSON.parse(readFileSync(targetPath, 'utf-8')) as UpdateBundle;
404
+ ctx.print(inspectUpdateBundle(bundle));
405
+ return;
406
+ }
407
+ }
408
+
409
+ ctx.print('Usage: /update [check|apply|review|bundle export <path>|bundle inspect <path>]');
410
+ },
411
+ });
412
+ }
@@ -0,0 +1,103 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-attempt-dependents.ts — held-dependent rendering for non-leaf
3
+ // best-of-N groups.
4
+ //
5
+ // A best-of-N item may be NON-LEAF: other items can depend on it. The scheduler
6
+ // holds each such dependent until the group's winner is picked AND merged, then
7
+ // resolves the dependency to that winner (SDK scheduler.ts attemptGroup
8
+ // DependencyStatus). This module mirrors that status computation as a pure
9
+ // function so the /workstream attempts surface can show, honestly, which
10
+ // dependents are held/waiting/released for a group — and reflect the losing
11
+ // attempts' cleanup that the winner pick performs.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Minimal structural view of a WorkItem needed to compute group + dependent status. */
15
+ export interface AttemptGraphItem {
16
+ readonly id: string;
17
+ readonly title: string;
18
+ readonly dependsOn?: readonly string[] | undefined;
19
+ readonly state: string;
20
+ readonly attemptSourceId?: string | undefined;
21
+ readonly attemptWinner?: boolean | undefined;
22
+ readonly mergeState?: string | undefined;
23
+ }
24
+
25
+ export type AttemptGroupDependencyStatus = 'waiting' | 'satisfied' | 'failed' | 'not-a-group';
26
+
27
+ /**
28
+ * Mirror of the SDK scheduler's attemptGroupDependencyStatus: resolve a
29
+ * best-of-N source id to the status of its sibling group.
30
+ * - a picked winner that has merged → 'satisfied' (dependents release)
31
+ * - a picked winner not yet merged → 'waiting'
32
+ * - no winner but some sibling alive → 'waiting'
33
+ * - every sibling failed → 'failed'
34
+ */
35
+ export function attemptGroupDependencyStatus(
36
+ items: readonly AttemptGraphItem[],
37
+ sourceId: string,
38
+ ): AttemptGroupDependencyStatus {
39
+ const siblings = items.filter((i) => i.attemptSourceId === sourceId);
40
+ if (siblings.length === 0) return 'not-a-group';
41
+ const winner = siblings.find((s) => s.attemptWinner === true);
42
+ if (winner) return winner.mergeState === 'merged' ? 'satisfied' : 'waiting';
43
+ const anyNonFailed = siblings.some((s) => s.state !== 'failed');
44
+ return anyNonFailed ? 'waiting' : 'failed';
45
+ }
46
+
47
+ /** The best-of-N source id for a group, resolved from any of its candidate item ids. */
48
+ export function resolveGroupSourceId(
49
+ items: readonly AttemptGraphItem[],
50
+ candidateItemIds: readonly string[],
51
+ ): string | null {
52
+ for (const id of candidateItemIds) {
53
+ const sourceId = items.find((i) => i.id === id)?.attemptSourceId;
54
+ if (sourceId) return sourceId;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** One dependent held by a best-of-N group, with the honest hold descriptor. */
60
+ export interface DependentHold {
61
+ readonly title: string;
62
+ readonly descriptor: string;
63
+ }
64
+
65
+ function holdDescriptor(status: AttemptGroupDependencyStatus): string {
66
+ switch (status) {
67
+ case 'satisfied':
68
+ return 'released — the winner was picked and merged';
69
+ case 'failed':
70
+ return 'blocked — every attempt in the group failed';
71
+ default:
72
+ return 'held until the winner is picked and merged';
73
+ }
74
+ }
75
+
76
+ /** Dependents held by a group's source, each tagged with the group's hold status. */
77
+ export function dependentsHeldByGroup(
78
+ items: readonly AttemptGraphItem[],
79
+ sourceId: string | null,
80
+ ): DependentHold[] {
81
+ if (!sourceId) return [];
82
+ const status = attemptGroupDependencyStatus(items, sourceId);
83
+ const descriptor = holdDescriptor(status);
84
+ return items
85
+ .filter((i) => (i.dependsOn ?? []).includes(sourceId))
86
+ .map((d) => ({ title: d.title, descriptor }));
87
+ }
88
+
89
+ /**
90
+ * Render the dependent-hold block for a best-of-N group. Empty for a leaf group
91
+ * (no dependents) so leaf best-of-N output is unchanged.
92
+ */
93
+ export function renderDependentHoldLines(
94
+ items: readonly AttemptGraphItem[],
95
+ candidateItemIds: readonly string[],
96
+ ): string[] {
97
+ const sourceId = resolveGroupSourceId(items, candidateItemIds);
98
+ const holds = dependentsHeldByGroup(items, sourceId);
99
+ if (holds.length === 0) return [];
100
+ const lines = [` ${holds.length} dependent(s) ${holds[0]!.descriptor}:`];
101
+ for (const h of holds) lines.push(` - "${h.title}"`);
102
+ return lines;
103
+ }