@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +12 -1
  3. package/dist/package/main.js +36712 -28803
  4. package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
  5. package/docs/README.md +1 -1
  6. package/docs/connected-host.md +1 -1
  7. package/docs/getting-started.md +1 -1
  8. package/docs/tools-and-commands.md +1 -0
  9. package/package.json +3 -3
  10. package/release/live-verification/live-verification.json +11 -11
  11. package/release/live-verification/live-verification.md +12 -12
  12. package/src/agent/email/email-service.ts +1 -1
  13. package/src/agent/email/imap-client.ts +4 -4
  14. package/src/agent/email/smtp-client.ts +5 -5
  15. package/src/cli/config-overrides.ts +29 -14
  16. package/src/cli/entrypoint.ts +12 -4
  17. package/src/cli/launch-auto-update.ts +218 -0
  18. package/src/cli/relay-command.ts +4 -4
  19. package/src/cli/service-posture.ts +2 -2
  20. package/src/cli/tui-startup.ts +31 -0
  21. package/src/cli/workspaces-command.ts +5 -1
  22. package/src/cli-flags.ts +1 -1
  23. package/src/config/index.ts +1 -1
  24. package/src/config/update-settings.ts +45 -0
  25. package/src/config/workspace-registration.ts +214 -15
  26. package/src/input/commands/runtime-services.ts +0 -5
  27. package/src/input/commands/update-runtime.ts +313 -0
  28. package/src/input/commands.ts +2 -0
  29. package/src/input/feed-context-factory.ts +2 -2
  30. package/src/input/handler-feed.ts +8 -8
  31. package/src/input/handler.ts +1 -1
  32. package/src/input/mcp-workspace.ts +5 -1
  33. package/src/input/panel-paste-flood-guard.ts +1 -1
  34. package/src/input/settings-modal-types.ts +11 -5
  35. package/src/input/settings-modal.ts +56 -61
  36. package/src/main.ts +19 -20
  37. package/src/renderer/activity-sidebar.ts +53 -4
  38. package/src/renderer/settings-modal-helpers.ts +2 -0
  39. package/src/renderer/settings-modal.ts +37 -25
  40. package/src/runtime/bootstrap-core.ts +16 -5
  41. package/src/runtime/bootstrap-external-services.ts +107 -11
  42. package/src/runtime/bootstrap-hook-bridge.ts +2 -2
  43. package/src/runtime/bootstrap-shell.ts +4 -4
  44. package/src/runtime/bootstrap.ts +34 -12
  45. package/src/runtime/connected-host-autostart.ts +269 -0
  46. package/src/runtime/daemon-receipts.ts +66 -0
  47. package/src/runtime/diagnostics/panels/index.ts +0 -2
  48. package/src/runtime/feature-enablement.ts +176 -0
  49. package/src/runtime/index.ts +12 -29
  50. package/src/runtime/memory-spine-adoption.ts +18 -0
  51. package/src/runtime/onboarding/apply.ts +50 -37
  52. package/src/runtime/onboarding/types.ts +2 -2
  53. package/src/runtime/onboarding/verify.ts +22 -4
  54. package/src/runtime/release-artifacts.ts +113 -0
  55. package/src/runtime/services.ts +228 -19
  56. package/src/runtime/session-spine-rest-transport.ts +84 -4
  57. package/src/runtime/ui-services.ts +1 -1
  58. package/src/runtime/update-check.ts +64 -0
  59. package/src/shell/ui-openers.ts +8 -0
  60. package/src/tools/agent-harness-metadata.ts +8 -0
  61. package/src/tools/agent-harness-setup-connected-host.ts +1 -1
  62. package/src/tools/agent-harness-setup-posture.ts +1 -1
  63. package/src/version.ts +1 -1
  64. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  65. 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
+ }
@@ -1,4 +1,4 @@
1
- import { FEATURE_FLAG_MAP } from '@pellux/goodvibes-sdk/platform/runtime/state';
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 flag = FEATURE_FLAG_MAP.get('relay-connect');
38
+ const feature = FEATURE_SETTINGS.find((entry) => entry.id === 'relay-connect');
39
39
  return {
40
40
  id: 'relay-connect',
41
- name: flag?.name ?? 'Outbound Zero-Knowledge Relay',
42
- defaultState: flag?.defaultState ?? 'disabled',
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: no',
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: ignored by Agent',
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}`] : []),
@@ -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
- const result = await store.add(target, label ? { label } : undefined);
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
@@ -5,7 +5,7 @@ export {
5
5
  applyRuntimeConfigValue,
6
6
  applyRuntimeCommandEndpointFlagOverrides,
7
7
  applyRuntimeEndpointFlagOverrides,
8
- applyRuntimeFeatureFlagOverrides,
8
+ applyRuntimeFeatureOverrides,
9
9
  applyRuntimeUrlOverride,
10
10
  handleGoodVibesCliCommand,
11
11
  parseGoodVibesCli,
@@ -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, PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config';
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
- return { root: normalizeWorkspaceRoot(root), registeredAt, ...(label ? { label } : {}) };
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
- * Build a cheap, repeatable live-registration checker for one fixed workspace
147
- * root. `probeWorktreeLink` (a `git` subprocess spawn) runs ONCE here, since a
148
- * long-running process's working directory and its git-worktree relationship
149
- * do not change mid-launch; every subsequent call only re-reads the shared
150
- * registration JSON file (a small synchronous fs read) cheap enough to call
151
- * on every turn/agent-lifecycle event, unlike calling
152
- * `resolveWorkspaceRegistrationSync` directly (which re-probes git every time).
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 (writing to the shared
155
- * store from a separate `goodvibes-agent workspaces register` invocation) take
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
- * returned function on each event always reads current on-disk state.
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 () => resolveWorkspaceRegistrationSync(shellPaths, path, git).status;
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
- const store = createWorkspaceRegistrationStore(shellPaths);
295
- const outcome = accepted ? store.add(root) : store.decline(root);
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
  });