@pellux/goodvibes-agent 1.8.1 → 1.9.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 (89) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/dist/package/{ast-grep-napi.linux-x64-gnu-mkk8xwww.node → ast-grep-napi.linux-x64-gnu-44ar68xs.node} +0 -0
  4. package/dist/package/{ast-grep-napi.linux-x64-musl-ryqtgdv6.node → ast-grep-napi.linux-x64-musl-asvc2tv1.node} +0 -0
  5. package/dist/package/main.js +135337 -118733
  6. package/dist/package/{web-tree-sitter-vmqc5d26.wasm → web-tree-sitter-jbz042ba.wasm} +0 -0
  7. package/docs/README.md +1 -1
  8. package/docs/tools-and-commands.md +9 -0
  9. package/package.json +5 -3
  10. package/src/agent/capability-registry.ts +300 -0
  11. package/src/agent/email/style-reply-lane.ts +13 -4
  12. package/src/agent/memory-consolidation-receipts.ts +79 -0
  13. package/src/agent/memory-prompt.ts +3 -1
  14. package/src/agent/operator-gateway-call.ts +134 -0
  15. package/src/agent/principal-attribution.ts +97 -0
  16. package/src/agent/prompt-context-receipts.ts +5 -1
  17. package/src/agent/unified-inbox.ts +17 -1
  18. package/src/cli/bundle-command.ts +2 -1
  19. package/src/cli/channel-profiles-command.ts +168 -0
  20. package/src/cli/ci-command.ts +214 -0
  21. package/src/cli/completion.ts +5 -0
  22. package/src/cli/connected-host-metrics.ts +165 -0
  23. package/src/cli/entrypoint.ts +25 -1
  24. package/src/cli/fleet-command.ts +178 -0
  25. package/src/cli/help.ts +115 -0
  26. package/src/cli/import-command.ts +208 -0
  27. package/src/cli/index.ts +1 -0
  28. package/src/cli/management-commands.ts +9 -8
  29. package/src/cli/management.ts +42 -0
  30. package/src/cli/memory-command.ts +4 -3
  31. package/src/cli/openclaw-import.ts +382 -0
  32. package/src/cli/operator-command-args.ts +88 -0
  33. package/src/cli/parser.ts +12 -0
  34. package/src/cli/personas-command.ts +3 -2
  35. package/src/cli/principals-command.ts +230 -0
  36. package/src/cli/profiles-command.ts +2 -1
  37. package/src/cli/relay-command.ts +100 -0
  38. package/src/cli/routines-command.ts +4 -3
  39. package/src/cli/skill-bundle-command.ts +3 -2
  40. package/src/cli/skills-command.ts +3 -2
  41. package/src/cli/status.ts +114 -2
  42. package/src/cli/temporal-label.ts +89 -0
  43. package/src/cli/types.ts +7 -0
  44. package/src/cli/workspaces-command.ts +135 -0
  45. package/src/config/agent-settings-policy.ts +8 -0
  46. package/src/config/checkpoint-settings.ts +113 -0
  47. package/src/config/workspace-registration.ts +299 -0
  48. package/src/core/rewind-turn-anchors.ts +82 -0
  49. package/src/input/agent-workspace-categories.ts +1 -0
  50. package/src/input/agent-workspace-channel-triage.ts +27 -8
  51. package/src/input/agent-workspace-local-library-snapshot.ts +5 -1
  52. package/src/input/agent-workspace-onboarding-categories.ts +17 -1
  53. package/src/input/agent-workspace-snapshot.ts +25 -3
  54. package/src/input/agent-workspace-types.ts +5 -5
  55. package/src/input/commands/connected-host-admin-runtime.ts +75 -0
  56. package/src/input/commands.ts +2 -0
  57. package/src/input/settings-modal-types.ts +16 -5
  58. package/src/main.ts +17 -12
  59. package/src/permissions/approval-posture.ts +40 -5
  60. package/src/permissions/prompt.ts +35 -1
  61. package/src/renderer/settings-modal-helpers.ts +11 -0
  62. package/src/renderer/settings-modal.ts +21 -0
  63. package/src/renderer/terminal-escapes.ts +65 -12
  64. package/src/runtime/agent-runtime-events.ts +102 -3
  65. package/src/runtime/bootstrap-core.ts +111 -10
  66. package/src/runtime/bootstrap-external-services.ts +156 -0
  67. package/src/runtime/bootstrap.ts +73 -69
  68. package/src/runtime/context-accounting-source.ts +114 -0
  69. package/src/runtime/conversation-rewind-port.ts +133 -0
  70. package/src/runtime/index.ts +8 -28
  71. package/src/runtime/memory-consolidation-scheduler.ts +67 -0
  72. package/src/runtime/memory-consolidation-wiring.ts +56 -0
  73. package/src/runtime/memory-usage-wiring.ts +75 -0
  74. package/src/runtime/services.ts +250 -14
  75. package/src/runtime/tool-permission-safety.ts +18 -1
  76. package/src/shell/blocking-input.ts +38 -8
  77. package/src/shell/startup-wiring.ts +26 -1
  78. package/src/tools/agent-context-policy.ts +1 -1
  79. package/src/tools/agent-harness-cli-command-policy.ts +29 -3
  80. package/src/tools/agent-harness-learning-auto-promote.ts +7 -3
  81. package/src/tools/agent-harness-memory-posture.ts +57 -2
  82. package/src/tools/agent-harness-metadata.ts +12 -0
  83. package/src/tools/agent-harness-mode-catalog.ts +1 -1
  84. package/src/tools/agent-harness-personal-ops-lanes.ts +8 -6
  85. package/src/tools/agent-harness-prompt-context.ts +3 -1
  86. package/src/tools/agent-harness-tool.ts +3 -2
  87. package/src/tools/agent-policy-explanation.ts +40 -0
  88. package/src/tools/tool-definition-compaction.ts +2 -0
  89. package/src/version.ts +1 -1
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Human temporal labels for CLI record listings.
3
+ *
4
+ * CLI output that lists sessions, routines, principals, and other timestamped
5
+ * records used to show a raw value (an ISO string or nothing) with no sense of
6
+ * how recent it is. These helpers add a plain "how long ago" (or "in ...")
7
+ * label ALONGSIDE the precise value — the precise value is never replaced,
8
+ * because the exact timestamp still matters for records, correlation, and
9
+ * scripting. `appendTemporalLabel` is the intended entry point: it keeps the
10
+ * precise string the caller already renders and appends `(2 hours ago)`.
11
+ *
12
+ * Both an epoch-milliseconds number and an ISO/parseable date string are
13
+ * accepted, since session summaries carry an epoch number while the local
14
+ * record registries (routines, personas, skills, principals, ...) carry ISO
15
+ * strings. Unparseable or absent input yields no label rather than a fabricated
16
+ * one — honesty over a plausible-looking guess.
17
+ */
18
+
19
+ /** Parse an epoch-ms number or a date string into epoch ms, or null when it is not a real instant. */
20
+ export function toEpochMs(value: number | string | null | undefined): number | null {
21
+ if (value === null || value === undefined) return null;
22
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
23
+ const trimmed = value.trim();
24
+ if (!trimmed) return null;
25
+ const parsed = Date.parse(trimmed);
26
+ return Number.isNaN(parsed) ? null : parsed;
27
+ }
28
+
29
+ const MINUTE = 60_000;
30
+ const HOUR = 60 * MINUTE;
31
+ const DAY = 24 * HOUR;
32
+ const WEEK = 7 * DAY;
33
+ const MONTH = 30 * DAY;
34
+ const YEAR = 365 * DAY;
35
+
36
+ function unit(count: number, singular: string): string {
37
+ return `${count} ${singular}${count === 1 ? '' : 's'}`;
38
+ }
39
+
40
+ /**
41
+ * A plain relative label for an instant, e.g. "2 hours ago", "in 3 days",
42
+ * "just now". Returns null when the input is not a real instant.
43
+ *
44
+ * Elapsed magnitudes are floored (not rounded) so a label never describes a
45
+ * moment as further in the past than it truly is — the same anti-overstatement
46
+ * rule the resume-relaunch age formatter follows. Future instants read "in N"
47
+ * with the same tiers.
48
+ */
49
+ export function formatTemporalLabel(
50
+ value: number | string | null | undefined,
51
+ now: number = Date.now(),
52
+ ): string | null {
53
+ const at = toEpochMs(value);
54
+ if (at === null) return null;
55
+
56
+ const deltaMs = now - at;
57
+ const future = deltaMs < 0;
58
+ const abs = Math.abs(deltaMs);
59
+
60
+ if (abs < 10_000) return 'just now';
61
+
62
+ const magnitude = ((): string => {
63
+ if (abs < MINUTE) return unit(Math.floor(abs / 1000), 'second');
64
+ if (abs < HOUR) return unit(Math.floor(abs / MINUTE), 'minute');
65
+ if (abs < DAY) return unit(Math.floor(abs / HOUR), 'hour');
66
+ if (abs < WEEK) return unit(Math.floor(abs / DAY), 'day');
67
+ if (abs < MONTH) return unit(Math.floor(abs / WEEK), 'week');
68
+ if (abs < YEAR) return unit(Math.floor(abs / MONTH), 'month');
69
+ return unit(Math.floor(abs / YEAR), 'year');
70
+ })();
71
+
72
+ return future ? `in ${magnitude}` : `${magnitude} ago`;
73
+ }
74
+
75
+ /**
76
+ * Append a parenthetical temporal label to a precise value string the caller
77
+ * already renders. The precise value stays exactly as-is; the label is added
78
+ * only when the instant parses. Example:
79
+ * appendTemporalLabel('2026-07-11T09:00:00.000Z', someEpochOrIso)
80
+ * -> '2026-07-11T09:00:00.000Z (2 hours ago)'
81
+ */
82
+ export function appendTemporalLabel(
83
+ precise: string,
84
+ value: number | string | null | undefined,
85
+ now: number = Date.now(),
86
+ ): string {
87
+ const label = formatTemporalLabel(value, now);
88
+ return label ? `${precise} (${label})` : precise;
89
+ }
package/src/cli/types.ts CHANGED
@@ -11,6 +11,11 @@ export type GoodVibesCliCommand =
11
11
  | 'skills'
12
12
  | 'memory'
13
13
  | 'routines'
14
+ | 'ci'
15
+ | 'principals'
16
+ | 'channel-profiles'
17
+ | 'workspaces'
18
+ | 'fleet'
14
19
  | 'auth'
15
20
  | 'compat'
16
21
  | 'knowledge'
@@ -21,8 +26,10 @@ export type GoodVibesCliCommand =
21
26
  | 'secrets'
22
27
  | 'sessions'
23
28
  | 'tasks'
29
+ | 'relay'
24
30
  | 'pair'
25
31
  | 'bundle'
32
+ | 'import'
26
33
  | 'completion'
27
34
  | 'help'
28
35
  | 'version'
@@ -0,0 +1,135 @@
1
+ import { createShellPathService } from '@/runtime/index.ts';
2
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
3
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
4
+ import { WorkspaceRegistrationError } from '@pellux/goodvibes-sdk/platform/workspace';
5
+ import {
6
+ createWorkspaceRegistrationStore,
7
+ migrateLegacyWorkspaceRegistryIfNeeded,
8
+ normalizeWorkspaceRoot,
9
+ } from '../config/workspace-registration.ts';
10
+ import type { CliCommandOutput } from './types.ts';
11
+ import type { CliCommandRuntime } from './management.ts';
12
+
13
+ /**
14
+ * `goodvibes-agent workspaces` — manage the shared registered-workspace store
15
+ * (SDK 1.6.1 platform/workspace/registration) that gates automatic checkpoints
16
+ * (owner ruling, 2026-07-10; see ../runtime/services.ts and
17
+ * ../config/workspace-registration.ts). A workspace root not covered by this
18
+ * store gets no automatic turn/agent-lifecycle checkpoints, and explicit
19
+ * `checkpoints.create` gateway calls against it are refused with this
20
+ * command's registration hint (see services.ts's checkpointsGatewayManager).
21
+ *
22
+ * Coverage flows down a registered root's subtree and is inherited through
23
+ * the git worktree→main-repo link (an orchestration worktree of a registered
24
+ * repo is covered without being registered itself) — the CLI UX below (the
25
+ * list/register/unregister subcommands, their flags, and their exit codes) is
26
+ * unchanged from the local-registry predecessor this now reads/writes
27
+ * through instead.
28
+ */
29
+
30
+ function hasYes(args: readonly string[]): boolean {
31
+ return args.includes('--yes');
32
+ }
33
+
34
+ function flagValue(args: readonly string[], names: readonly string[]): string | null {
35
+ for (let index = 0; index < args.length; index += 1) {
36
+ const token = args[index]!;
37
+ for (const name of names) {
38
+ if (token === name) {
39
+ const next = args[index + 1];
40
+ return next && !next.startsWith('--') ? next : null;
41
+ }
42
+ if (token.startsWith(`${name}=`)) return token.slice(name.length + 1);
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function commandValues(args: readonly string[]): readonly string[] {
49
+ const values: string[] = [];
50
+ for (let index = 0; index < args.length; index += 1) {
51
+ const token = args[index]!;
52
+ if (!token.startsWith('--')) values.push(token);
53
+ }
54
+ return values;
55
+ }
56
+
57
+ function jsonOrText(runtime: CliCommandRuntime, value: unknown, text: string): string {
58
+ return runtime.cli.flags.outputFormat === 'json' ? JSON.stringify(value, null, 2) : text;
59
+ }
60
+
61
+ function usage(runtime: CliCommandRuntime, error: string): CliCommandOutput {
62
+ const message = `${error}\nUsage: goodvibes-agent workspaces [list|register [path] [--label <label>] --yes|unregister [path] --yes]`;
63
+ return { output: jsonOrText(runtime, { ok: false, error: message }, message), exitCode: 2 };
64
+ }
65
+
66
+ export async function handleWorkspacesCommand(runtime: CliCommandRuntime): Promise<CliCommandOutput> {
67
+ const shellPaths = createShellPathService({
68
+ workingDirectory: runtime.workingDirectory,
69
+ homeDirectory: runtime.homeDirectory,
70
+ });
71
+
72
+ // Idempotent, receipt-gated: a no-op after the first successful migration on
73
+ // this machine, from any entry point. See workspace-registration.ts.
74
+ const migration = migrateLegacyWorkspaceRegistryIfNeeded(shellPaths);
75
+ if (migration) {
76
+ logger.info('Migrated the local workspace registry into the shared registration store', { ...migration });
77
+ }
78
+
79
+ const store = createWorkspaceRegistrationStore(shellPaths);
80
+ const [sub = 'list', ...rawRest] = runtime.cli.commandArgs;
81
+ const values = commandValues(rawRest);
82
+
83
+ if (sub === 'list' || sub === 'ls') {
84
+ const snapshot = await store.snapshot();
85
+ const current = normalizeWorkspaceRoot(runtime.workingDirectory);
86
+ const currentResolution = await store.resolve(current);
87
+ const currentWorkspaceRegistered = currentResolution.status === 'covered';
88
+ if (runtime.cli.flags.outputFormat === 'json') {
89
+ return {
90
+ output: JSON.stringify({ ok: true, ...snapshot, currentWorkspaceRegistered }, null, 2),
91
+ exitCode: 0,
92
+ };
93
+ }
94
+ const lines = snapshot.workspaces.length === 0
95
+ ? ['No registered workspaces', ' automatic checkpoints are off everywhere until a workspace is registered']
96
+ : [
97
+ `Registered workspaces (${snapshot.workspaces.length})`,
98
+ ...snapshot.workspaces.map((entry) => ` ${entry.root}${entry.label ? ` (${entry.label})` : ''} — registered ${entry.registeredAt}`),
99
+ ];
100
+ lines.push('', `current workspace ${current}`, ` registered ${currentWorkspaceRegistered ? 'yes' : 'no'}`);
101
+ return { output: lines.join('\n'), exitCode: 0 };
102
+ }
103
+
104
+ if (sub === 'register' || sub === 'add') {
105
+ const target = values[0] ?? runtime.workingDirectory;
106
+ if (!hasYes(rawRest)) {
107
+ return usage(runtime, `Refusing to register workspace ${normalizeWorkspaceRoot(target)} for automatic checkpoints without --yes.`);
108
+ }
109
+ const label = flagValue(rawRest, ['--label']) ?? undefined;
110
+ try {
111
+ const result = await store.add(target, label ? { label } : undefined);
112
+ const text = result.alreadyRegistered
113
+ ? `Workspace already registered: ${result.record.root}`
114
+ : `Workspace registered: ${result.record.root}\n automatic checkpoints are now allowed for this workspace`;
115
+ return { output: jsonOrText(runtime, { ok: true, ...result }, text), exitCode: 0 };
116
+ } catch (error) {
117
+ const message = error instanceof WorkspaceRegistrationError ? error.message : summarizeError(error);
118
+ return { output: jsonOrText(runtime, { ok: false, error: message }, message), exitCode: 2 };
119
+ }
120
+ }
121
+
122
+ if (sub === 'unregister' || sub === 'remove' || sub === 'rm') {
123
+ const target = values[0] ?? runtime.workingDirectory;
124
+ if (!hasYes(rawRest)) {
125
+ return usage(runtime, `Refusing to unregister workspace ${normalizeWorkspaceRoot(target)} without --yes.`);
126
+ }
127
+ const result = await store.remove(target);
128
+ const text = result.removed
129
+ ? `Workspace unregistered: ${normalizeWorkspaceRoot(target)}\n automatic checkpoints are now off for this workspace`
130
+ : `Workspace was not registered: ${normalizeWorkspaceRoot(target)}`;
131
+ return { output: jsonOrText(runtime, { ok: true, ...result }, text), exitCode: result.removed ? 0 : 1 };
132
+ }
133
+
134
+ return usage(runtime, `Unknown workspaces subcommand: ${sub}`);
135
+ }
@@ -9,6 +9,14 @@ const AGENT_HIDDEN_SETTING_KEYS = new Set<string>([
9
9
  ]);
10
10
 
11
11
  const EXTERNAL_HOST_SETTING_PREFIXES = [
12
+ // Outbound relay reachability is a daemon lifecycle concern (dial-out,
13
+ // identity custody, WebAuthn step-up enforcement) owned by whichever
14
+ // GoodVibes host Agent is connected to. Agent's own copy of these keys is
15
+ // an imported/local snapshot (see README's "shared GoodVibes settings
16
+ // import"), not a live-shared file — toggling relay.enabled here would not
17
+ // actually start or stop the connected daemon's relay registration, so the
18
+ // whole domain is locked exactly like danger.httpListener below.
19
+ 'relay.',
12
20
  ] as const;
13
21
 
14
22
  const EXTERNAL_HOST_SETTING_KEYS = new Set<string>([
@@ -0,0 +1,113 @@
1
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
+
3
+ /**
4
+ * Agent-local checkpoint guard settings (`checkpoints.*` namespace).
5
+ *
6
+ * The SDK's WorkspaceCheckpointManager exposes root/retention guard options that
7
+ * decide which directory it is safe to snapshot and how the first sweep and
8
+ * retention behave:
9
+ *
10
+ * - `checkpoints.preferGitRoot` (boolean, SDK default true) — prefer the
11
+ * enclosing git repository's top level over the raw working directory.
12
+ * - `checkpoints.allowBroadRoot` (boolean, SDK default false) — opt in to
13
+ * snapshotting a broad root (filesystem root, home directory, ~/.goodvibes).
14
+ * - `checkpoints.allowLargeFirstSnapshot` (boolean, SDK default false) — opt
15
+ * in to a first snapshot whose full sweep exceeds `maxFirstSnapshotFiles`.
16
+ * - `checkpoints.maxFirstSnapshotFiles` (number, SDK default) — ceiling for
17
+ * the first-ever snapshot's file sweep.
18
+ * - `checkpoints.autoRetention` (boolean, SDK default true) — run a retention
19
+ * sweep automatically after each successful create and once at init.
20
+ *
21
+ * A sixth key, `checkpoints.unregisteredWorkspaces`, is read separately by
22
+ * `readCheckpointRegistrationSetting` below (kept out of `CheckpointGuardSettings`
23
+ * because it is an Agent-owned enforcement switch, not an SDK manager
24
+ * constructor option, and must never be spread into the manager's options).
25
+ *
26
+ * The shared SDK config schema (GoodVibesConfig) has no `checkpoints` category,
27
+ * so these are read directly from a user-supplied `checkpoints` block in
28
+ * settings.json. The SDK ConfigManager deep-merges loaded settings onto the
29
+ * default config and preserves unknown top-level blocks, so a hand-added
30
+ * `checkpoints` object survives to `getRaw()`. Only these five passthrough keys
31
+ * are read here; each absent key falls back to the SDK manager's own default.
32
+ *
33
+ * settings.json example:
34
+ *
35
+ * "checkpoints": {
36
+ * "preferGitRoot": true,
37
+ * "allowBroadRoot": false,
38
+ * "allowLargeFirstSnapshot": false,
39
+ * "maxFirstSnapshotFiles": 20000,
40
+ * "autoRetention": true,
41
+ * "unregisteredWorkspaces": "off"
42
+ * }
43
+ */
44
+ export interface CheckpointGuardSettings {
45
+ readonly preferGitRoot?: boolean;
46
+ readonly allowBroadRoot?: boolean;
47
+ readonly allowLargeFirstSnapshot?: boolean;
48
+ readonly maxFirstSnapshotFiles?: number;
49
+ readonly autoRetention?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Read the `checkpoints.*` guard passthrough keys from the loaded config. Only
54
+ * keys present with the right primitive type are returned; everything else is
55
+ * omitted so the SDK manager applies its own default. `maxFirstSnapshotFiles`
56
+ * must be a finite positive number to be accepted.
57
+ */
58
+ export function readCheckpointGuardSettings(
59
+ configManager: Pick<ConfigManager, 'getRaw'>,
60
+ ): CheckpointGuardSettings {
61
+ const raw = configManager.getRaw() as unknown as Record<string, unknown>;
62
+ const block = raw.checkpoints;
63
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) return {};
64
+ const cp = block as Record<string, unknown>;
65
+
66
+ const out: {
67
+ preferGitRoot?: boolean;
68
+ allowBroadRoot?: boolean;
69
+ allowLargeFirstSnapshot?: boolean;
70
+ maxFirstSnapshotFiles?: number;
71
+ autoRetention?: boolean;
72
+ } = {};
73
+
74
+ if (typeof cp.preferGitRoot === 'boolean') out.preferGitRoot = cp.preferGitRoot;
75
+ if (typeof cp.allowBroadRoot === 'boolean') out.allowBroadRoot = cp.allowBroadRoot;
76
+ if (typeof cp.allowLargeFirstSnapshot === 'boolean') out.allowLargeFirstSnapshot = cp.allowLargeFirstSnapshot;
77
+ if (
78
+ typeof cp.maxFirstSnapshotFiles === 'number'
79
+ && Number.isFinite(cp.maxFirstSnapshotFiles)
80
+ && cp.maxFirstSnapshotFiles > 0
81
+ ) {
82
+ out.maxFirstSnapshotFiles = cp.maxFirstSnapshotFiles;
83
+ }
84
+ if (typeof cp.autoRetention === 'boolean') out.autoRetention = cp.autoRetention;
85
+
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * The registered-workspaces-only override (owner ruling, 2026-07-10):
91
+ *
92
+ * - `checkpoints.unregisteredWorkspaces` (`'off' | 'guarded'`, default `'off'`)
93
+ * — `'off'` is the ruling's default: automatic (turn-end/lifecycle)
94
+ * checkpoints, and explicit checkpoint creation through the ws-only
95
+ * `checkpoints.create` gateway verb, both refuse when the resolved
96
+ * workspace root is not covered by the shared registration store
97
+ * (../config/workspace-registration.ts).
98
+ * `'guarded'` opts back into the pre-ruling behavior for an unregistered
99
+ * workspace: automatic snapshots subscribe and explicit create proceeds,
100
+ * subject only to the SDK's own root/size guards above — never a silent
101
+ * re-enable, an explicit per-workspace opt-out of the registration gate.
102
+ *
103
+ * Any other value (including absence) reads as `'off'`.
104
+ */
105
+ export function readCheckpointRegistrationSetting(
106
+ configManager: Pick<ConfigManager, 'getRaw'>,
107
+ ): 'off' | 'guarded' {
108
+ const raw = configManager.getRaw() as unknown as Record<string, unknown>;
109
+ const block = raw.checkpoints;
110
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) return 'off';
111
+ const value = (block as Record<string, unknown>).unregisteredWorkspaces;
112
+ return value === 'guarded' ? 'guarded' : 'off';
113
+ }
@@ -0,0 +1,299 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, parse, resolve } from 'node:path';
3
+ import type { ShellPathService } from '@/runtime/index.ts';
4
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
5
+ import {
6
+ WorkspaceRegistrationStore,
7
+ resolveWorkspaceRegistration,
8
+ normalizeWorkspaceRoot,
9
+ probeWorktreeLink,
10
+ type RegisteredWorkspaceRecord,
11
+ type DeclinedWorkspaceRecord,
12
+ type WorkspaceCoverageStatus,
13
+ type WorkspaceGitMetadata,
14
+ type WorkspaceResolution,
15
+ } from '@pellux/goodvibes-sdk/platform/workspace';
16
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from './surface.ts';
17
+
18
+ export { normalizeWorkspaceRoot } from '@pellux/goodvibes-sdk/platform/workspace';
19
+ export type {
20
+ DeclinedWorkspaceRecord,
21
+ RegisteredWorkspaceRecord,
22
+ WorkspaceCoverageStatus,
23
+ WorkspaceResolution,
24
+ } from '@pellux/goodvibes-sdk/platform/workspace';
25
+
26
+ /**
27
+ * Shared registered-workspace registry (SDK 1.6.1 platform/workspace/registration),
28
+ * the successor to this fork's local per-user JSON registry
29
+ * (superseded ../config/workspace-registry.ts, deleted).
30
+ *
31
+ * MIGRATION. The local registry's record shape (`{ root, registeredAt, label? }`)
32
+ * is field-identical to the shared store's `RegisteredWorkspaceRecord`, so a local
33
+ * file migrates in as a straight import — see migrateLegacyWorkspaceRegistryIfNeeded.
34
+ *
35
+ * WHY A SYNCHRONOUS RESOLVER EXISTS ALONGSIDE THE ASYNC STORE. The SDK's
36
+ * WorkspaceRegistrationStore is Promise-based (it persists through
37
+ * PersistentStore). createRuntimeServices() (../runtime/services.ts) is
38
+ * synchronous by design — every consumer (the CLI, the TUI bootstrap, every
39
+ * test file) calls it synchronously — and the automatic-checkpoint decision
40
+ * (whether to pass runtimeBus to WorkspaceCheckpointManager) must be made AT
41
+ * construction time, not after an async read resolves later. So this module
42
+ * also ships a synchronous snapshot read + the SDK's PURE resolver
43
+ * (resolveWorkspaceRegistration, no disk/git I/O) for that one call site;
44
+ * every other consumer (the workspaces CLI, a future interactive prompt) goes
45
+ * through the real async store below, the single source of truth for writes.
46
+ * Both read the exact same on-disk file, so there is only ever one registry.
47
+ */
48
+
49
+ export type StoreShellPaths = Pick<ShellPathService, 'resolveUserPath' | 'homeDirectory'>;
50
+
51
+ /** Path of the shared store's JSON document — the SAME path the SDK's registerGatewayVerbGroups uses internally to construct its own store instance. */
52
+ export function sharedWorkspaceRegistrationStorePath(shellPaths: StoreShellPaths): string {
53
+ return shellPaths.resolveUserPath('control-plane', 'workspace-registrations.json');
54
+ }
55
+
56
+ /** Construct a store instance over the shared file — for callers that can go async (CLI commands, interactive prompts). */
57
+ export function createWorkspaceRegistrationStore(shellPaths: StoreShellPaths): WorkspaceRegistrationStore {
58
+ return new WorkspaceRegistrationStore({
59
+ path: sharedWorkspaceRegistrationStorePath(shellPaths),
60
+ homeDir: shellPaths.homeDirectory,
61
+ daemonStateDir: shellPaths.resolveUserPath(),
62
+ });
63
+ }
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+
69
+ function readString(value: unknown): string {
70
+ return typeof value === 'string' ? value.trim() : '';
71
+ }
72
+
73
+ function parseRegisteredRecord(value: unknown): RegisteredWorkspaceRecord | null {
74
+ if (!isRecord(value)) return null;
75
+ const root = readString(value.root);
76
+ const registeredAt = readString(value.registeredAt);
77
+ if (!root || !registeredAt || Number.isNaN(Date.parse(registeredAt))) return null;
78
+ const label = readString(value.label);
79
+ return { root: normalizeWorkspaceRoot(root), registeredAt, ...(label ? { label } : {}) };
80
+ }
81
+
82
+ function parseDeclinedRecord(value: unknown): DeclinedWorkspaceRecord | null {
83
+ if (!isRecord(value)) return null;
84
+ const root = readString(value.root);
85
+ const declinedAt = readString(value.declinedAt);
86
+ if (!root || !declinedAt || Number.isNaN(Date.parse(declinedAt))) return null;
87
+ return { root: normalizeWorkspaceRoot(root), declinedAt };
88
+ }
89
+
90
+ interface SharedRegistrationSnapshot {
91
+ readonly workspaces: readonly RegisteredWorkspaceRecord[];
92
+ readonly declines: readonly DeclinedWorkspaceRecord[];
93
+ }
94
+
95
+ /**
96
+ * Synchronous read of the shared store's on-disk JSON, mirroring the store's
97
+ * own validate() logic exactly (version 1, workspaces[], declines[]). A
98
+ * missing or unparsable file reads as empty — never throws.
99
+ */
100
+ export function readSharedWorkspaceRegistrationSnapshotSync(shellPaths: StoreShellPaths): SharedRegistrationSnapshot {
101
+ const path = sharedWorkspaceRegistrationStorePath(shellPaths);
102
+ if (!existsSync(path)) return { workspaces: [], declines: [] };
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
105
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.workspaces)) {
106
+ return { workspaces: [], declines: [] };
107
+ }
108
+ const workspaces = parsed.workspaces
109
+ .map(parseRegisteredRecord)
110
+ .filter((entry): entry is RegisteredWorkspaceRecord => entry !== null);
111
+ const declineList = Array.isArray(parsed.declines) ? parsed.declines : [];
112
+ const declines = declineList
113
+ .map(parseDeclinedRecord)
114
+ .filter((entry): entry is DeclinedWorkspaceRecord => entry !== null);
115
+ return { workspaces, declines };
116
+ } catch {
117
+ return { workspaces: [], declines: [] };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Resolve `path` against the shared registration store SYNCHRONOUSLY: a direct
123
+ * on-disk read plus the SDK's pure resolveWorkspaceRegistration. `git` defaults
124
+ * to a real worktree-link probe (probeWorktreeLink spawns `git`), so a linked
125
+ * orchestration worktree of a registered repo resolves as covered without any
126
+ * extra wiring — the worktree-link inheritance the shared store provides.
127
+ * Callers that already have git metadata (or want to avoid the spawn, e.g. in
128
+ * a pure unit test) can pass it explicitly.
129
+ */
130
+ export function resolveWorkspaceRegistrationSync(
131
+ shellPaths: StoreShellPaths,
132
+ path: string,
133
+ git?: WorkspaceGitMetadata,
134
+ ): WorkspaceResolution {
135
+ const snapshot = readSharedWorkspaceRegistrationSnapshotSync(shellPaths);
136
+ const gitMeta = git ?? probeWorktreeLink(path);
137
+ return resolveWorkspaceRegistration({
138
+ path,
139
+ git: gitMeta,
140
+ registrations: snapshot.workspaces,
141
+ declines: snapshot.declines,
142
+ });
143
+ }
144
+
145
+ /**
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).
153
+ *
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
156
+ * 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.
159
+ */
160
+ export function createWorkspaceRegistrationLiveChecker(
161
+ shellPaths: StoreShellPaths,
162
+ path: string,
163
+ ): () => WorkspaceCoverageStatus {
164
+ const git = probeWorktreeLink(path);
165
+ return () => resolveWorkspaceRegistrationSync(shellPaths, path, git).status;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // One-time migration: local per-user registry -> shared store
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function legacyWorkspaceRegistryPath(shellPaths: StoreShellPaths): string {
173
+ return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'checkpoints', 'registered-workspaces.json');
174
+ }
175
+
176
+ function migrationReceiptPath(shellPaths: StoreShellPaths): string {
177
+ return shellPaths.resolveUserPath('control-plane', 'workspace-registration-migration-receipt.json');
178
+ }
179
+
180
+ function atomicWriteJson(path: string, data: unknown): void {
181
+ mkdirSync(dirname(path), { recursive: true });
182
+ const tempPath = `${path}.tmp`;
183
+ writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
184
+ renameSync(tempPath, path);
185
+ }
186
+
187
+ export interface WorkspaceRegistrationMigrationResult {
188
+ readonly sourcePath: string;
189
+ readonly recordsMigrated: number;
190
+ readonly recordsAlreadyPresent: number;
191
+ readonly migratedAt: string;
192
+ }
193
+
194
+ /**
195
+ * Migrate the local per-user registry into the shared store, exactly once.
196
+ *
197
+ * Runs synchronously (see this module's doc comment on why). Idempotent via a
198
+ * receipt file: once written, later calls (any process, any startup) return
199
+ * null immediately without touching either file again — even if the legacy
200
+ * file is still present, and even if an owner later unregisters a migrated
201
+ * root through the new store (a migrated-then-removed root must not come back
202
+ * on the next start). A missing legacy file is not an error and writes no
203
+ * receipt (nothing happened worth remembering); an unparsable legacy file
204
+ * migrates zero records but still writes a receipt so it is never retried.
205
+ *
206
+ * Returns null when nothing was migrated this call (already migrated, or no
207
+ * legacy file); a caller logs only a real migration.
208
+ */
209
+ export function migrateLegacyWorkspaceRegistryIfNeeded(
210
+ shellPaths: StoreShellPaths,
211
+ ): WorkspaceRegistrationMigrationResult | null {
212
+ const receiptPath = migrationReceiptPath(shellPaths);
213
+ if (existsSync(receiptPath)) return null;
214
+
215
+ const legacyPath = legacyWorkspaceRegistryPath(shellPaths);
216
+ if (!existsSync(legacyPath)) return null;
217
+
218
+ let legacyRecords: RegisteredWorkspaceRecord[] = [];
219
+ try {
220
+ const parsed = JSON.parse(readFileSync(legacyPath, 'utf-8')) as unknown;
221
+ const list = isRecord(parsed) && Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
222
+ legacyRecords = list
223
+ .map(parseRegisteredRecord)
224
+ .filter((entry): entry is RegisteredWorkspaceRecord => entry !== null);
225
+ } catch {
226
+ legacyRecords = [];
227
+ }
228
+
229
+ const sharedPath = sharedWorkspaceRegistrationStorePath(shellPaths);
230
+ const existing = readSharedWorkspaceRegistrationSnapshotSync(shellPaths);
231
+ const existingRoots = new Set(existing.workspaces.map((entry) => entry.root));
232
+ const merged = [...existing.workspaces];
233
+ let migrated = 0;
234
+ let alreadyPresent = 0;
235
+ for (const record of legacyRecords) {
236
+ if (existingRoots.has(record.root)) {
237
+ alreadyPresent += 1;
238
+ continue;
239
+ }
240
+ existingRoots.add(record.root);
241
+ merged.push(record);
242
+ migrated += 1;
243
+ }
244
+
245
+ if (migrated > 0) {
246
+ atomicWriteJson(sharedPath, { version: 1, workspaces: merged, declines: existing.declines });
247
+ }
248
+
249
+ const result: WorkspaceRegistrationMigrationResult = {
250
+ sourcePath: legacyPath,
251
+ recordsMigrated: migrated,
252
+ recordsAlreadyPresent: alreadyPresent,
253
+ migratedAt: new Date().toISOString(),
254
+ };
255
+ atomicWriteJson(receiptPath, result);
256
+ return result;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // First-start registration prompt support
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function canonicalPath(path: string): string {
264
+ try {
265
+ return existsSync(path) ? realpathSync(path) : resolve(path);
266
+ } catch {
267
+ return resolve(path);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Would the shared store refuse to register `root` as too broad? Mirrors the
273
+ * SDK's broadRootReason (checkpoint/root-guard.ts — not part of the public
274
+ * platform/workspace export, so replicated here at the same single-purpose
275
+ * level as this module's other store-internal-logic mirrors) closely enough
276
+ * for a PRE-registration "would this be refused?" check. Used only to decide
277
+ * whether to OFFER registration in the first-start prompt; the store's own
278
+ * add() remains the authoritative guard at write time, never bypassed here.
279
+ */
280
+ export function isBroadWorkspaceRoot(shellPaths: StoreShellPaths, root: string): boolean {
281
+ const canonicalRoot = canonicalPath(root);
282
+ if (parse(canonicalRoot).root === canonicalRoot) return true;
283
+ if (canonicalRoot === canonicalPath(shellPaths.homeDirectory)) return true;
284
+ if (canonicalRoot === canonicalPath(shellPaths.resolveUserPath())) return true;
285
+ return false;
286
+ }
287
+
288
+ /**
289
+ * Resolve a first-start registration prompt's answer against the shared
290
+ * store: fire-and-forget (the caller is a keypress handler, not an async
291
+ * context) but never a silent failure — a write error is logged, not lost.
292
+ */
293
+ 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);
296
+ void outcome.catch((error: unknown) => {
297
+ logger.error('Failed to persist workspace registration prompt answer', { root, accepted, error: summarizeError(error) });
298
+ });
299
+ }