@pellux/goodvibes-tui 1.13.0 → 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.
- package/CHANGELOG.md +101 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +363 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +79 -0
- package/src/runtime/services.ts +38 -37
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- 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,363 @@
|
|
|
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. For any other
|
|
13
|
+
* install kind, prints the exact command to
|
|
14
|
+
* run instead — it never attempts a swap it
|
|
15
|
+
* can't do safely.
|
|
16
|
+
* /update review — install/subscription/sandbox posture,
|
|
17
|
+
* unrelated to the update mechanics above.
|
|
18
|
+
* /update bundle export|inspect <path> — portable posture bundle, as before.
|
|
19
|
+
*
|
|
20
|
+
* The former `/update channel <stable|preview>` subcommand wrote a
|
|
21
|
+
* release.channel config value that nothing downstream ever read — it only
|
|
22
|
+
* ever changed what a later `/update review` printed back to you. It has
|
|
23
|
+
* been removed rather than kept decorative; this repo's release process
|
|
24
|
+
* (scripts/release.ts) does not publish separate stable/preview channels,
|
|
25
|
+
* so there is no real channel selection to wire it to.
|
|
26
|
+
*/
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
31
|
+
import { VERSION } from '../../version.ts';
|
|
32
|
+
import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
|
|
33
|
+
import {
|
|
34
|
+
CHECKSUM_MANIFEST_NAME,
|
|
35
|
+
parseChecksumFile,
|
|
36
|
+
resolveArtifactNames,
|
|
37
|
+
sha256,
|
|
38
|
+
verifyChecksum,
|
|
39
|
+
} from '../../runtime/release-artifacts.ts';
|
|
40
|
+
import {
|
|
41
|
+
compareVersions,
|
|
42
|
+
detectInstallKind,
|
|
43
|
+
fallbackUpdateCommand,
|
|
44
|
+
normalizeVersion,
|
|
45
|
+
resolveLatestReleaseTag,
|
|
46
|
+
type InstallKind,
|
|
47
|
+
type UpdateFetchLike,
|
|
48
|
+
} from '../../runtime/update-check.ts';
|
|
49
|
+
import { resolveConfiguredServiceName } from '../../runtime/legacy-daemon-migration.ts';
|
|
50
|
+
import { requireShellPaths, requireSubscriptionManager } from './runtime-services.ts';
|
|
51
|
+
|
|
52
|
+
const REPO_RELEASES_LATEST_URL = 'https://github.com/mgd34msu/goodvibes-tui/releases/latest';
|
|
53
|
+
|
|
54
|
+
function releaseDownloadBaseUrl(tag: string): string {
|
|
55
|
+
return `https://github.com/mgd34msu/goodvibes-tui/releases/download/${tag}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function downloadText(fetchImpl: UpdateFetchLike, url: string): Promise<string> {
|
|
59
|
+
const response = await fetchImpl(url);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(`download failed (${response.status}) for ${url}`);
|
|
62
|
+
}
|
|
63
|
+
return await response.text();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function downloadBuffer(fetchImpl: UpdateFetchLike, url: string): Promise<Buffer> {
|
|
67
|
+
const response = await fetchImpl(url);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(`download failed (${response.status}) for ${url}`);
|
|
70
|
+
}
|
|
71
|
+
return Buffer.from(await response.arrayBuffer());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Writes the new binary next to the target, then renames over it — an
|
|
76
|
+
* atomic replace on the same filesystem, so a currently-running process
|
|
77
|
+
* that already opened the old file keeps its old inode (POSIX unlink
|
|
78
|
+
* semantics) instead of executing a half-written file. Same approach as
|
|
79
|
+
* scripts/install.sh's `mv -f "$WORKDIR/$artifact" "$target"`.
|
|
80
|
+
*/
|
|
81
|
+
function swapBinaryAtomically(targetPath: string, buffer: Buffer): void {
|
|
82
|
+
const tempPath = `${targetPath}.update-download`;
|
|
83
|
+
writeFileSync(tempPath, buffer);
|
|
84
|
+
if (process.platform !== 'win32') {
|
|
85
|
+
chmodSync(tempPath, 0o755);
|
|
86
|
+
}
|
|
87
|
+
renameSync(tempPath, targetPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DaemonServiceRestartInfo {
|
|
91
|
+
readonly managed: boolean;
|
|
92
|
+
readonly unitName: string;
|
|
93
|
+
readonly restartCommand: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Injectable so tests never shell out to a real systemctl. */
|
|
97
|
+
export type RunCommand = (command: string, args: string[]) => { readonly status: number | null; readonly stdout: string };
|
|
98
|
+
|
|
99
|
+
const defaultRunCommand: RunCommand = (command, args) => {
|
|
100
|
+
try {
|
|
101
|
+
const result = spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8' });
|
|
102
|
+
return { status: result.status, stdout: result.stdout ?? '' };
|
|
103
|
+
} catch {
|
|
104
|
+
return { status: null, stdout: '' };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Detects, honestly, whether the daemon is currently running as a systemd
|
|
110
|
+
* user service — so the post-update message can tell the user the real
|
|
111
|
+
* restart command instead of assuming one. Only Linux/systemd is checked
|
|
112
|
+
* (this repo's PlatformServiceManager also supports launchd on macOS and
|
|
113
|
+
* Scheduled Tasks on Windows, but those aren't restarted with `systemctl`,
|
|
114
|
+
* so on those platforms this honestly reports "not managed here" rather
|
|
115
|
+
* than printing a command that would not work).
|
|
116
|
+
*/
|
|
117
|
+
export function detectDaemonServiceManaged(
|
|
118
|
+
platform: NodeJS.Platform,
|
|
119
|
+
configManager: { get(key: string): unknown },
|
|
120
|
+
runCommand: RunCommand = defaultRunCommand,
|
|
121
|
+
): DaemonServiceRestartInfo {
|
|
122
|
+
const unitName = resolveConfiguredServiceName(configManager);
|
|
123
|
+
if (platform !== 'linux') {
|
|
124
|
+
return { managed: false, unitName, restartCommand: '' };
|
|
125
|
+
}
|
|
126
|
+
const result = runCommand('systemctl', ['--user', 'is-active', `${unitName}.service`]);
|
|
127
|
+
const managed = result.status === 0 && result.stdout.trim() === 'active';
|
|
128
|
+
return { managed, unitName, restartCommand: `systemctl --user restart ${unitName}.service` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface CheckForUpdateResult {
|
|
132
|
+
readonly latestTag: string;
|
|
133
|
+
readonly isCurrent: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function checkForUpdate(fetchImpl: UpdateFetchLike, currentVersion: string): Promise<CheckForUpdateResult> {
|
|
137
|
+
const latestTag = await resolveLatestReleaseTag(fetchImpl, REPO_RELEASES_LATEST_URL);
|
|
138
|
+
const isCurrent = compareVersions(currentVersion, latestTag) >= 0;
|
|
139
|
+
return { latestTag, isCurrent };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ApplyUpdateOptions {
|
|
143
|
+
readonly fetchImpl: UpdateFetchLike;
|
|
144
|
+
readonly execPath: string;
|
|
145
|
+
readonly platform: NodeJS.Platform;
|
|
146
|
+
readonly arch: string;
|
|
147
|
+
readonly currentVersion: string;
|
|
148
|
+
readonly print: (line: string) => void;
|
|
149
|
+
readonly configManager: { get(key: string): unknown };
|
|
150
|
+
readonly runCommand?: RunCommand;
|
|
151
|
+
/** Injectable so tests can observe/skip the actual filesystem swap. */
|
|
152
|
+
readonly swap?: (targetPath: string, buffer: Buffer) => void;
|
|
153
|
+
readonly fileExists?: (path: string) => boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The real self-update path. For a binary install: resolve the latest tag,
|
|
158
|
+
* compare to the running version, and if newer, download + verify both
|
|
159
|
+
* artifacts BEFORE swapping either one (so a checksum failure on the second
|
|
160
|
+
* artifact never leaves a mismatched pair installed), then atomically swap
|
|
161
|
+
* each in place. For any other install kind, never attempts a swap — it
|
|
162
|
+
* prints the exact command for that install method instead.
|
|
163
|
+
*/
|
|
164
|
+
export async function applyUpdate(options: ApplyUpdateOptions): Promise<void> {
|
|
165
|
+
const installKind: InstallKind = detectInstallKind(options.execPath);
|
|
166
|
+
if (installKind !== 'binary') {
|
|
167
|
+
options.print(
|
|
168
|
+
[
|
|
169
|
+
`This install is not a self-updatable binary install (detected: ${installKind === 'bun-global-package' ? 'bun/npm package install' : 'running from source'}).`,
|
|
170
|
+
`Update with: ${fallbackUpdateCommand(installKind)}`,
|
|
171
|
+
].join('\n'),
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const latestTag = await resolveLatestReleaseTag(options.fetchImpl, REPO_RELEASES_LATEST_URL);
|
|
177
|
+
if (compareVersions(options.currentVersion, latestTag) >= 0) {
|
|
178
|
+
options.print(`Already current: running v${normalizeVersion(options.currentVersion)}, latest release is ${latestTag}.`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const artifacts = resolveArtifactNames(options.platform, options.arch);
|
|
183
|
+
if (!artifacts) {
|
|
184
|
+
options.print(`No prebuilt binaries are published for ${options.platform}-${options.arch}; cannot self-update. Update with: ${fallbackUpdateCommand('source')}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
options.print(`Update available: ${latestTag} (running v${normalizeVersion(options.currentVersion)}). Downloading and verifying...`);
|
|
189
|
+
|
|
190
|
+
const baseUrl = releaseDownloadBaseUrl(latestTag);
|
|
191
|
+
const checksumText = await downloadText(options.fetchImpl, `${baseUrl}/${CHECKSUM_MANIFEST_NAME}`);
|
|
192
|
+
const checksums = parseChecksumFile(checksumText);
|
|
193
|
+
|
|
194
|
+
const appBinaryPath = options.execPath;
|
|
195
|
+
const daemonBinaryPath = join(dirname(appBinaryPath), 'goodvibes-daemon');
|
|
196
|
+
const fileExists = options.fileExists ?? existsSync;
|
|
197
|
+
const daemonBinaryPresent = fileExists(daemonBinaryPath);
|
|
198
|
+
|
|
199
|
+
const appBuffer = await downloadBuffer(options.fetchImpl, `${baseUrl}/${artifacts.app}`);
|
|
200
|
+
verifyChecksum(artifacts.app, sha256(appBuffer), checksums.get(artifacts.app));
|
|
201
|
+
|
|
202
|
+
let daemonBuffer: Buffer | null = null;
|
|
203
|
+
if (daemonBinaryPresent) {
|
|
204
|
+
daemonBuffer = await downloadBuffer(options.fetchImpl, `${baseUrl}/${artifacts.daemon}`);
|
|
205
|
+
verifyChecksum(artifacts.daemon, sha256(daemonBuffer), checksums.get(artifacts.daemon));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Both downloads verified before either swap — an update must not apply partially.
|
|
209
|
+
const swap = options.swap ?? swapBinaryAtomically;
|
|
210
|
+
swap(appBinaryPath, appBuffer);
|
|
211
|
+
if (daemonBuffer) {
|
|
212
|
+
swap(daemonBinaryPath, daemonBuffer);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const serviceInfo = detectDaemonServiceManaged(options.platform, options.configManager, options.runCommand);
|
|
216
|
+
|
|
217
|
+
options.print(
|
|
218
|
+
[
|
|
219
|
+
`Updated to ${latestTag}.`,
|
|
220
|
+
` app binary: ${appBinaryPath}`,
|
|
221
|
+
daemonBinaryPresent
|
|
222
|
+
? ` daemon binary: ${daemonBinaryPath}`
|
|
223
|
+
: ` daemon binary: not found at ${daemonBinaryPath} — left untouched`,
|
|
224
|
+
'',
|
|
225
|
+
'Restart goodvibes to run the new version.',
|
|
226
|
+
serviceInfo.managed
|
|
227
|
+
? `The daemon is managed by systemd — restart it with: ${serviceInfo.restartCommand}`
|
|
228
|
+
: 'The daemon restarts automatically the next time goodvibes launches.',
|
|
229
|
+
].join('\n'),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface UpdateBundle {
|
|
234
|
+
readonly version: 1;
|
|
235
|
+
readonly exportedAt: number;
|
|
236
|
+
readonly appVersion: string;
|
|
237
|
+
readonly installKind: InstallKind;
|
|
238
|
+
readonly subscriptionProviders: readonly string[];
|
|
239
|
+
readonly sandboxProfile: string;
|
|
240
|
+
readonly notes: readonly string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function inspectUpdateBundle(bundle: UpdateBundle): string {
|
|
244
|
+
return [
|
|
245
|
+
'Update Bundle Review',
|
|
246
|
+
` appVersion: ${bundle.appVersion}`,
|
|
247
|
+
` installKind: ${bundle.installKind}`,
|
|
248
|
+
` subscriptionProviders: ${bundle.subscriptionProviders.length}`,
|
|
249
|
+
` sandboxProfile: ${bundle.sandboxProfile}`,
|
|
250
|
+
` notes: ${bundle.notes.length}`,
|
|
251
|
+
].join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function registerUpdateCommand(registry: CommandRegistry): void {
|
|
255
|
+
registry.register({
|
|
256
|
+
name: 'update',
|
|
257
|
+
aliases: ['upgrade'],
|
|
258
|
+
description: 'Check for a newer GoodVibes release and, for binary installs, download/verify/apply it',
|
|
259
|
+
usage: '[check|apply|review|bundle export <path>|bundle inspect <path>]',
|
|
260
|
+
async handler(args, ctx) {
|
|
261
|
+
const sub = args[0] ?? 'check';
|
|
262
|
+
|
|
263
|
+
if (sub === 'check') {
|
|
264
|
+
try {
|
|
265
|
+
const result = await checkForUpdate(fetch, VERSION);
|
|
266
|
+
ctx.print(
|
|
267
|
+
result.isCurrent
|
|
268
|
+
? `Already current: running v${normalizeVersion(VERSION)} (latest release is ${result.latestTag}).`
|
|
269
|
+
: `Update available: ${result.latestTag} (running v${normalizeVersion(VERSION)}). Run /update apply to install it.`,
|
|
270
|
+
);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
ctx.print(`Could not check for updates: ${error instanceof Error ? error.message : String(error)}`);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (sub === 'apply') {
|
|
278
|
+
try {
|
|
279
|
+
await applyUpdate({
|
|
280
|
+
fetchImpl: fetch,
|
|
281
|
+
execPath: process.execPath,
|
|
282
|
+
platform: process.platform,
|
|
283
|
+
arch: process.arch,
|
|
284
|
+
currentVersion: VERSION,
|
|
285
|
+
print: ctx.print,
|
|
286
|
+
configManager: ctx.platform.configManager,
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
ctx.print(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (sub === 'review') {
|
|
295
|
+
const installKind = detectInstallKind(process.execPath);
|
|
296
|
+
const subscriptions = requireSubscriptionManager(ctx);
|
|
297
|
+
const builtinProviders = listBuiltinSubscriptionProviders().map((entry) => entry.provider);
|
|
298
|
+
const activeSubscriptions = subscriptions.list().map((entry) => entry.provider);
|
|
299
|
+
const sandboxProfile = [
|
|
300
|
+
`${ctx.platform.configManager.get('sandbox.replIsolation')}`,
|
|
301
|
+
`${ctx.platform.configManager.get('sandbox.mcpIsolation')}`,
|
|
302
|
+
`${ctx.platform.configManager.get('sandbox.vmBackend')}`,
|
|
303
|
+
].join('/');
|
|
304
|
+
ctx.print(
|
|
305
|
+
[
|
|
306
|
+
'Update Review',
|
|
307
|
+
` version: ${VERSION}`,
|
|
308
|
+
` install kind: ${installKind}`,
|
|
309
|
+
` built-in subscription providers: ${builtinProviders.length}${builtinProviders.length > 0 ? ` (${builtinProviders.join(', ')})` : ''}`,
|
|
310
|
+
` active subscriptions: ${activeSubscriptions.length}${activeSubscriptions.length > 0 ? ` (${activeSubscriptions.join(', ')})` : ''}`,
|
|
311
|
+
` sandbox profile: ${sandboxProfile}`,
|
|
312
|
+
' use /update check to look for a newer release, /update apply to install it',
|
|
313
|
+
].join('\n'),
|
|
314
|
+
);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (sub === 'bundle') {
|
|
319
|
+
const shellPaths = requireShellPaths(ctx);
|
|
320
|
+
const mode = args[1];
|
|
321
|
+
const pathArg = args[2];
|
|
322
|
+
if ((mode === 'export' || mode === 'inspect') && !pathArg) {
|
|
323
|
+
ctx.print(`Usage: /update bundle ${mode} <path>`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const targetPath = shellPaths.resolveWorkspacePath(pathArg!);
|
|
327
|
+
if (mode === 'export') {
|
|
328
|
+
const subscriptions = requireSubscriptionManager(ctx);
|
|
329
|
+
const builtinProviders = listBuiltinSubscriptionProviders().map((entry) => entry.provider);
|
|
330
|
+
const activeSubscriptions = subscriptions.list().map((entry) => entry.provider);
|
|
331
|
+
const sandboxProfile = [
|
|
332
|
+
`${ctx.platform.configManager.get('sandbox.replIsolation')}`,
|
|
333
|
+
`${ctx.platform.configManager.get('sandbox.mcpIsolation')}`,
|
|
334
|
+
`${ctx.platform.configManager.get('sandbox.vmBackend')}`,
|
|
335
|
+
].join('/');
|
|
336
|
+
const bundle: UpdateBundle = {
|
|
337
|
+
version: 1,
|
|
338
|
+
exportedAt: Date.now(),
|
|
339
|
+
appVersion: VERSION,
|
|
340
|
+
installKind: detectInstallKind(process.execPath),
|
|
341
|
+
subscriptionProviders: [...new Set([...builtinProviders, ...activeSubscriptions])],
|
|
342
|
+
sandboxProfile,
|
|
343
|
+
notes: [
|
|
344
|
+
'installKind reflects how THIS running process was launched, detected from process.execPath.',
|
|
345
|
+
'OAuth-backed provider subscriptions survive updates and continue to apply to supported provider surfaces.',
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
349
|
+
writeFileSync(targetPath, JSON.stringify(bundle, null, 2) + '\n', 'utf-8');
|
|
350
|
+
ctx.print(`Update bundle exported to ${targetPath}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (mode === 'inspect') {
|
|
354
|
+
const bundle = JSON.parse(readFileSync(targetPath, 'utf-8')) as UpdateBundle;
|
|
355
|
+
ctx.print(inspectUpdateBundle(bundle));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
ctx.print('Usage: /update [check|apply|review|bundle export <path>|bundle inspect <path>]');
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -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
|
+
}
|