@pellux/goodvibes-agent 1.9.1 → 1.10.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 +10 -0
- package/README.md +12 -1
- package/dist/package/main.js +36712 -28803
- package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
- package/docs/README.md +1 -1
- package/docs/connected-host.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/tools-and-commands.md +1 -0
- package/package.json +3 -3
- package/release/live-verification/live-verification.json +11 -11
- package/release/live-verification/live-verification.md +12 -12
- package/src/agent/email/email-service.ts +1 -1
- package/src/agent/email/imap-client.ts +4 -4
- package/src/agent/email/smtp-client.ts +5 -5
- package/src/cli/config-overrides.ts +29 -14
- package/src/cli/entrypoint.ts +12 -4
- package/src/cli/launch-auto-update.ts +218 -0
- package/src/cli/relay-command.ts +4 -4
- package/src/cli/service-posture.ts +2 -2
- package/src/cli/tui-startup.ts +31 -0
- package/src/cli/workspaces-command.ts +5 -1
- package/src/cli-flags.ts +1 -1
- package/src/config/index.ts +1 -1
- package/src/config/update-settings.ts +45 -0
- package/src/config/workspace-registration.ts +214 -15
- package/src/input/commands/runtime-services.ts +0 -5
- package/src/input/commands/update-runtime.ts +313 -0
- package/src/input/commands.ts +2 -0
- package/src/input/feed-context-factory.ts +2 -2
- package/src/input/handler-feed.ts +8 -8
- package/src/input/handler.ts +1 -1
- package/src/input/mcp-workspace.ts +5 -1
- package/src/input/panel-paste-flood-guard.ts +1 -1
- package/src/input/settings-modal-types.ts +11 -5
- package/src/input/settings-modal.ts +56 -61
- package/src/main.ts +19 -20
- package/src/renderer/activity-sidebar.ts +53 -4
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +37 -25
- package/src/runtime/bootstrap-core.ts +16 -5
- package/src/runtime/bootstrap-external-services.ts +107 -11
- package/src/runtime/bootstrap-hook-bridge.ts +2 -2
- package/src/runtime/bootstrap-shell.ts +4 -4
- package/src/runtime/bootstrap.ts +34 -12
- package/src/runtime/connected-host-autostart.ts +269 -0
- package/src/runtime/daemon-receipts.ts +66 -0
- package/src/runtime/diagnostics/panels/index.ts +0 -2
- package/src/runtime/feature-enablement.ts +176 -0
- package/src/runtime/index.ts +12 -29
- package/src/runtime/memory-spine-adoption.ts +18 -0
- package/src/runtime/onboarding/apply.ts +50 -37
- package/src/runtime/onboarding/types.ts +2 -2
- package/src/runtime/onboarding/verify.ts +22 -4
- package/src/runtime/release-artifacts.ts +113 -0
- package/src/runtime/services.ts +228 -19
- package/src/runtime/session-spine-rest-transport.ts +84 -4
- package/src/runtime/ui-services.ts +1 -1
- package/src/runtime/update-check.ts +64 -0
- package/src/shell/ui-openers.ts +8 -0
- package/src/tools/agent-harness-metadata.ts +8 -0
- package/src/tools/agent-harness-setup-connected-host.ts +1 -1
- package/src/tools/agent-harness-setup-posture.ts +1 -1
- package/src/version.ts +1 -1
- package/src/runtime/diagnostics/panels/ops.ts +0 -156
- package/src/runtime/surface-feature-flags.ts +0 -100
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-time self-update — the agent lands on the newest release at startup
|
|
3
|
+
* so an installed binary never drifts behind. At interactive launch (before
|
|
4
|
+
* any runtime bootstrap or terminal mode change) this runs a quick version
|
|
5
|
+
* check and, when a newer release exists, installs it through the SAME
|
|
6
|
+
* checksum-verified download/verify/swap path `/update apply` uses
|
|
7
|
+
* (src/input/commands/update-runtime.ts — there is deliberately no second
|
|
8
|
+
* updater), then asks the caller to restart onto the new binary.
|
|
9
|
+
*
|
|
10
|
+
* Honesty rules, in both directions:
|
|
11
|
+
* - the check gets a short timeout; when it cannot complete (offline, slow
|
|
12
|
+
* network) the CURRENT version starts with exactly one line saying the
|
|
13
|
+
* check was skipped — launch is never held hostage by the network.
|
|
14
|
+
* - a successful update restarts into the new binary and the restarted
|
|
15
|
+
* process prints a receipt naming both versions, so the swap is never
|
|
16
|
+
* silent.
|
|
17
|
+
* - every swap keeps the outgoing file at `<path>.previous`; rollback is
|
|
18
|
+
* one command (`/update rollback`).
|
|
19
|
+
*
|
|
20
|
+
* The feature is a real configurable setting (`update.autoUpdateAtLaunch` in
|
|
21
|
+
* settings.json, read via readUpdateSettings): default ON, off with an
|
|
22
|
+
* explicit false. Only binary installs self-update — a package-managed
|
|
23
|
+
* install or a from-source dev checkout skips with one honest line naming
|
|
24
|
+
* why (a swap there would fight the package manager, or there is no compiled
|
|
25
|
+
* file to swap; see detectInstallKind).
|
|
26
|
+
*
|
|
27
|
+
* Everything effectful is injectable; tests drive the decision logic with a
|
|
28
|
+
* stubbed fetch, a stubbed apply, and pinned fixture versions — never the
|
|
29
|
+
* live build VERSION and never the real network.
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { detectInstallKind, normalizeVersion, type UpdateFetchLike } from '../runtime/update-check.ts';
|
|
33
|
+
import { applyUpdate, checkForUpdate, type ApplyUpdateOptions } from '../input/commands/update-runtime.ts';
|
|
34
|
+
import { readUpdateSettings, type UpdateSettings } from '../config/update-settings.ts';
|
|
35
|
+
import type { ConfigManager } from '../config/index.ts';
|
|
36
|
+
import { VERSION } from '../version.ts';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Set on the restarted process by restartOntoUpdatedBinary, carrying the
|
|
40
|
+
* version that performed the update. Its presence means "an update already
|
|
41
|
+
* happened this launch": the restarted process prints the receipt and skips
|
|
42
|
+
* its own check, which also makes an update-that-didn't-change-the-version
|
|
43
|
+
* structurally unable to restart-loop.
|
|
44
|
+
*/
|
|
45
|
+
export const LAUNCH_UPDATED_FROM_ENV = 'GOODVIBES_AGENT_LAUNCH_UPDATED_FROM';
|
|
46
|
+
|
|
47
|
+
/** Default budget for the launch-time version check; user-tunable via update.launchCheckTimeoutMs. */
|
|
48
|
+
export const LAUNCH_UPDATE_CHECK_TIMEOUT_MS = 2500;
|
|
49
|
+
|
|
50
|
+
export type LaunchAutoUpdateOutcome =
|
|
51
|
+
| {
|
|
52
|
+
readonly action: 'continue';
|
|
53
|
+
readonly reason: 'just-updated' | 'disabled' | 'not-swappable-install' | 'already-current' | 'check-skipped' | 'update-failed';
|
|
54
|
+
}
|
|
55
|
+
| { readonly action: 'restart'; readonly latestTag: string };
|
|
56
|
+
|
|
57
|
+
export interface RunLaunchAutoUpdateOptions {
|
|
58
|
+
readonly fetchImpl: UpdateFetchLike;
|
|
59
|
+
readonly execPath: string;
|
|
60
|
+
readonly platform: NodeJS.Platform;
|
|
61
|
+
readonly arch: string;
|
|
62
|
+
readonly currentVersion: string;
|
|
63
|
+
readonly settings: UpdateSettings;
|
|
64
|
+
readonly env: NodeJS.ProcessEnv;
|
|
65
|
+
readonly print: (line: string) => void;
|
|
66
|
+
/** Injectable so tests observe the install step instead of swapping real files. */
|
|
67
|
+
readonly apply?: (options: ApplyUpdateOptions) => Promise<void>;
|
|
68
|
+
readonly timeoutMs?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolves to 'timeout' when `promise` does not settle within `ms`; the timer never keeps the process alive. */
|
|
72
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | 'timeout'> {
|
|
73
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
74
|
+
try {
|
|
75
|
+
return await Promise.race([
|
|
76
|
+
promise,
|
|
77
|
+
new Promise<'timeout'>((resolve) => {
|
|
78
|
+
timer = setTimeout(() => resolve('timeout'), ms);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The launch-time decision: receipt-and-continue (restarted process), skip
|
|
89
|
+
* (disabled / unswappable / current / unreachable), install-and-restart, or
|
|
90
|
+
* fail-and-continue on the current version. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export async function runLaunchAutoUpdate(options: RunLaunchAutoUpdateOptions): Promise<LaunchAutoUpdateOutcome> {
|
|
93
|
+
const updatedFrom = options.env[LAUNCH_UPDATED_FROM_ENV];
|
|
94
|
+
if (typeof updatedFrom === 'string' && updatedFrom.length > 0) {
|
|
95
|
+
// Consumed here so sessions spawned from inside this one never inherit it.
|
|
96
|
+
delete options.env[LAUNCH_UPDATED_FROM_ENV];
|
|
97
|
+
options.print(`auto-update: updated from v${normalizeVersion(updatedFrom)} to v${normalizeVersion(options.currentVersion)} at launch`);
|
|
98
|
+
return { action: 'continue', reason: 'just-updated' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!(options.settings.autoUpdateAtLaunch ?? true)) {
|
|
102
|
+
return { action: 'continue', reason: 'disabled' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const installKind = detectInstallKind(options.execPath);
|
|
106
|
+
if (installKind !== 'binary') {
|
|
107
|
+
// Honest, single-line skip: only a compiled release binary can be swapped
|
|
108
|
+
// in place, and the skip should never look like an update happened.
|
|
109
|
+
options.print(installKind === 'source'
|
|
110
|
+
? 'auto-update skipped: running from source (dev checkout)'
|
|
111
|
+
: 'auto-update skipped: package-managed install — update with: bun add -g @pellux/goodvibes-agent');
|
|
112
|
+
return { action: 'continue', reason: 'not-swappable-install' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? options.settings.launchCheckTimeoutMs ?? LAUNCH_UPDATE_CHECK_TIMEOUT_MS;
|
|
116
|
+
let check: Awaited<ReturnType<typeof checkForUpdate>> | 'timeout';
|
|
117
|
+
try {
|
|
118
|
+
check = await withTimeout(checkForUpdate(options.fetchImpl, options.currentVersion), timeoutMs);
|
|
119
|
+
} catch {
|
|
120
|
+
check = 'timeout';
|
|
121
|
+
}
|
|
122
|
+
if (check === 'timeout') {
|
|
123
|
+
options.print('update check skipped: offline');
|
|
124
|
+
return { action: 'continue', reason: 'check-skipped' };
|
|
125
|
+
}
|
|
126
|
+
if (check.isCurrent) {
|
|
127
|
+
return { action: 'continue', reason: 'already-current' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const apply = options.apply ?? applyUpdate;
|
|
132
|
+
await apply({
|
|
133
|
+
fetchImpl: options.fetchImpl,
|
|
134
|
+
execPath: options.execPath,
|
|
135
|
+
platform: options.platform,
|
|
136
|
+
arch: options.arch,
|
|
137
|
+
currentVersion: options.currentVersion,
|
|
138
|
+
print: options.print,
|
|
139
|
+
});
|
|
140
|
+
options.print(`auto-update: ${check.latestTag} installed — restarting onto the new version`);
|
|
141
|
+
return { action: 'restart', latestTag: check.latestTag };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
options.print(
|
|
144
|
+
`auto-update failed: ${error instanceof Error ? error.message : String(error)} — starting the current version v${normalizeVersion(options.currentVersion)}`,
|
|
145
|
+
);
|
|
146
|
+
return { action: 'continue', reason: 'update-failed' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface SelfUpdateAtLaunchParams {
|
|
151
|
+
readonly configManager: Pick<ConfigManager, 'getRaw'>;
|
|
152
|
+
readonly stdout: { write(chunk: string): unknown };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The complete launch wiring for main(), with real host inputs: run the
|
|
157
|
+
* decision above; when an update was installed, restart onto the swapped
|
|
158
|
+
* binary and EXIT THIS PROCESS with the new instance's exit code (this call
|
|
159
|
+
* never returns in that case); otherwise return the honest lines that were
|
|
160
|
+
* printed (receipt / skipped check / failed update) so the caller can
|
|
161
|
+
* re-surface them through the system message router once it exists — the
|
|
162
|
+
* stdout copies written here are wiped by the agent's alternate screen.
|
|
163
|
+
*/
|
|
164
|
+
export async function selfUpdateAtLaunch(params: SelfUpdateAtLaunchParams): Promise<readonly string[]> {
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
const outcome = await runLaunchAutoUpdate({
|
|
167
|
+
fetchImpl: fetch as UpdateFetchLike,
|
|
168
|
+
execPath: process.execPath,
|
|
169
|
+
platform: process.platform,
|
|
170
|
+
arch: process.arch,
|
|
171
|
+
currentVersion: VERSION,
|
|
172
|
+
settings: readUpdateSettings(params.configManager),
|
|
173
|
+
env: process.env,
|
|
174
|
+
print: (line) => {
|
|
175
|
+
lines.push(line);
|
|
176
|
+
params.stdout.write(`${line}\n`);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (outcome.action === 'restart') {
|
|
180
|
+
process.exit(
|
|
181
|
+
restartOntoUpdatedBinary({
|
|
182
|
+
execPath: process.execPath,
|
|
183
|
+
argv: process.argv.slice(2),
|
|
184
|
+
env: process.env,
|
|
185
|
+
fromVersion: VERSION,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface RestartOntoUpdatedBinaryOptions {
|
|
193
|
+
readonly execPath: string;
|
|
194
|
+
readonly argv: readonly string[];
|
|
195
|
+
readonly env: NodeJS.ProcessEnv;
|
|
196
|
+
/** The version that performed the update — the restarted process prints it in its receipt line. */
|
|
197
|
+
readonly fromVersion: string;
|
|
198
|
+
/** Injectable so tests observe the restart instead of spawning a process. */
|
|
199
|
+
readonly spawn?: (execPath: string, argv: readonly string[], env: NodeJS.ProcessEnv) => number | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Runs the (just-swapped) binary at execPath with the original CLI arguments
|
|
204
|
+
* and an inherited terminal, blocking until it exits, and returns its exit
|
|
205
|
+
* code for the caller to process.exit with. The receipt marker travels in the
|
|
206
|
+
* child's environment, never on the command line.
|
|
207
|
+
*/
|
|
208
|
+
export function restartOntoUpdatedBinary(options: RestartOntoUpdatedBinaryOptions): number {
|
|
209
|
+
const spawn =
|
|
210
|
+
options.spawn ??
|
|
211
|
+
((execPath: string, argv: readonly string[], env: NodeJS.ProcessEnv) =>
|
|
212
|
+
spawnSync(execPath, [...argv], { stdio: 'inherit', env }).status);
|
|
213
|
+
const status = spawn(options.execPath, options.argv, {
|
|
214
|
+
...options.env,
|
|
215
|
+
[LAUNCH_UPDATED_FROM_ENV]: options.fromVersion,
|
|
216
|
+
});
|
|
217
|
+
return status ?? 0;
|
|
218
|
+
}
|
package/src/cli/relay-command.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FEATURE_SETTINGS } from '@pellux/goodvibes-sdk/platform/runtime/state';
|
|
2
2
|
import type { CliCommandOutput } from './types.ts';
|
|
3
3
|
import type { CliCommandRuntime } from './management.ts';
|
|
4
4
|
|
|
@@ -35,11 +35,11 @@ function jsonOrText(runtime: CliCommandRuntime, value: unknown, text: string): s
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function relayConnectFlagSummary(): { readonly id: string; readonly name: string; readonly defaultState: string } {
|
|
38
|
-
const
|
|
38
|
+
const feature = FEATURE_SETTINGS.find((entry) => entry.id === 'relay-connect');
|
|
39
39
|
return {
|
|
40
40
|
id: 'relay-connect',
|
|
41
|
-
name:
|
|
42
|
-
defaultState:
|
|
41
|
+
name: feature?.name ?? 'Outbound Zero-Knowledge Relay',
|
|
42
|
+
defaultState: feature === undefined ? 'disabled' : feature.defaultEnabled ? 'enabled' : 'disabled',
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -226,9 +226,9 @@ export function formatCliServicePosture(posture: CliServicePosture, json = false
|
|
|
226
226
|
return [
|
|
227
227
|
'GoodVibes Agent connected-host diagnostics',
|
|
228
228
|
' lifecycle owner: outside goodvibes-agent',
|
|
229
|
-
' Agent starts connected host:
|
|
229
|
+
' Agent starts connected host: only at boot, when it is installed but stopped',
|
|
230
230
|
` external host config present: ${yesNo(posture.config.enabled)}`,
|
|
231
|
-
' external host lifecycle config:
|
|
231
|
+
' external host lifecycle config: only the service name is read, for the boot start check',
|
|
232
232
|
` daemon considered enabled: ${yesNo(posture.config.daemonEnabled)}`,
|
|
233
233
|
` log: ${posture.log.path ?? 'n/a'} (${posture.log.exists ? 'present' : 'missing'})`,
|
|
234
234
|
...(posture.log.readError ? [` log read error: ${posture.log.readError}`] : []),
|
package/src/cli/tui-startup.ts
CHANGED
|
@@ -45,6 +45,37 @@ export function formatFatalStartupErrorForLog(error: unknown): string {
|
|
|
45
45
|
return fatalStartupStack(error);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The one fatal-startup exit path for main(): log the full detail, print the
|
|
50
|
+
* user-facing explanation to stderr, and exit 1. Lives beside the two
|
|
51
|
+
* formatters it composes so main.ts carries no error-formatting plumbing of
|
|
52
|
+
* its own. Both writes are individually best-effort — a failing logger or a
|
|
53
|
+
* torn-down stderr must never hide the original launch failure.
|
|
54
|
+
*/
|
|
55
|
+
export function reportFatalStartupError(
|
|
56
|
+
err: unknown,
|
|
57
|
+
options: FatalStartupFormatOptions,
|
|
58
|
+
sinks: {
|
|
59
|
+
readonly logError: (message: string, context: Record<string, unknown>) => void;
|
|
60
|
+
readonly writeStderr: (chunk: string) => void;
|
|
61
|
+
readonly exit: (code: number) => void;
|
|
62
|
+
},
|
|
63
|
+
): void {
|
|
64
|
+
const detail = formatFatalStartupErrorForLog(err);
|
|
65
|
+
try {
|
|
66
|
+
sinks.logError('Fatal error', { error: detail });
|
|
67
|
+
} catch {
|
|
68
|
+
// Startup diagnostics must never hide the original launch failure.
|
|
69
|
+
}
|
|
70
|
+
const userDetail = formatFatalStartupErrorForUser(err, options);
|
|
71
|
+
try {
|
|
72
|
+
sinks.writeStderr(`${options.binary} failed to launch:\n${userDetail}\n`);
|
|
73
|
+
} catch {
|
|
74
|
+
// Ignore secondary stderr failures during process teardown.
|
|
75
|
+
}
|
|
76
|
+
sinks.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
export function formatFatalStartupErrorForUser(error: unknown, options: FatalStartupFormatOptions): string {
|
|
49
80
|
if (options.debug === true) return fatalStartupStack(error);
|
|
50
81
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createWorkspaceRegistrationStore,
|
|
7
7
|
migrateLegacyWorkspaceRegistryIfNeeded,
|
|
8
8
|
normalizeWorkspaceRoot,
|
|
9
|
+
registerWorkspaceForCheckpoints,
|
|
9
10
|
} from '../config/workspace-registration.ts';
|
|
10
11
|
import type { CliCommandOutput } from './types.ts';
|
|
11
12
|
import type { CliCommandRuntime } from './management.ts';
|
|
@@ -108,7 +109,10 @@ export async function handleWorkspacesCommand(runtime: CliCommandRuntime): Promi
|
|
|
108
109
|
}
|
|
109
110
|
const label = flagValue(rawRest, ['--label']) ?? undefined;
|
|
110
111
|
try {
|
|
111
|
-
|
|
112
|
+
// Explicit registration: registers AND stamps checkpoint-eligibility, so
|
|
113
|
+
// this workspace becomes an owner-opted checkpoint boundary (a plain
|
|
114
|
+
// store.add would register without enabling automatic checkpoints).
|
|
115
|
+
const result = await registerWorkspaceForCheckpoints(shellPaths, target, label ? { label } : undefined);
|
|
112
116
|
const text = result.alreadyRegistered
|
|
113
117
|
? `Workspace already registered: ${result.record.root}`
|
|
114
118
|
: `Workspace registered: ${result.record.root}\n automatic checkpoints are now allowed for this workspace`;
|
package/src/cli-flags.ts
CHANGED
package/src/config/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
export { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
10
10
|
export type { DeepReadonly } from '@pellux/goodvibes-sdk/platform/config';
|
|
11
|
-
export type { GoodVibesConfig, ConfigKey, ConfigValue, ConfigSetting, PermissionMode, PermissionAction, PermissionsToolConfig, NotificationsConfig
|
|
11
|
+
export type { GoodVibesConfig, ConfigKey, ConfigValue, ConfigSetting, PermissionMode, PermissionAction, PermissionsToolConfig, NotificationsConfig } from '@pellux/goodvibes-sdk/platform/config';
|
|
12
12
|
export { DEFAULT_CONFIG, CONFIG_SCHEMA } from '@pellux/goodvibes-sdk/platform/config';
|
|
13
13
|
export { ConfigError } from '@pellux/goodvibes-sdk/platform/types';
|
|
14
14
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `update.*` — launch-time self-update behavior.
|
|
3
|
+
*
|
|
4
|
+
* The SDK owns settings.json's typed schema, but its loader deep-merges user
|
|
5
|
+
* JSON over the defaults and keeps unknown top-level namespaces through
|
|
6
|
+
* `getRaw()` (the same passthrough contract checkpoint-settings.ts documents),
|
|
7
|
+
* so the agent keeps its own `update` namespace in the same file and reads it
|
|
8
|
+
* back here.
|
|
9
|
+
*
|
|
10
|
+
* The reader hand-validates each field and returns a PARTIAL object holding
|
|
11
|
+
* only the keys the user actually set to a well-typed value — a missing or
|
|
12
|
+
* malformed block degrades to "use the built-in defaults", never a crash. The
|
|
13
|
+
* defaults themselves live in the consumer (src/cli/launch-auto-update.ts):
|
|
14
|
+
* the feature defaults ON for binary installs, per the recorded owner
|
|
15
|
+
* directive that clients update on start, and `update.autoUpdateAtLaunch:
|
|
16
|
+
* false` is the explicit, persisted off switch.
|
|
17
|
+
*/
|
|
18
|
+
import type { ConfigManager } from './index.ts';
|
|
19
|
+
|
|
20
|
+
type RawRecord = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
export interface UpdateSettings {
|
|
23
|
+
/** Check for a newer release at launch and install it before starting. Default: true. */
|
|
24
|
+
readonly autoUpdateAtLaunch?: boolean;
|
|
25
|
+
/** How long the launch-time version check may take before it is skipped. Defaults to 2500; clamped to [250, 30000]. */
|
|
26
|
+
readonly launchCheckTimeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LAUNCH_CHECK_MIN_TIMEOUT_MS = 250;
|
|
30
|
+
const LAUNCH_CHECK_MAX_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** Read `update.*` from settings.json, validating and clamping the timeout. */
|
|
33
|
+
export function readUpdateSettings(configManager: Pick<ConfigManager, 'getRaw'>): UpdateSettings {
|
|
34
|
+
const raw = configManager.getRaw() as unknown as RawRecord;
|
|
35
|
+
const block = raw['update'];
|
|
36
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) return {};
|
|
37
|
+
const src = block as RawRecord;
|
|
38
|
+
const out: { autoUpdateAtLaunch?: boolean; launchCheckTimeoutMs?: number } = {};
|
|
39
|
+
if (typeof src['autoUpdateAtLaunch'] === 'boolean') out.autoUpdateAtLaunch = src['autoUpdateAtLaunch'];
|
|
40
|
+
const timeout = src['launchCheckTimeoutMs'];
|
|
41
|
+
if (typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0) {
|
|
42
|
+
out.launchCheckTimeoutMs = Math.min(LAUNCH_CHECK_MAX_TIMEOUT_MS, Math.max(LAUNCH_CHECK_MIN_TIMEOUT_MS, Math.floor(timeout)));
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { ShellPathService } from '@/runtime/index.ts';
|
|
|
4
4
|
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
5
5
|
import {
|
|
6
6
|
WorkspaceRegistrationStore,
|
|
7
|
+
type RegisterWorkspaceResult,
|
|
7
8
|
resolveWorkspaceRegistration,
|
|
8
9
|
normalizeWorkspaceRoot,
|
|
9
10
|
probeWorktreeLink,
|
|
@@ -23,6 +24,27 @@ export type {
|
|
|
23
24
|
WorkspaceResolution,
|
|
24
25
|
} from '@pellux/goodvibes-sdk/platform/workspace';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* CHECKPOINT-ELIGIBILITY BOUNDARY (owner ruling: the checkpoint boundary stays
|
|
29
|
+
* EXPLICIT, never silently widened).
|
|
30
|
+
*
|
|
31
|
+
* The shared registration store is ONE file the whole platform reads and writes
|
|
32
|
+
* (~/.goodvibes/control-plane/workspace-registrations.json). The TUI's first-open
|
|
33
|
+
* self-recording writes a plain {@link RegisteredWorkspaceRecord} to it for any
|
|
34
|
+
* directory a user merely opens — so "registered in the store" can no longer mean
|
|
35
|
+
* "the owner opted this workspace into automatic checkpoints". The agent's
|
|
36
|
+
* checkpoint boundary must therefore consume ONLY records the owner EXPLICITLY
|
|
37
|
+
* registered for checkpoints, marked by `checkpointEligible === true`.
|
|
38
|
+
*
|
|
39
|
+
* The SDK record schema natively carries both fields (typed on
|
|
40
|
+
* {@link RegisteredWorkspaceRecord}): `origin` — which flow wrote/stamped the
|
|
41
|
+
* record, provenance only — and `checkpointEligible`, where ABSENT MEANS FALSE.
|
|
42
|
+
* The store's `add` stamps/upgrades them typed, and absent options never strip
|
|
43
|
+
* an existing stamp, so one surface's plain self-recording cannot demote
|
|
44
|
+
* another consumer's eligibility.
|
|
45
|
+
*/
|
|
46
|
+
export const AGENT_EXPLICIT_REGISTRATION_ORIGIN = 'agent-explicit-registration';
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* Shared registered-workspace registry (SDK 1.6.1 platform/workspace/registration),
|
|
28
50
|
* the successor to this fork's local per-user JSON registry
|
|
@@ -76,7 +98,15 @@ function parseRegisteredRecord(value: unknown): RegisteredWorkspaceRecord | null
|
|
|
76
98
|
const registeredAt = readString(value.registeredAt);
|
|
77
99
|
if (!root || !registeredAt || Number.isNaN(Date.parse(registeredAt))) return null;
|
|
78
100
|
const label = readString(value.label);
|
|
79
|
-
|
|
101
|
+
const origin = readString(value.origin);
|
|
102
|
+
return {
|
|
103
|
+
root: normalizeWorkspaceRoot(root),
|
|
104
|
+
registeredAt,
|
|
105
|
+
...(label ? { label } : {}),
|
|
106
|
+
...(origin ? { origin } : {}),
|
|
107
|
+
// Strictly `true` only; any other value (including absent) is not eligible.
|
|
108
|
+
...(value.checkpointEligible === true ? { checkpointEligible: true } : {}),
|
|
109
|
+
};
|
|
80
110
|
}
|
|
81
111
|
|
|
82
112
|
function parseDeclinedRecord(value: unknown): DeclinedWorkspaceRecord | null {
|
|
@@ -143,26 +173,54 @@ export function resolveWorkspaceRegistrationSync(
|
|
|
143
173
|
}
|
|
144
174
|
|
|
145
175
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
176
|
+
* Resolve `path` against ONLY the checkpoint-eligible registrations — the
|
|
177
|
+
* boundary the automatic/explicit checkpoint gate consumes. Identical to
|
|
178
|
+
* {@link resolveWorkspaceRegistrationSync} except the registrations are filtered
|
|
179
|
+
* to `checkpointEligible === true` first, so a plain TUI self-record (registered
|
|
180
|
+
* in the shared store, but never explicitly opted into checkpoints) resolves as
|
|
181
|
+
* NOT covered here even though the general resolver reports it registered.
|
|
182
|
+
* Worktree-link inheritance still applies: a linked worktree of a checkpoint-
|
|
183
|
+
* eligible main repo resolves covered.
|
|
184
|
+
*/
|
|
185
|
+
export function resolveCheckpointEligibilitySync(
|
|
186
|
+
shellPaths: StoreShellPaths,
|
|
187
|
+
path: string,
|
|
188
|
+
git?: WorkspaceGitMetadata,
|
|
189
|
+
): WorkspaceResolution {
|
|
190
|
+
const snapshot = readSharedWorkspaceRegistrationSnapshotSync(shellPaths);
|
|
191
|
+
const eligible = snapshot.workspaces.filter((entry) => entry.checkpointEligible === true);
|
|
192
|
+
const gitMeta = git ?? probeWorktreeLink(path);
|
|
193
|
+
return resolveWorkspaceRegistration({
|
|
194
|
+
path,
|
|
195
|
+
git: gitMeta,
|
|
196
|
+
registrations: eligible,
|
|
197
|
+
declines: snapshot.declines,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build a cheap, repeatable live checkpoint-eligibility checker for one fixed
|
|
203
|
+
* workspace root. `probeWorktreeLink` (a `git` subprocess spawn) runs ONCE here,
|
|
204
|
+
* since a long-running process's working directory and its git-worktree
|
|
205
|
+
* relationship do not change mid-launch; every subsequent call only re-reads the
|
|
206
|
+
* shared registration JSON file (a small synchronous fs read) — cheap enough to
|
|
207
|
+
* call on every turn/agent-lifecycle event, unlike calling
|
|
208
|
+
* `resolveCheckpointEligibilitySync` directly (which re-probes git every time).
|
|
153
209
|
*
|
|
154
|
-
* This is what makes registering a workspace mid-launch (
|
|
155
|
-
*
|
|
210
|
+
* This is what makes registering a workspace mid-launch (an explicit
|
|
211
|
+
* `goodvibes-agent workspaces register` that stamps `checkpointEligible`) take
|
|
156
212
|
* effect on the very next automatic-checkpoint-eligible event in an
|
|
157
|
-
* already-running process, without a restart: a caller that re-runs the
|
|
158
|
-
*
|
|
213
|
+
* already-running process, without a restart: a caller that re-runs the returned
|
|
214
|
+
* function on each event always reads current on-disk state. It consumes ONLY
|
|
215
|
+
* checkpoint-eligible records, so a directory a TUI user merely opened never
|
|
216
|
+
* silently becomes checkpoint-eligible to the agent.
|
|
159
217
|
*/
|
|
160
218
|
export function createWorkspaceRegistrationLiveChecker(
|
|
161
219
|
shellPaths: StoreShellPaths,
|
|
162
220
|
path: string,
|
|
163
221
|
): () => WorkspaceCoverageStatus {
|
|
164
222
|
const git = probeWorktreeLink(path);
|
|
165
|
-
return () =>
|
|
223
|
+
return () => resolveCheckpointEligibilitySync(shellPaths, path, git).status;
|
|
166
224
|
}
|
|
167
225
|
|
|
168
226
|
// ---------------------------------------------------------------------------
|
|
@@ -184,6 +242,142 @@ function atomicWriteJson(path: string, data: unknown): void {
|
|
|
184
242
|
renameSync(tempPath, path);
|
|
185
243
|
}
|
|
186
244
|
|
|
245
|
+
interface RawSharedStoreDoc {
|
|
246
|
+
readonly version: 1;
|
|
247
|
+
readonly workspaces: unknown[];
|
|
248
|
+
readonly declines: unknown[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Raw read of the shared store document that preserves EVERY field on EVERY
|
|
253
|
+
* record verbatim — unlike {@link readSharedWorkspaceRegistrationSnapshotSync},
|
|
254
|
+
* which strips each record to the known shape. Used by the synchronous
|
|
255
|
+
* eligibility backfill below so it never drops another surface's fields (or a
|
|
256
|
+
* future one) when it rewrites the file. A missing/unparsable file reads as an
|
|
257
|
+
* empty document.
|
|
258
|
+
*/
|
|
259
|
+
function readSharedStoreRawDoc(path: string): RawSharedStoreDoc {
|
|
260
|
+
if (!existsSync(path)) return { version: 1, workspaces: [], declines: [] };
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
263
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.workspaces)) {
|
|
264
|
+
return { version: 1, workspaces: [], declines: [] };
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
version: 1,
|
|
268
|
+
workspaces: parsed.workspaces,
|
|
269
|
+
declines: Array.isArray(parsed.declines) ? parsed.declines : [],
|
|
270
|
+
};
|
|
271
|
+
} catch {
|
|
272
|
+
return { version: 1, workspaces: [], declines: [] };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The agent's EXPLICIT checkpoint-registration path: one typed store `add` that
|
|
278
|
+
* registers `root` (the SDK's own root-guarded write) AND stamps it
|
|
279
|
+
* `checkpointEligible: true` with the agent-explicit origin — the store's `add`
|
|
280
|
+
* carries both fields natively, upgrading an already-present record's stamp in
|
|
281
|
+
* the same call. This is the ONLY way a record becomes checkpoint-eligible at
|
|
282
|
+
* write time — a plain SDK `add` (the TUI's first-open self-recording) never
|
|
283
|
+
* sets the flag, so opening a directory in the TUI cannot widen the agent's
|
|
284
|
+
* checkpoint boundary. Returns the SDK's register result unchanged so callers
|
|
285
|
+
* keep their existing messaging.
|
|
286
|
+
*/
|
|
287
|
+
export async function registerWorkspaceForCheckpoints(
|
|
288
|
+
shellPaths: StoreShellPaths,
|
|
289
|
+
root: string,
|
|
290
|
+
opts?: { readonly label?: string },
|
|
291
|
+
): Promise<RegisterWorkspaceResult> {
|
|
292
|
+
const store = createWorkspaceRegistrationStore(shellPaths);
|
|
293
|
+
return await store.add(root, {
|
|
294
|
+
...(opts?.label ? { label: opts.label } : {}),
|
|
295
|
+
origin: AGENT_EXPLICIT_REGISTRATION_ORIGIN,
|
|
296
|
+
checkpointEligible: true,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function checkpointEligibilityBackfillReceiptPath(shellPaths: StoreShellPaths): string {
|
|
301
|
+
return shellPaths.resolveUserPath('control-plane', 'workspace-checkpoint-eligibility-backfill-receipt.json');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface CheckpointEligibilityBackfillResult {
|
|
305
|
+
readonly sourcePath: string;
|
|
306
|
+
readonly recordsStamped: number;
|
|
307
|
+
readonly backfilledAt: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One-time boot backfill that stamps `checkpointEligible: true` on the shared-
|
|
312
|
+
* store records that came from the agent's OWN explicit registrations, so the
|
|
313
|
+
* new eligibility boundary does not retroactively drop workspaces the owner had
|
|
314
|
+
* already opted into checkpoints before this flag existed. This is exactly the
|
|
315
|
+
* "the consumer that owns checkpointing re-stamps its own roots on boot" the
|
|
316
|
+
* SDK's record schema documents for pre-provenance records; it writes the raw
|
|
317
|
+
* document directly (field-preserving, see readSharedStoreRawDoc) because it
|
|
318
|
+
* runs inside the synchronous createRuntimeServices path where the async typed
|
|
319
|
+
* store cannot be awaited.
|
|
320
|
+
*
|
|
321
|
+
* The honest source of "which records were the agent's explicit list" is the
|
|
322
|
+
* legacy per-user registry file (`<surface>/checkpoints/registered-workspaces.json`)
|
|
323
|
+
* — written only by `workspaces register`, and never deleted by the migration
|
|
324
|
+
* that imported it into the shared store. Every root in it is an explicit owner
|
|
325
|
+
* opt-in, so the matching shared-store record is stamped eligible. This covers
|
|
326
|
+
* both a fresh import (records just migrated in without the flag) and a machine
|
|
327
|
+
* where the import already ran (the earlier commit that migrated the explicit
|
|
328
|
+
* list into the shared store predates this flag).
|
|
329
|
+
*
|
|
330
|
+
* Receipt-gated so it runs once. A missing legacy file writes no receipt
|
|
331
|
+
* (there is no explicit-list source to derive from, and nothing worth
|
|
332
|
+
* remembering); records registered afresh through
|
|
333
|
+
* {@link registerWorkspaceForCheckpoints} are already stamped at write time.
|
|
334
|
+
* Returns null when nothing was backfilled this call.
|
|
335
|
+
*/
|
|
336
|
+
export function backfillCheckpointEligibilityIfNeeded(
|
|
337
|
+
shellPaths: StoreShellPaths,
|
|
338
|
+
): CheckpointEligibilityBackfillResult | null {
|
|
339
|
+
const receiptPath = checkpointEligibilityBackfillReceiptPath(shellPaths);
|
|
340
|
+
if (existsSync(receiptPath)) return null;
|
|
341
|
+
|
|
342
|
+
const legacyPath = legacyWorkspaceRegistryPath(shellPaths);
|
|
343
|
+
if (!existsSync(legacyPath)) return null;
|
|
344
|
+
|
|
345
|
+
const legacyRoots = new Set<string>();
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(readFileSync(legacyPath, 'utf-8')) as unknown;
|
|
348
|
+
const list = isRecord(parsed) && Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
|
|
349
|
+
for (const entry of list) {
|
|
350
|
+
if (!isRecord(entry)) continue;
|
|
351
|
+
const root = readString(entry.root);
|
|
352
|
+
if (root) legacyRoots.add(normalizeWorkspaceRoot(root));
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// An unparsable legacy file yields no roots but still writes a receipt below
|
|
356
|
+
// so this is never retried.
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const sharedPath = sharedWorkspaceRegistrationStorePath(shellPaths);
|
|
360
|
+
const doc = readSharedStoreRawDoc(sharedPath);
|
|
361
|
+
let stamped = 0;
|
|
362
|
+
const workspaces = doc.workspaces.map((entry) => {
|
|
363
|
+
if (!isRecord(entry)) return entry;
|
|
364
|
+
const root = normalizeWorkspaceRoot(readString(entry.root));
|
|
365
|
+
if (!legacyRoots.has(root) || entry.checkpointEligible === true) return entry;
|
|
366
|
+
stamped += 1;
|
|
367
|
+
const existingOrigin = readString(entry.origin);
|
|
368
|
+
return { ...entry, checkpointEligible: true, origin: existingOrigin || AGENT_EXPLICIT_REGISTRATION_ORIGIN };
|
|
369
|
+
});
|
|
370
|
+
if (stamped > 0) atomicWriteJson(sharedPath, { version: 1, workspaces, declines: doc.declines });
|
|
371
|
+
|
|
372
|
+
const result: CheckpointEligibilityBackfillResult = {
|
|
373
|
+
sourcePath: legacyPath,
|
|
374
|
+
recordsStamped: stamped,
|
|
375
|
+
backfilledAt: new Date().toISOString(),
|
|
376
|
+
};
|
|
377
|
+
atomicWriteJson(receiptPath, result);
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
187
381
|
export interface WorkspaceRegistrationMigrationResult {
|
|
188
382
|
readonly sourcePath: string;
|
|
189
383
|
readonly recordsMigrated: number;
|
|
@@ -291,8 +485,13 @@ export function isBroadWorkspaceRoot(shellPaths: StoreShellPaths, root: string):
|
|
|
291
485
|
* context) but never a silent failure — a write error is logged, not lost.
|
|
292
486
|
*/
|
|
293
487
|
export function answerWorkspaceRegistrationPrompt(shellPaths: StoreShellPaths, root: string, accepted: boolean): void {
|
|
294
|
-
|
|
295
|
-
|
|
488
|
+
// Accepting the first-start prompt is an EXPLICIT owner opt-in, so it goes
|
|
489
|
+
// through registerWorkspaceForCheckpoints (registers AND stamps eligibility) —
|
|
490
|
+
// not a plain store.add, which would register without making the workspace
|
|
491
|
+
// checkpoint-eligible. Declining stays a plain decline.
|
|
492
|
+
const outcome = accepted
|
|
493
|
+
? registerWorkspaceForCheckpoints(shellPaths, root)
|
|
494
|
+
: createWorkspaceRegistrationStore(shellPaths).decline(root);
|
|
296
495
|
void outcome.catch((error: unknown) => {
|
|
297
496
|
logger.error('Failed to persist workspace registration prompt answer', { root, accepted, error: summarizeError(error) });
|
|
298
497
|
});
|