@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.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/package/{ast-grep-napi.linux-x64-gnu-mkk8xwww.node → ast-grep-napi.linux-x64-gnu-44ar68xs.node} +0 -0
- package/dist/package/{ast-grep-napi.linux-x64-musl-ryqtgdv6.node → ast-grep-napi.linux-x64-musl-asvc2tv1.node} +0 -0
- package/dist/package/main.js +135337 -118733
- package/dist/package/{web-tree-sitter-vmqc5d26.wasm → web-tree-sitter-jbz042ba.wasm} +0 -0
- package/docs/README.md +1 -1
- package/docs/tools-and-commands.md +9 -0
- package/package.json +5 -3
- package/src/agent/capability-registry.ts +300 -0
- package/src/agent/email/style-reply-lane.ts +13 -4
- package/src/agent/memory-consolidation-receipts.ts +79 -0
- package/src/agent/memory-prompt.ts +3 -1
- package/src/agent/operator-gateway-call.ts +134 -0
- package/src/agent/principal-attribution.ts +97 -0
- package/src/agent/prompt-context-receipts.ts +5 -1
- package/src/agent/unified-inbox.ts +17 -1
- package/src/cli/bundle-command.ts +2 -1
- package/src/cli/channel-profiles-command.ts +168 -0
- package/src/cli/ci-command.ts +214 -0
- package/src/cli/completion.ts +5 -0
- package/src/cli/connected-host-metrics.ts +165 -0
- package/src/cli/entrypoint.ts +25 -1
- package/src/cli/fleet-command.ts +178 -0
- package/src/cli/help.ts +115 -0
- package/src/cli/import-command.ts +208 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/management-commands.ts +9 -8
- package/src/cli/management.ts +42 -0
- package/src/cli/memory-command.ts +4 -3
- package/src/cli/openclaw-import.ts +382 -0
- package/src/cli/operator-command-args.ts +88 -0
- package/src/cli/parser.ts +12 -0
- package/src/cli/personas-command.ts +3 -2
- package/src/cli/principals-command.ts +230 -0
- package/src/cli/profiles-command.ts +2 -1
- package/src/cli/relay-command.ts +100 -0
- package/src/cli/routines-command.ts +4 -3
- package/src/cli/skill-bundle-command.ts +3 -2
- package/src/cli/skills-command.ts +3 -2
- package/src/cli/status.ts +114 -2
- package/src/cli/temporal-label.ts +89 -0
- package/src/cli/types.ts +7 -0
- package/src/cli/workspaces-command.ts +135 -0
- package/src/config/agent-settings-policy.ts +8 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/workspace-registration.ts +299 -0
- package/src/core/rewind-turn-anchors.ts +82 -0
- package/src/input/agent-workspace-categories.ts +1 -0
- package/src/input/agent-workspace-channel-triage.ts +27 -8
- package/src/input/agent-workspace-local-library-snapshot.ts +5 -1
- package/src/input/agent-workspace-onboarding-categories.ts +17 -1
- package/src/input/agent-workspace-snapshot.ts +25 -3
- package/src/input/agent-workspace-types.ts +5 -5
- package/src/input/commands/connected-host-admin-runtime.ts +75 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-types.ts +16 -5
- package/src/main.ts +17 -12
- package/src/permissions/approval-posture.ts +40 -5
- package/src/permissions/prompt.ts +35 -1
- package/src/renderer/settings-modal-helpers.ts +11 -0
- package/src/renderer/settings-modal.ts +21 -0
- package/src/renderer/terminal-escapes.ts +65 -12
- package/src/runtime/agent-runtime-events.ts +102 -3
- package/src/runtime/bootstrap-core.ts +111 -10
- package/src/runtime/bootstrap-external-services.ts +156 -0
- package/src/runtime/bootstrap.ts +73 -69
- package/src/runtime/context-accounting-source.ts +114 -0
- package/src/runtime/conversation-rewind-port.ts +133 -0
- package/src/runtime/index.ts +8 -28
- package/src/runtime/memory-consolidation-scheduler.ts +67 -0
- package/src/runtime/memory-consolidation-wiring.ts +56 -0
- package/src/runtime/memory-usage-wiring.ts +75 -0
- package/src/runtime/services.ts +250 -14
- package/src/runtime/tool-permission-safety.ts +18 -1
- package/src/shell/blocking-input.ts +38 -8
- package/src/shell/startup-wiring.ts +26 -1
- package/src/tools/agent-context-policy.ts +1 -1
- package/src/tools/agent-harness-cli-command-policy.ts +29 -3
- package/src/tools/agent-harness-learning-auto-promote.ts +7 -3
- package/src/tools/agent-harness-memory-posture.ts +57 -2
- package/src/tools/agent-harness-metadata.ts +12 -0
- package/src/tools/agent-harness-mode-catalog.ts +1 -1
- package/src/tools/agent-harness-personal-ops-lanes.ts +8 -6
- package/src/tools/agent-harness-prompt-context.ts +3 -1
- package/src/tools/agent-harness-tool.ts +3 -2
- package/src/tools/agent-policy-explanation.ts +40 -0
- package/src/tools/tool-definition-compaction.ts +2 -0
- 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
|
+
}
|