@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,62 @@
1
+ import { TriggerManager } from '@pellux/goodvibes-sdk/platform/triggers';
2
+ import { createBunStreamHost, createProcessManagerTriggerHost, createTriggerActionExecutor } from '@pellux/goodvibes-sdk/platform/triggers';
3
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
4
+ import type { AgentManager, ProcessManager } from '@pellux/goodvibes-sdk/platform/tools';
5
+ import type { SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
6
+ import type { ShellPathService } from '@/runtime/index.ts';
7
+
8
+ /**
9
+ * The trigger family: stream watchers, on-exit process triggers, and condition
10
+ * checks, supervised as one.
11
+ *
12
+ * This daemon composes the full family and feeds the manager to the fleet as
13
+ * its trigger supervisor, so a trigger defined against the daemon fires
14
+ * reliably. The daemon is the right process to own it — it is the one that
15
+ * stays running.
16
+ *
17
+ * Two things about the shape are load-bearing:
18
+ * - `config` is a CLOSURE over the config manager rather than a snapshot, so
19
+ * toggling `watchers.triggers.*` takes effect on the next read instead of at
20
+ * the next restart.
21
+ * - the process host is ProcessManager-backed, so a supervised on-exit child
22
+ * inherits the same credential-environment scrub, live output collection and
23
+ * SIGTERM/SIGKILL watchdog as any other background command the daemon runs.
24
+ */
25
+ export function createTriggerServices(deps: {
26
+ readonly configManager: ConfigManager;
27
+ readonly shellPaths: ShellPathService;
28
+ readonly surfaceRoot: string;
29
+ readonly agentManager: AgentManager;
30
+ readonly processManager: ProcessManager;
31
+ readonly sessionBroker: Pick<SharedSessionBroker, 'getSession'>;
32
+ }): TriggerManager {
33
+ const { configManager } = deps;
34
+ return new TriggerManager({
35
+ storePath: deps.shellPaths.resolveProjectPath(deps.surfaceRoot, 'triggers.json'),
36
+ config: () => ({
37
+ enabled: configManager.get('watchers.triggers.enabled'),
38
+ backoffLadderMs: configManager.get('watchers.triggers.backoffLadderMs'),
39
+ breakerStrikes: configManager.get('watchers.triggers.breakerStrikes'),
40
+ defaultCheckIntervalMs: configManager.get('watchers.triggers.defaultCheckIntervalMs'),
41
+ probeTimeoutMs: configManager.get('watchers.triggers.probeTimeoutMs'),
42
+ maxConcurrentChecks: configManager.get('watchers.triggers.maxConcurrentChecks'),
43
+ observationRingSize: configManager.get('watchers.triggers.observationRingSize'),
44
+ runHistoryLimit: configManager.get('watchers.triggers.runHistoryLimit'),
45
+ runHistoryTtlHours: configManager.get('watchers.triggers.runHistoryTtlHours'),
46
+ eventLogLimit: configManager.get('watchers.triggers.eventLogLimit'),
47
+ eventLogTtlHours: configManager.get('watchers.triggers.eventLogTtlHours'),
48
+ sweepIntervalMs: configManager.get('watchers.triggers.sweepIntervalMs'),
49
+ supervisionTickMs: configManager.get('watchers.triggers.supervisionTickMs'),
50
+ streamQueueLimit: configManager.get('watchers.triggers.streamQueueLimit'),
51
+ streamBatchLines: configManager.get('watchers.triggers.streamBatchLines'),
52
+ streamBatchIntervalMs: configManager.get('watchers.triggers.streamBatchIntervalMs'),
53
+ onExitMaxDurationMs: configManager.get('watchers.triggers.onExitMaxDurationMs'),
54
+ onExitStdin: configManager.get('watchers.triggers.onExitStdin'),
55
+ outputTailBytes: configManager.get('watchers.triggers.outputTailBytes'),
56
+ }),
57
+ actions: createTriggerActionExecutor({ agents: deps.agentManager, processManager: deps.processManager }),
58
+ processHost: createProcessManagerTriggerHost(deps.processManager),
59
+ streamHost: createBunStreamHost(),
60
+ sessionIsLive: (sessionId: string) => deps.sessionBroker.getSession(sessionId) !== null,
61
+ });
62
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import type { ShellPathService } from '@/runtime/index.ts';
3
+ import {
4
+ normalizeWorkspaceRoot,
5
+ probeWorktreeLink,
6
+ resolveWorkspaceRegistration,
7
+ type DeclinedWorkspaceRecord,
8
+ type RegisteredWorkspaceRecord,
9
+ type WorkspaceCoverageStatus,
10
+ type WorkspaceGitMetadata,
11
+ type WorkspaceResolution,
12
+ } from '@pellux/goodvibes-sdk/platform/workspace';
13
+
14
+ /**
15
+ * Checkpoint eligibility, read live off the shared workspace-registration store.
16
+ *
17
+ * The daemon takes automatic checkpoints on turn and agent-run lifecycle events.
18
+ * Which workspaces that is allowed to happen in is the owner's registered-
19
+ * workspaces-only ruling: only a workspace that was EXPLICITLY registered for
20
+ * checkpoints (`checkpointEligible`) qualifies, and a directory somebody merely
21
+ * opened in a surface never silently becomes checkpoint-eligible.
22
+ *
23
+ * This read is synchronous by design. The decision is made per lifecycle event,
24
+ * inside a subscription callback that cannot await, and it has to reflect the
25
+ * store as it is on disk RIGHT NOW — registering a workspace while the daemon is
26
+ * running has to take effect on the next eligible event, not on the next
27
+ * restart. The SDK's resolver is pure, so the only I/O is one small JSON read
28
+ * plus a single git worktree probe amortized at construction.
29
+ */
30
+
31
+ export type StoreShellPaths = Pick<ShellPathService, 'resolveUserPath' | 'homeDirectory'>;
32
+
33
+ /** Path of the shared store's JSON document — the same path the SDK's gateway verb group constructs its own store over. */
34
+ export function sharedWorkspaceRegistrationStorePath(shellPaths: StoreShellPaths): string {
35
+ return shellPaths.resolveUserPath('control-plane', 'workspace-registrations.json');
36
+ }
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
40
+ }
41
+
42
+ function readString(value: unknown): string {
43
+ return typeof value === 'string' ? value.trim() : '';
44
+ }
45
+
46
+ function parseRegisteredRecord(value: unknown): RegisteredWorkspaceRecord | null {
47
+ if (!isRecord(value)) return null;
48
+ const root = readString(value.root);
49
+ const registeredAt = readString(value.registeredAt);
50
+ if (!root || !registeredAt || Number.isNaN(Date.parse(registeredAt))) return null;
51
+ const label = readString(value.label);
52
+ const origin = readString(value.origin);
53
+ return {
54
+ root: normalizeWorkspaceRoot(root),
55
+ registeredAt,
56
+ ...(label ? { label } : {}),
57
+ ...(origin ? { origin } : {}),
58
+ // Strictly `true` only; any other value (including absent) is not eligible.
59
+ ...(value.checkpointEligible === true ? { checkpointEligible: true } : {}),
60
+ };
61
+ }
62
+
63
+ function parseDeclinedRecord(value: unknown): DeclinedWorkspaceRecord | null {
64
+ if (!isRecord(value)) return null;
65
+ const root = readString(value.root);
66
+ const declinedAt = readString(value.declinedAt);
67
+ if (!root || !declinedAt || Number.isNaN(Date.parse(declinedAt))) return null;
68
+ return { root: normalizeWorkspaceRoot(root), declinedAt };
69
+ }
70
+
71
+ interface SharedRegistrationSnapshot {
72
+ readonly workspaces: readonly RegisteredWorkspaceRecord[];
73
+ readonly declines: readonly DeclinedWorkspaceRecord[];
74
+ }
75
+
76
+ /**
77
+ * Synchronous read of the shared store's on-disk JSON, mirroring the store's own
78
+ * validation exactly (version 1, workspaces[], declines[]). A missing or
79
+ * unparsable file reads as empty — never throws.
80
+ */
81
+ export function readSharedWorkspaceRegistrationSnapshotSync(shellPaths: StoreShellPaths): SharedRegistrationSnapshot {
82
+ const path = sharedWorkspaceRegistrationStorePath(shellPaths);
83
+ if (!existsSync(path)) return { workspaces: [], declines: [] };
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
86
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.workspaces)) {
87
+ return { workspaces: [], declines: [] };
88
+ }
89
+ const workspaces = parsed.workspaces
90
+ .map(parseRegisteredRecord)
91
+ .filter((entry): entry is RegisteredWorkspaceRecord => entry !== null);
92
+ const declineList = Array.isArray(parsed.declines) ? parsed.declines : [];
93
+ const declines = declineList
94
+ .map(parseDeclinedRecord)
95
+ .filter((entry): entry is DeclinedWorkspaceRecord => entry !== null);
96
+ return { workspaces, declines };
97
+ } catch {
98
+ return { workspaces: [], declines: [] };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Resolve `path` against ONLY the checkpoint-eligible registrations — the
104
+ * boundary the automatic and explicit checkpoint gates consume. Worktree-link
105
+ * inheritance still applies: a linked worktree of a checkpoint-eligible main
106
+ * repository resolves as covered.
107
+ */
108
+ export function resolveCheckpointEligibilitySync(
109
+ shellPaths: StoreShellPaths,
110
+ path: string,
111
+ git?: WorkspaceGitMetadata,
112
+ ): WorkspaceResolution {
113
+ const snapshot = readSharedWorkspaceRegistrationSnapshotSync(shellPaths);
114
+ const eligible = snapshot.workspaces.filter((entry) => entry.checkpointEligible === true);
115
+ const gitMeta = git ?? probeWorktreeLink(path);
116
+ return resolveWorkspaceRegistration({
117
+ path,
118
+ git: gitMeta,
119
+ registrations: eligible,
120
+ declines: snapshot.declines,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Build a cheap, repeatable live checkpoint-eligibility checker for one fixed
126
+ * workspace root. `probeWorktreeLink` (a `git` subprocess spawn) runs ONCE here,
127
+ * since a long-running process's working directory and its git-worktree
128
+ * relationship do not change mid-launch; every subsequent call only re-reads the
129
+ * shared registration JSON file, which is cheap enough to call on every
130
+ * turn/agent-lifecycle event.
131
+ */
132
+ export function createWorkspaceRegistrationLiveChecker(
133
+ shellPaths: StoreShellPaths,
134
+ path: string,
135
+ ): () => WorkspaceCoverageStatus {
136
+ const git = probeWorktreeLink(path);
137
+ return () => resolveCheckpointEligibilitySync(shellPaths, path, git).status;
138
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * trust-gated-approvals.ts — how a headless daemon asks the workspace trust
3
+ * question, and how the answer reaches the runs it hosts.
4
+ *
5
+ * The terminal app composes the trust gate at the permission machinery's final
6
+ * ask layer and raises the question as a modal on its own screen. The daemon
7
+ * has the same gate — `trustGatedAsk` next door, reading the same
8
+ * `<cwd>/.goodvibes/<surface>/trust.json` the terminal app writes — and no
9
+ * screen to raise anything on. Before this module it therefore
10
+ * did neither: the gate was constructed, never loaded, never consulted, and no
11
+ * hosted run passed through it.
12
+ *
13
+ * Two pieces close that:
14
+ *
15
+ * 1. `createWorkspaceTrustDecisionAsk` raises the trust question as an
16
+ * ordinary approval record. That is the whole point of the approval-raise
17
+ * path: a process with no screen states the question, publishes it on
18
+ * `approval-update`, and whichever surface is attached answers it. Approved
19
+ * means "trusted", denied means "restricted" — a real decision either way,
20
+ * persisted by the gate, and never asked again for this workspace.
21
+ *
22
+ * 2. `trustGatedApprovalRaiser` puts the gate in front of the raiser the
23
+ * permission manager asks through, and loads the persisted decision before
24
+ * consulting it. The load is here rather than in the composition root
25
+ * because `createRuntimeServices` is synchronous: a fire-and-forget load
26
+ * started at composition time can lose the race with the first hosted run,
27
+ * and losing it means re-asking a question the user already answered.
28
+ * `WorkspaceTrustManager.load()` is idempotent, so paying for it on every
29
+ * ask costs one already-resolved promise after the first.
30
+ *
31
+ * What the daemon does NOT do here is decide by default. An untrusted
32
+ * workspace's hosted run neither runs as if trusted nor fails as if refused: it
33
+ * asks. If the ask cannot be answered — nothing attached, or the broker itself
34
+ * failed — the workspace stays undecided and this run is refused, with the
35
+ * reason in the log rather than in a silence. A refusal that records nothing is
36
+ * the failure mode this whole seam exists to remove.
37
+ */
38
+ import { randomUUID } from 'node:crypto';
39
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
40
+ import type { PermissionPromptDecision, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
41
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
42
+ const { trustGatedAsk } = operations;
43
+ type WorkspaceTrustLevel = operations.WorkspaceTrustLevel;
44
+ type WorkspaceTrustManager = operations.WorkspaceTrustManager;
45
+
46
+ /**
47
+ * The extra fields a raise carries beside the request itself — the attribution
48
+ * routing/metadata a background-agent ask is stamped with before it reaches the
49
+ * broker. The gate only reads `request.category`, so these ride around it.
50
+ */
51
+ export interface ApprovalRaiseExtras {
52
+ readonly routeId?: string | undefined;
53
+ readonly metadata?: Record<string, unknown> | undefined;
54
+ /** Expiry for a raised ask; the trust question sets one, tool asks do not. */
55
+ readonly timeoutMs?: number | undefined;
56
+ }
57
+
58
+ /** Raise an ask and wait for the answer. Matches the SDK's ApprovalRaiser seam. */
59
+ export type ApprovalRaise = (
60
+ input: { readonly request: PermissionPromptRequest } & ApprovalRaiseExtras,
61
+ ) => Promise<PermissionPromptDecision>;
62
+
63
+ /**
64
+ * How long a raised trust question stays open before it expires.
65
+ *
66
+ * A hosted run blocked on a question nobody is there to answer is a run that
67
+ * never finishes, and an unattended daemon is the normal case, not the edge
68
+ * one. Ten minutes is long enough for someone who is at a surface to see the
69
+ * ask and answer it, and short enough that an unattended run fails with a
70
+ * reason instead of hanging until the process restarts.
71
+ */
72
+ export const WORKSPACE_TRUST_ASK_TIMEOUT_MS = 10 * 60 * 1000;
73
+
74
+ export interface WorkspaceTrustDecisionAskDeps {
75
+ readonly requestApproval: ApprovalRaise;
76
+ /** The workspace the question is about — it names the directory being trusted. */
77
+ readonly workingDirectory: string;
78
+ readonly timeoutMs?: number | undefined;
79
+ }
80
+
81
+ /**
82
+ * Build the `requestTrustDecision` callback `trustGatedAsk` calls when a
83
+ * workspace has no decision yet.
84
+ *
85
+ * The question is raised as a `read`-category request on purpose: it is the
86
+ * trust question itself, raised BY the gate, and routing it back through the
87
+ * gate would ask the gate to decide whether it may ask.
88
+ */
89
+ export function createWorkspaceTrustDecisionAsk(
90
+ deps: WorkspaceTrustDecisionAskDeps,
91
+ ): () => Promise<WorkspaceTrustLevel> {
92
+ return async () => {
93
+ const request: PermissionPromptRequest = {
94
+ callId: `workspace-trust-${randomUUID().slice(0, 8)}`,
95
+ tool: 'workspace-trust',
96
+ args: { workspace: deps.workingDirectory },
97
+ category: 'read',
98
+ analysis: {
99
+ classification: 'workspace-trust',
100
+ riskLevel: 'high',
101
+ summary: `Trust the workspace ${deps.workingDirectory}?`,
102
+ reasons: [
103
+ `A run hosted by this daemon wants to write, execute, or delegate in "${deps.workingDirectory}", which has no trust decision recorded.`,
104
+ 'Approving marks the workspace trusted and stops this question being asked for it again.',
105
+ 'Denying marks it restricted: reads keep working, and writes, commands and delegation are refused there.',
106
+ ],
107
+ target: deps.workingDirectory,
108
+ targetKind: 'path',
109
+ blastRadius: 'local',
110
+ },
111
+ };
112
+ try {
113
+ const decision = await deps.requestApproval({
114
+ request,
115
+ timeoutMs: deps.timeoutMs ?? WORKSPACE_TRUST_ASK_TIMEOUT_MS,
116
+ metadata: { source: 'workspace-trust', workspace: deps.workingDirectory },
117
+ });
118
+ return decision.approved ? 'trusted' : 'restricted';
119
+ } catch (error) {
120
+ // Nothing attached, or the broker failed. Say so and leave the workspace
121
+ // undecided — the next run asks again rather than inheriting a decision
122
+ // nobody made.
123
+ logger.warn('Workspace trust question could not be answered; this run is refused and the workspace stays undecided', {
124
+ workspace: deps.workingDirectory,
125
+ error: error instanceof Error ? error.message : String(error),
126
+ });
127
+ throw error;
128
+ }
129
+ };
130
+ }
131
+
132
+ /** The slice of the trust manager this seam uses. */
133
+ export type TrustGateManager =
134
+ & Pick<WorkspaceTrustManager, 'isCategoryAllowed' | 'isDecided' | 'setLevel'>
135
+ & { load(): Promise<void> };
136
+
137
+ /**
138
+ * Wrap an approval raiser with the workspace trust gate.
139
+ *
140
+ * Layering matches the terminal app's: the gate is the outer ask, the broker
141
+ * raise is the inner one, and the permission manager's own layers (mode,
142
+ * policy, session cache, durable rules) still run before either. A trusted
143
+ * workspace is exactly as permissive as it was; a restricted one refuses
144
+ * non-read categories without asking, because that is what the user chose.
145
+ *
146
+ * `routeId`/`metadata` reach the inner raise unchanged. The gate's own
147
+ * signature carries only the request, so the extras are held against the
148
+ * request object for the duration of the call rather than in a shared slot a
149
+ * concurrent ask could overwrite.
150
+ */
151
+ export function trustGatedApprovalRaiser(
152
+ manager: TrustGateManager,
153
+ raise: ApprovalRaise,
154
+ requestTrustDecision: () => Promise<WorkspaceTrustLevel>,
155
+ ): ApprovalRaise {
156
+ const extrasByRequest = new WeakMap<PermissionPromptRequest, ApprovalRaiseExtras>();
157
+ const gated = trustGatedAsk(
158
+ manager,
159
+ (request) => raise({ request, ...(extrasByRequest.get(request) ?? {}) }),
160
+ requestTrustDecision,
161
+ );
162
+ return async (input) => {
163
+ extrasByRequest.set(input.request, { routeId: input.routeId, metadata: input.metadata });
164
+ // Idempotent, and awaited on every ask rather than once at composition:
165
+ // see the module header for why the composition root cannot await it.
166
+ await manager.load();
167
+ return gated(input.request);
168
+ };
169
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure logic for `/update`: version comparison, the latest-release-tag
3
+ * redirect lookup, and honest install-kind detection.
4
+ *
5
+ * Version comparison and the release-tag lookup are re-exported from the
6
+ * SDK's canonical update policy module (platform/runtime/self-update), which
7
+ * was hoisted from this file's semantics — one mechanism everywhere.
8
+ * Install-kind detection stays local: it encodes how THIS package is
9
+ * installed (compiled binary vs bun/npm package vs source run) and what
10
+ * command replaces a swap for each kind.
11
+ *
12
+ * The self-update download/verify/swap orchestration that USES these lives
13
+ * in this daemon's own hourly loop (`src/daemon/lifecycle.ts`); this module
14
+ * only decides "is there a newer version" and "can this install be swapped
15
+ * in place". The terminal app's own manual `/update` command solves the same
16
+ * problem for its own users, independently.
17
+ */
18
+ export {
19
+ compareVersions,
20
+ normalizeVersion,
21
+ parseReleaseTagFromLocation,
22
+ resolveLatestReleaseTag,
23
+ type UpdateFetchLike,
24
+ } from '@pellux/goodvibes-sdk/platform/runtime/self-update';
25
+
26
+ /**
27
+ * How this running process was installed, detected honestly from
28
+ * process.execPath rather than assumed:
29
+ * - "binary": a standalone `bun build --compile` executable with no
30
+ * package-manager ancestry — the scripts/install.sh install path.
31
+ * Swappable in place.
32
+ * - "bun-global-package": running the vendored binary shipped inside an
33
+ * npm/bun-managed package install (execPath contains a "node_modules"
34
+ * path segment — true for both `bun add -g` and a local project
35
+ * dependency). Managed by the package manager; swapping the vendored
36
+ * file in place would fight the next `bun add -g` upgrade, so this is
37
+ * never swapped — the user re-runs their package manager instead.
38
+ * - "source": running directly via the `bun` interpreter (`bun run
39
+ * src/main.ts`), not a compiled binary at all.
40
+ */
41
+ export type InstallKind = 'binary' | 'bun-global-package' | 'source';
42
+
43
+ export function detectInstallKind(execPath: string): InstallKind {
44
+ const segments = execPath.split(/[\\/]/);
45
+ const execName = (segments[segments.length - 1] ?? '').toLowerCase();
46
+ if (execName === 'bun' || execName === 'bun.exe') {
47
+ return 'source';
48
+ }
49
+ if (segments.includes('node_modules')) {
50
+ return 'bun-global-package';
51
+ }
52
+ return 'binary';
53
+ }
54
+
55
+ /** The exact command to tell the user to run instead of a swap, for each non-binary install kind. */
56
+ export function fallbackUpdateCommand(kind: Exclude<InstallKind, 'binary'>): string {
57
+ if (kind === 'bun-global-package') {
58
+ return 'bun add -g goodvibes-daemon';
59
+ }
60
+ return 'curl -fsSL https://goodvibes.sh/install.sh | sh';
61
+ }
@@ -0,0 +1,116 @@
1
+ import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
2
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
+ import type { RuntimeEventBus, SessionSurface } from '@/runtime/index.ts';
4
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
+ import { readCheckpointGuardSettings, readCheckpointRegistrationSetting } from '../config/checkpoint-settings.ts';
6
+ import { createWorkspaceRegistrationLiveChecker, type StoreShellPaths } from './trust/checkpoint-eligibility.ts';
7
+
8
+ /**
9
+ * The workspace checkpoint manager, gated on live registration.
10
+ *
11
+ * This daemon's checkpoint manager scopes the checkpoint git store to the
12
+ * surface's own directory AND gates every automatic snapshot on the owner's
13
+ * registered-workspaces-only ruling.
14
+ *
15
+ * The gate is a LIVE re-check rather than a construction-time decision. The
16
+ * manager only subscribes to turn/agent-lifecycle events when it is built with a
17
+ * runtime bus, so building it without one for an unregistered workspace would
18
+ * mean registering that workspace mid-run had no effect until a restart. It is
19
+ * always built WITH the bus and each individual automatic snapshot attempt is
20
+ * refused instead — by overriding this one instance's own `create`, since the
21
+ * manager has no predicate hook and `create` is the single seam both the
22
+ * automatic subscription and every explicit caller pass through.
23
+ */
24
+
25
+ export interface WorkspaceCheckpointing {
26
+ /** The manager itself — automatic snapshots gated, explicit creates unrestricted. */
27
+ readonly manager: WorkspaceCheckpointManager;
28
+ /**
29
+ * The narrower surface handed to the `checkpoints.*` gateway verbs: identical
30
+ * except that an explicit create refuses, with an actionable message, when the
31
+ * workspace is not checkpoint-eligible. Reads (list/diff/sessionChanges) and
32
+ * restore stay unrestricted — they operate over checkpoints that may already
33
+ * exist, including from a since-unregistered workspace.
34
+ */
35
+ readonly gatewayManager: Pick<WorkspaceCheckpointManager, 'list' | 'create' | 'diff' | 'restore' | 'sessionChanges' | 'workspaceRoot'>;
36
+ /** Whether checkpoints are currently permitted for this workspace. Re-reads the store on every call. */
37
+ readonly currentlyAllowed: () => boolean;
38
+ }
39
+
40
+ export function createWorkspaceCheckpointing(opts: {
41
+ readonly workspaceRoot: string;
42
+ /**
43
+ * The declare-once storage handle. Passing it resolves the checkpoint git
44
+ * store to `surface.checkpointsDir` instead of the unscoped
45
+ * `<workspaceRoot>/.goodvibes/checkpoints` every product using this SDK would
46
+ * otherwise share. The SDK migrates an existing legacy store into the scoped
47
+ * location on first use.
48
+ */
49
+ readonly surface: SessionSurface;
50
+ readonly runtimeBus: RuntimeEventBus;
51
+ readonly configManager: ConfigManager;
52
+ readonly shellPaths: StoreShellPaths;
53
+ /** Stamps automatic snapshots with the live session id, so a checkpoint made this launch is found by the session-scoped restore lookup. */
54
+ readonly resolveSessionId?: (ctx: { readonly turnId?: string | undefined; readonly agentId?: string | undefined }) => string | undefined;
55
+ }): WorkspaceCheckpointing {
56
+ const registrationStatus = createWorkspaceRegistrationLiveChecker(opts.shellPaths, opts.workspaceRoot);
57
+ const currentlyAllowed = (): boolean =>
58
+ registrationStatus() === 'covered' || readCheckpointRegistrationSetting(opts.configManager) === 'guarded';
59
+
60
+ const manager = new WorkspaceCheckpointManager({
61
+ workspaceRoot: opts.workspaceRoot,
62
+ // Root and retention guards from the owner's `checkpoints.*` settings. These
63
+ // are defense in depth UNDER the registration rule, not a replacement for
64
+ // it: even a registered root can still be refused as too broad or too large.
65
+ ...readCheckpointGuardSettings(opts.configManager),
66
+ surface: opts.surface,
67
+ runtimeBus: opts.runtimeBus,
68
+ ...(opts.resolveSessionId ? { resolveSessionId: opts.resolveSessionId } : {}),
69
+ });
70
+
71
+ // Automatic snapshots ('turn' | 'agent-run', fired by the manager's own bus
72
+ // subscription) resolve to null quietly when the workspace is not eligible —
73
+ // there is no caller to throw to, and the manager already documents a null
74
+ // return as the cheap no-op for an unchanged tree. Explicit ('manual') creates
75
+ // are NOT re-gated here: they go through the gateway surface below, which
76
+ // throws something actionable before ever reaching this method.
77
+ const originalCreate = manager.create.bind(manager);
78
+ manager.create = ((createOpts) => {
79
+ if (createOpts.kind !== 'manual' && !currentlyAllowed()) return Promise.resolve(null);
80
+ return originalCreate(createOpts);
81
+ }) as typeof manager.create;
82
+
83
+ // Eagerly initialize so the automatic-snapshot subscription is live before the
84
+ // first turn completes. If init() rejects, the manager caches that rejection
85
+ // forever and every later call re-throws it — the catch here only prevents an
86
+ // unhandled rejection at startup; the checkpoint verbs report the failure to
87
+ // whoever calls them.
88
+ void manager.init().catch((error: unknown) => {
89
+ logger.warn('WorkspaceCheckpointManager.init failed', { error: error instanceof Error ? error.message : String(error) });
90
+ });
91
+
92
+ const gatewayManager: WorkspaceCheckpointing['gatewayManager'] = {
93
+ workspaceRoot: manager.workspaceRoot,
94
+ list: manager.list.bind(manager),
95
+ diff: manager.diff.bind(manager),
96
+ restore: manager.restore.bind(manager),
97
+ sessionChanges: manager.sessionChanges.bind(manager),
98
+ create: (createOpts) => {
99
+ if (!currentlyAllowed()) {
100
+ throw new Error(
101
+ `Checkpoints are off for this workspace: ${opts.workspaceRoot} is not registered. `
102
+ + 'Register it first, then retry (or set checkpoints.unregisteredWorkspaces to "guarded" '
103
+ + 'to opt this workspace out of the registration gate).',
104
+ );
105
+ }
106
+ // Default the session stamp from the live resolver when the caller omits
107
+ // it — the resolveSessionId hook only auto-stamps automatic snapshots, so
108
+ // without this an explicit checkpoint made this launch would be written
109
+ // unstamped and excluded by the session-scoped restore lookup.
110
+ const sessionId = createOpts.sessionId ?? opts.resolveSessionId?.({});
111
+ return manager.create(sessionId ? { ...createOpts, sessionId } : createOpts);
112
+ },
113
+ };
114
+
115
+ return { manager, gatewayManager, currentlyAllowed };
116
+ }