@pellux/goodvibes-tui 1.13.1 → 1.14.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 (158) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +33 -33
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
@@ -0,0 +1,184 @@
1
+ /**
2
+ * workspace-registration.ts — first-open workspace REGISTRATION half.
3
+ *
4
+ * Companion to the per-workspace trust gate (workspace-trust.ts). Trust is
5
+ * TUI-local (a decision persisted under <cwd>/.goodvibes/tui); REGISTRATION is
6
+ * the platform-wide "which project roots has the operator opted GoodVibes into"
7
+ * registry, owned by the SDK.
8
+ *
9
+ * SINGLE SOURCE OF TRUTH. Resolution and mutation both go through the SDK's
10
+ * shared WorkspaceRegistrationStore — the exact class the daemon composes its
11
+ * `workspaces.*` gateway verbs over (control-plane/routes/register-gateway-verb-
12
+ * groups.ts constructs `new WorkspaceRegistrationStore({ path: resolveUserPath(
13
+ * 'control-plane', 'workspace-registrations.json'), homeDir: dirname(daemonState
14
+ * Dir), daemonStateDir })`). We point an in-process store at that identical path
15
+ * and roots, so whether the daemon's `workspaces.resolve` verb reads it over the
16
+ * wire or we read it in-process, both operate on ONE persisted document. This is
17
+ * not a divergent reimplementation: same SDK store class, same file, same
18
+ * root-guard. In-process (rather than an RPC to a composed daemon) because the
19
+ * first-open prompt renders synchronously at startup and must not depend on a
20
+ * daemon being up — the file is the shared source either way.
21
+ *
22
+ * Coverage semantics are the store's (implemented in the SDK, not here): coverage
23
+ * flows DOWN a registered root's subtree, a linked git worktree inherits its main
24
+ * repo's registration, and a remembered decline is subtree-scoped at the root it
25
+ * was asked. So the register half of the first-open prompt only appears when
26
+ * resolution says UNKNOWN; a covered or declined path never re-asks.
27
+ */
28
+ import { dirname } from 'node:path';
29
+ import { parse as parsePath } from 'node:path';
30
+ import {
31
+ WorkspaceRegistrationStore,
32
+ WorkspaceRegistrationError,
33
+ normalizeWorkspaceRoot,
34
+ type WorkspaceCoverageStatus,
35
+ type RegisterWorkspaceResult,
36
+ } from '@pellux/goodvibes-sdk/platform/workspace';
37
+
38
+ /** The slice of ShellPathService this manager needs. */
39
+ export interface WorkspaceRegistrationShellPaths {
40
+ readonly workingDirectory: string;
41
+ readonly homeDirectory: string;
42
+ resolveUserPath(...segments: string[]): string;
43
+ }
44
+
45
+ /** Filename of the shared control-plane registry document (matches the daemon composition). */
46
+ const REGISTRATIONS_FILE = 'workspace-registrations.json';
47
+
48
+ export interface WorkspaceRegistrationEvaluation {
49
+ /** Normalized absolute working directory the prompt would ask about. */
50
+ readonly root: string;
51
+ /** Coverage verdict from the shared store: covered / declined / unknown. */
52
+ readonly status: WorkspaceCoverageStatus;
53
+ /** The nearest registered root covering the path, or null. */
54
+ readonly coveredBy: string | null;
55
+ /** True when coverage was inherited through the git worktree→main-repo link. */
56
+ readonly viaWorktreeLink: boolean;
57
+ /** True when the root is one the store would refuse ($HOME, filesystem root, ~/.goodvibes). */
58
+ readonly broad: boolean;
59
+ /**
60
+ * Whether to OFFER the register half of the first-open prompt: only when the
61
+ * store says UNKNOWN and the root is registrable. Broad roots are never
62
+ * offered — the store refuses them, so we do not prompt for what would be
63
+ * refused. Covered / declined roots never re-ask.
64
+ */
65
+ readonly offerRegister: boolean;
66
+ /** Human-readable justification from the resolver. */
67
+ readonly reason: string;
68
+ }
69
+
70
+ export interface WorkspaceRegistrationManagerOptions {
71
+ readonly shellPaths: WorkspaceRegistrationShellPaths;
72
+ /**
73
+ * Inject a pre-built store (e.g. a `:memory:` store for tests). Defaults to an
74
+ * in-process store at the shared control-plane path, configured identically to
75
+ * the daemon's.
76
+ */
77
+ readonly store?: WorkspaceRegistrationStore;
78
+ }
79
+
80
+ export class WorkspaceRegistrationManager {
81
+ private readonly store: WorkspaceRegistrationStore;
82
+ private readonly workingDirectory: string;
83
+ private readonly homeDir: string;
84
+ private readonly daemonStateDir: string;
85
+
86
+ constructor(options: WorkspaceRegistrationManagerOptions) {
87
+ const { shellPaths } = options;
88
+ this.workingDirectory = shellPaths.workingDirectory;
89
+ // Mirror the daemon's composition exactly (register-gateway-verb-groups.ts):
90
+ // the daemon state dir IS resolveUserPath() (~/.goodvibes); its parent is the
91
+ // home directory — both refused as broad roots by the same guard.
92
+ this.daemonStateDir = shellPaths.resolveUserPath();
93
+ this.homeDir = dirname(this.daemonStateDir);
94
+ this.store =
95
+ options.store ??
96
+ new WorkspaceRegistrationStore({
97
+ path: shellPaths.resolveUserPath('control-plane', REGISTRATIONS_FILE),
98
+ homeDir: this.homeDir,
99
+ daemonStateDir: this.daemonStateDir,
100
+ });
101
+ }
102
+
103
+ /**
104
+ * isBroadRoot — mirrors the SDK store's broad-root refusal SET (filesystem
105
+ * root, home directory, daemon state dir) so the register half is never
106
+ * offered for a root the store would refuse. This is a UI-suppression predicate
107
+ * only; the store's `add` remains the enforcement authority (see register()),
108
+ * which is why register() also catches WorkspaceRegistrationError.
109
+ */
110
+ private isBroadRoot(normalizedRoot: string): boolean {
111
+ if (normalizedRoot === parsePath(normalizedRoot).root) return true;
112
+ if (normalizedRoot === normalizeWorkspaceRoot(this.homeDir)) return true;
113
+ if (normalizedRoot === normalizeWorkspaceRoot(this.daemonStateDir)) return true;
114
+ return false;
115
+ }
116
+
117
+ /**
118
+ * Resolve the current working directory against the shared registry and decide
119
+ * whether the register half of the first-open prompt should appear. Read-only —
120
+ * never writes, so it is safe to call from `status`/`doctor`.
121
+ */
122
+ async evaluate(): Promise<WorkspaceRegistrationEvaluation> {
123
+ const resolution = await this.store.resolve(this.workingDirectory);
124
+ const broad = this.isBroadRoot(resolution.path);
125
+ return {
126
+ root: resolution.path,
127
+ status: resolution.status,
128
+ coveredBy: resolution.coveredBy,
129
+ viaWorktreeLink: resolution.viaWorktreeLink,
130
+ broad,
131
+ offerRegister: resolution.status === 'unknown' && !broad,
132
+ reason: resolution.reason,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Register the current working directory at its resolved project root. The
138
+ * store refuses broad roots (WorkspaceRegistrationError) — we surface that
139
+ * honestly rather than record a phantom registration.
140
+ */
141
+ async register(label?: string): Promise<
142
+ | { readonly registered: true; readonly result: RegisterWorkspaceResult }
143
+ | { readonly registered: false; readonly refusedReason: string }
144
+ > {
145
+ try {
146
+ const result = await this.store.add(this.workingDirectory, label ? { label } : undefined);
147
+ return { registered: true, result };
148
+ } catch (error) {
149
+ if (error instanceof WorkspaceRegistrationError) {
150
+ return { registered: false, refusedReason: error.message };
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ /** Remember a subtree-scoped decline at the current working directory. Idempotent. */
157
+ async decline(): Promise<{ readonly root: string; readonly alreadyDeclined: boolean }> {
158
+ return this.store.decline(this.workingDirectory);
159
+ }
160
+
161
+ /** The normalized working directory this manager asks about. */
162
+ getWorkingRoot(): string {
163
+ return normalizeWorkspaceRoot(this.workingDirectory);
164
+ }
165
+
166
+ /**
167
+ * A one-liner registration posture for `status`/`doctor`. Read-only.
168
+ */
169
+ async describe(): Promise<string> {
170
+ const evaluation = await this.evaluate();
171
+ switch (evaluation.status) {
172
+ case 'covered':
173
+ return evaluation.viaWorktreeLink
174
+ ? `registered (via worktree link to ${evaluation.coveredBy})`
175
+ : `registered (covered by ${evaluation.coveredBy})`;
176
+ case 'declined':
177
+ return 'not registered (registration declined for this directory)';
178
+ case 'unknown':
179
+ return evaluation.broad
180
+ ? 'not registered (this root is too broad to register)'
181
+ : 'not registered (never asked)';
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * workspace-trust.ts — per-workspace trust gate.
3
+ *
4
+ * The first time GoodVibes opens a workspace (a cwd / project root it has no
5
+ * prior state for), that workspace is "restricted": only read-category tools
6
+ * run; write, execute, and delegate tools are denied until the user marks the
7
+ * workspace trusted. This is NOT a parallel permission checker — the decision
8
+ * is consulted by the real permission machinery at its final ask layer (the
9
+ * requestPermission callback wired in bootstrap-core.ts), so it composes with,
10
+ * and cannot drift from, the existing PermissionManager layer chain.
11
+ *
12
+ * Trust is persisted per-workspace in <cwd>/.goodvibes/tui/trust.json. A
13
+ * workspace that already carries GoodVibes runtime state (prior sessions,
14
+ * checkpoints, state, memory, or a project onboarding marker) is grandfathered
15
+ * as trusted on first load, so the gate only ever prompts for genuinely new
16
+ * places.
17
+ */
18
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { JsonFileStore } from '@pellux/goodvibes-sdk/platform/state';
21
+ import type { PermissionCategory } from '@pellux/goodvibes-sdk/platform/permissions';
22
+ import type { PermissionPromptRequest, PermissionPromptDecision } from '@pellux/goodvibes-sdk/platform/permissions';
23
+
24
+ type AskCallback = (request: PermissionPromptRequest) => Promise<PermissionPromptDecision>;
25
+
26
+ /**
27
+ * Wrap the permission machinery's final ask callback with the workspace trust
28
+ * gate: in an untrusted workspace, deny any non-read tool outright instead of
29
+ * asking. This runs inside PermissionManager's own layer chain (it IS the ask
30
+ * layer), so it composes with — and cannot drift from — the real machinery.
31
+ */
32
+ export function trustGatedAsk(manager: Pick<WorkspaceTrustManager, 'isCategoryAllowed'>, ask: AskCallback): AskCallback {
33
+ return (request) =>
34
+ manager.isCategoryAllowed(request.category) ? ask(request) : Promise.resolve({ approved: false });
35
+ }
36
+
37
+ export type WorkspaceTrustLevel = 'trusted' | 'restricted';
38
+
39
+ interface PersistedWorkspaceTrust {
40
+ level: WorkspaceTrustLevel;
41
+ decidedAt: string;
42
+ grandfathered?: boolean;
43
+ }
44
+
45
+ const TRUST_FILE = 'trust.json';
46
+
47
+ /**
48
+ * detectPriorWorkspaceState — true if <workingDirectory>/.goodvibes already
49
+ * holds GoodVibes RUNTIME state from a prior session. Deliberately keyed on
50
+ * generated state (sessions/checkpoints/state/memory/onboarding marker), not on
51
+ * committed scaffolding like .goodvibes/agents or GOODVIBES.md, so a checked-out
52
+ * template does not read as "previously used". MUST be sampled before any
53
+ * service initializes the workspace this session (bootstrap-core samples it
54
+ * before createRuntimeServices), or this-run scaffolding would look like prior
55
+ * use.
56
+ */
57
+ export function detectPriorWorkspaceState(workingDirectory: string): boolean {
58
+ const root = join(workingDirectory, '.goodvibes');
59
+ if (!existsSync(root)) return false;
60
+
61
+ const fileSignals = [
62
+ join(root, 'checkpoints', 'index.json'),
63
+ join(root, 'tui', 'onboarding-checked.json'),
64
+ join(root, 'goodvibes.json'),
65
+ ];
66
+ for (const file of fileSignals) {
67
+ if (existsSync(file)) return true;
68
+ }
69
+
70
+ const dirSignals = ['sessions', 'checkpoints', 'state', 'memory'];
71
+ for (const dir of dirSignals) {
72
+ const path = join(root, dir);
73
+ try {
74
+ if (existsSync(path) && statSync(path).isDirectory() && readdirSync(path).length > 0) {
75
+ return true;
76
+ }
77
+ } catch {
78
+ // Unreadable — treat as absent rather than crash the gate.
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+
84
+ export interface WorkspaceTrustPaths {
85
+ readonly projectGoodVibesRoot: string;
86
+ resolveProjectPath(...segments: string[]): string;
87
+ }
88
+
89
+ /** Read-only view of the persisted trust decision, for status/doctor reporting. */
90
+ export interface PersistedWorkspaceTrustView {
91
+ /** 'trusted' | 'restricted' when a decision exists; 'undecided' when none is persisted yet. */
92
+ readonly level: WorkspaceTrustLevel | 'undecided';
93
+ readonly grandfathered: boolean;
94
+ }
95
+
96
+ /**
97
+ * readPersistedWorkspaceTrust — read <cwd>/.goodvibes/tui/trust.json WITHOUT the
98
+ * side effects of WorkspaceTrustManager.load() (which grandfathers and persists).
99
+ * Reporting surfaces (`status`/`doctor`) must never mutate trust state, so they
100
+ * use this pure reader instead of constructing a manager.
101
+ */
102
+ export function readPersistedWorkspaceTrust(paths: WorkspaceTrustPaths): PersistedWorkspaceTrustView {
103
+ const file = paths.resolveProjectPath('tui', TRUST_FILE);
104
+ try {
105
+ if (!existsSync(file)) return { level: 'undecided', grandfathered: false };
106
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<PersistedWorkspaceTrust>;
107
+ if (parsed.level === 'trusted' || parsed.level === 'restricted') {
108
+ return { level: parsed.level, grandfathered: parsed.grandfathered ?? false };
109
+ }
110
+ } catch {
111
+ // Unreadable/corrupt — report undecided rather than crash the report.
112
+ }
113
+ return { level: 'undecided', grandfathered: false };
114
+ }
115
+
116
+ export interface WorkspaceTrustManagerOptions {
117
+ readonly shellPaths: WorkspaceTrustPaths;
118
+ /** Whether the workspace already had prior GoodVibes runtime state at startup. */
119
+ readonly hadPriorState: boolean;
120
+ }
121
+
122
+ export class WorkspaceTrustManager {
123
+ private level: WorkspaceTrustLevel | null = null; // null = undecided (new place)
124
+ private grandfathered = false;
125
+ private loaded = false;
126
+ private readonly store: JsonFileStore<PersistedWorkspaceTrust>;
127
+ private readonly hadPriorState: boolean;
128
+
129
+ constructor(options: WorkspaceTrustManagerOptions) {
130
+ this.store = new JsonFileStore<PersistedWorkspaceTrust>(
131
+ options.shellPaths.resolveProjectPath('tui', TRUST_FILE),
132
+ );
133
+ this.hadPriorState = options.hadPriorState;
134
+ }
135
+
136
+ /**
137
+ * Load the persisted decision. If none exists, grandfather a workspace that
138
+ * already had prior GoodVibes state (persisting the decision so it is never
139
+ * re-evaluated); otherwise the workspace stays undecided and the gate treats
140
+ * it as restricted until the user chooses.
141
+ */
142
+ async load(): Promise<void> {
143
+ if (this.loaded) return;
144
+ const persisted = await this.store.load().catch(() => null);
145
+ if (persisted && (persisted.level === 'trusted' || persisted.level === 'restricted')) {
146
+ this.level = persisted.level;
147
+ this.grandfathered = persisted.grandfathered ?? false;
148
+ this.loaded = true;
149
+ return;
150
+ }
151
+ if (this.hadPriorState) {
152
+ this.level = 'trusted';
153
+ this.grandfathered = true;
154
+ await this.persist();
155
+ }
156
+ this.loaded = true;
157
+ }
158
+
159
+ isDecided(): boolean {
160
+ return this.level !== null;
161
+ }
162
+
163
+ /** Undecided workspaces read as 'restricted' — the safe default before a choice. */
164
+ getLevel(): WorkspaceTrustLevel {
165
+ return this.level ?? 'restricted';
166
+ }
167
+
168
+ isTrusted(): boolean {
169
+ return this.level === 'trusted';
170
+ }
171
+
172
+ wasGrandfathered(): boolean {
173
+ return this.grandfathered;
174
+ }
175
+
176
+ async setLevel(level: WorkspaceTrustLevel): Promise<void> {
177
+ this.level = level;
178
+ this.grandfathered = false;
179
+ this.loaded = true;
180
+ await this.persist();
181
+ }
182
+
183
+ /**
184
+ * The restriction rule the permission machinery consults. Read tools always
185
+ * run (read-only exploration is the whole point of "restricted"); every other
186
+ * category (write / execute / delegate) is denied until the workspace is
187
+ * trusted.
188
+ */
189
+ isCategoryAllowed(category: PermissionCategory): boolean {
190
+ if (this.isTrusted()) return true;
191
+ return category === 'read';
192
+ }
193
+
194
+ private async persist(): Promise<void> {
195
+ if (this.level === null) return;
196
+ await this.store.save({
197
+ level: this.level,
198
+ decidedAt: new Date().toISOString(),
199
+ grandfathered: this.grandfathered,
200
+ });
201
+ }
202
+ }
@@ -10,6 +10,7 @@ import { SessionUnionCache, type SessionReadFacade } from '@pellux/goodvibes-sdk
10
10
  import type { ShellPathService } from '@/runtime/index.ts';
11
11
  import type { HostServiceStatus } from '@/runtime/index.ts';
12
12
  import type { SecretsManager } from '../config/secrets.ts';
13
+ import type { buildRelayExternalServiceMethods } from './relay-reachability-bridge.ts';
13
14
 
14
15
  export interface UiEnvironmentServices {
15
16
  readonly workingDirectory: string;
@@ -63,7 +64,7 @@ export interface UiPlatformServices {
63
64
  readonly webhookNotifier: RuntimeServices['webhookNotifier'];
64
65
  readonly focusTracker: RuntimeServices['focusTracker'];
65
66
  readonly policyRuntimeState: RuntimeServices['policyRuntimeState'];
66
- readonly externalServices?: {
67
+ readonly externalServices?: ReturnType<typeof buildRelayExternalServiceMethods> & {
67
68
  inspect(): {
68
69
  readonly daemonRunning: boolean;
69
70
  readonly daemonPortInUse?: boolean;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure logic for `/update`: version comparison, the latest-release-tag
3
+ * redirect lookup, and honest install-kind detection. No filesystem or
4
+ * process side effects here — everything takes its inputs as arguments so
5
+ * tests exercise it without touching the network or the host, matching the
6
+ * repo rule that tests never compare against the live build VERSION and
7
+ * never make live network calls.
8
+ *
9
+ * The self-update download/verify/swap orchestration that USES these lives
10
+ * in src/input/commands/update-runtime.ts; this module only decides "is
11
+ * there a newer version" and "can this install be swapped in place".
12
+ */
13
+
14
+ export function normalizeVersion(version: string): string {
15
+ return version.replace(/^v/i, '').trim();
16
+ }
17
+
18
+ /**
19
+ * Compares two version strings component-wise as dotted non-negative
20
+ * integers (a leading "v" is ignored, matching the "vX.Y.Z" release tag
21
+ * format). Returns -1/0/1 for a<b / a==b / a>b. Non-numeric or missing
22
+ * components are treated as 0, so "1.2" and "1.2.0" compare equal.
23
+ */
24
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
25
+ const partsA = normalizeVersion(a).split('.');
26
+ const partsB = normalizeVersion(b).split('.');
27
+ const length = Math.max(partsA.length, partsB.length);
28
+ for (let i = 0; i < length; i++) {
29
+ const x = Number.parseInt(partsA[i] ?? '0', 10) || 0;
30
+ const y = Number.parseInt(partsB[i] ?? '0', 10) || 0;
31
+ if (x < y) return -1;
32
+ if (x > y) return 1;
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ /**
38
+ * Extracts the release tag from a GitHub "/releases/latest" redirect
39
+ * Location header, e.g. "https://github.com/mgd34msu/goodvibes-tui/releases/tag/v1.13.2"
40
+ * -> "v1.13.2". Mirrors scripts/install.sh's own redirect-URL tag parsing
41
+ * (it takes everything after the final slash of the redirect target).
42
+ */
43
+ export function parseReleaseTagFromLocation(location: string | null | undefined): string | null {
44
+ if (!location) return null;
45
+ const segments = location.split('/').filter((segment) => segment.length > 0);
46
+ const tag = segments[segments.length - 1];
47
+ return tag && tag.length > 0 ? tag : null;
48
+ }
49
+
50
+ /** Minimal shape of the subset of the fetch API this module needs, so tests inject a stub instead of the real network. */
51
+ export interface UpdateFetchLike {
52
+ (url: string, init?: { method?: string; redirect?: 'manual' | 'follow' | 'error' }): Promise<{
53
+ readonly ok: boolean;
54
+ readonly status: number;
55
+ readonly url: string;
56
+ readonly headers: { get(name: string): string | null };
57
+ text(): Promise<string>;
58
+ arrayBuffer(): Promise<ArrayBuffer>;
59
+ }>;
60
+ }
61
+
62
+ /**
63
+ * Resolves the latest release tag the same way scripts/install.sh does:
64
+ * a HEAD request with redirects NOT followed, reading the tag out of the
65
+ * response's redirect Location header. Throws if no tag can be resolved —
66
+ * callers must not silently fall back to "already current".
67
+ */
68
+ export async function resolveLatestReleaseTag(fetchImpl: UpdateFetchLike, releasesLatestUrl: string): Promise<string> {
69
+ const response = await fetchImpl(releasesLatestUrl, { method: 'HEAD', redirect: 'manual' });
70
+ const location = response.headers.get('location');
71
+ const tag = parseReleaseTagFromLocation(location) ?? (response.url !== releasesLatestUrl ? parseReleaseTagFromLocation(response.url) : null);
72
+ if (!tag) {
73
+ throw new Error(`could not resolve the latest release tag from ${releasesLatestUrl} (no redirect Location header)`);
74
+ }
75
+ return tag;
76
+ }
77
+
78
+ /**
79
+ * How this running process was installed, detected honestly from
80
+ * process.execPath rather than assumed:
81
+ * - "binary": a standalone `bun build --compile` executable with no
82
+ * package-manager ancestry — the scripts/install.sh install path.
83
+ * Swappable in place.
84
+ * - "bun-global-package": running the vendored binary shipped inside an
85
+ * npm/bun-managed package install (execPath contains a "node_modules"
86
+ * path segment — true for both `bun add -g` and a local project
87
+ * dependency). Managed by the package manager; swapping the vendored
88
+ * file in place would fight the next `bun add -g` upgrade, so this is
89
+ * never swapped — the user re-runs their package manager instead.
90
+ * - "source": running directly via the `bun` interpreter (`bun run
91
+ * src/main.ts`), not a compiled binary at all.
92
+ */
93
+ export type InstallKind = 'binary' | 'bun-global-package' | 'source';
94
+
95
+ export function detectInstallKind(execPath: string): InstallKind {
96
+ const segments = execPath.split(/[\\/]/);
97
+ const execName = (segments[segments.length - 1] ?? '').toLowerCase();
98
+ if (execName === 'bun' || execName === 'bun.exe') {
99
+ return 'source';
100
+ }
101
+ if (segments.includes('node_modules')) {
102
+ return 'bun-global-package';
103
+ }
104
+ return 'binary';
105
+ }
106
+
107
+ /** The exact command to tell the user to run instead of a swap, for each non-binary install kind. */
108
+ export function fallbackUpdateCommand(kind: Exclude<InstallKind, 'binary'>): string {
109
+ if (kind === 'bun-global-package') {
110
+ return 'bun add -g @pellux/goodvibes-tui';
111
+ }
112
+ return 'curl -fsSL https://goodvibes.sh/install.sh | sh';
113
+ }
@@ -0,0 +1,32 @@
1
+ import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
2
+ import { withCheckpointGuardSettings } from '../config/tui-extension-settings.ts';
3
+ import type { ConfigManager } from '../config/index.ts';
4
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
5
+
6
+ /**
7
+ * createWorkspaceCheckpointing — build the per-workspace checkpoint manager and
8
+ * kick off its initialization. Extracted from services.ts so the (load-bearing)
9
+ * failure-mode notes below live with the code they describe.
10
+ */
11
+ export function createWorkspaceCheckpointing(opts: {
12
+ workspaceRoot: string;
13
+ runtimeBus: RuntimeEventBus;
14
+ configManager: ConfigManager;
15
+ }): WorkspaceCheckpointManager {
16
+ // Checkpoint root-guard from the user's `checkpoints.*` config (docs/configuration.md;
17
+ // inert until the platform SDK's checkpoint manager exposes the keys — see the reader's note).
18
+ const manager = new WorkspaceCheckpointManager(
19
+ withCheckpointGuardSettings({ workspaceRoot: opts.workspaceRoot, runtimeBus: opts.runtimeBus }, opts.configManager),
20
+ );
21
+ // Fire-and-forget: subscriptions go live immediately if init() succeeds.
22
+ // If it rejects, WorkspaceCheckpointManager caches that rejection on
23
+ // `initPromise` and never clears it, so every later call (create/list/
24
+ // diff/restore — each awaits init() first) re-throws the same error
25
+ // forever: checkpointing becomes entirely unavailable for the rest of the
26
+ // session, not "degraded to manual-only". The `.catch(() => {})` here only
27
+ // exists to prevent an unhandled rejection at startup; the checkpoint
28
+ // commands (checkpoint-runtime.ts) are what actually catch and report the
29
+ // failure to the user, on first use.
30
+ void manager.init().catch(() => {});
31
+ return manager;
32
+ }
@@ -0,0 +1,77 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-attempts-validation.ts — plan-format validation for best-of-N.
3
+ //
4
+ // The plan format re-enables the per-item `attempts` field (SDK WorkItem/
5
+ // WorkItemSpec.attempts — best-of-N). A best-of-N item MAY be non-leaf: it may
6
+ // declare its own `dependsOn` (each sibling inherits it) and other items may
7
+ // depend on it — a dependent is held by the dependency gate until the group's
8
+ // winner is picked and merged, then resolves to that winner (SDK
9
+ // WorkItemSpec.attempts doc). The TUI enforces the engine's genuine constraints
10
+ // at plan validation rather than letting a launch silently drop the field:
11
+ // • WORKTREE isolation. Best-of-N needs isolated worktrees to run and compare
12
+ // the N sibling attempts; under `shared` isolation the value is ignored.
13
+ // • STABLE id when depended upon. A dependency edge references an id, so a
14
+ // best-of-N item that others depend on must carry a stable `id`; an
15
+ // anonymous best-of-N item stays a leaf by construction (nothing can name it).
16
+ // Values above the engine's MAX_ATTEMPTS (5) are clamped by the engine; the TUI
17
+ // surfaces that as a note so the preview never overstates the fan-out.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import type { CreateWorkstreamInput } from '@pellux/goodvibes-sdk/platform/orchestration';
21
+
22
+ /** The engine's hard cap on sibling attempts per work item (SDK MAX_ATTEMPTS). */
23
+ export const MAX_ATTEMPTS = 5;
24
+
25
+ export interface AttemptsValidation {
26
+ /** True when any item requests best-of-N (attempts >= 2). */
27
+ readonly hasAttempts: boolean;
28
+ /** Hard rule breaks that must block a launch (worktree isolation + stable id when depended upon). */
29
+ readonly violations: readonly string[];
30
+ /** Non-blocking notes (e.g. an over-cap value the engine will clamp). */
31
+ readonly notes: readonly string[];
32
+ }
33
+
34
+ function itemKey(item: CreateWorkstreamInput['items'][number]): string {
35
+ return item.id ?? item.title;
36
+ }
37
+
38
+ /**
39
+ * Validate a draft spec's best-of-N usage against the worktree-isolation and
40
+ * stable-id (when depended upon) constraints. Non-leaf best-of-N is allowed.
41
+ * Pure — no I/O. `violations` are hard (block launch); `notes` are advisory.
42
+ */
43
+ export function validateAttempts(spec: CreateWorkstreamInput): AttemptsValidation {
44
+ const violations: string[] = [];
45
+ const notes: string[] = [];
46
+ const worktree = spec.isolation === 'worktree';
47
+
48
+ // Which items are depended upon (so a best-of-N item that is a dependency
49
+ // target can be checked for the stable-id requirement).
50
+ const dependedUpon = new Set<string>();
51
+ for (const item of spec.items) {
52
+ for (const dep of item.dependsOn ?? []) dependedUpon.add(dep);
53
+ }
54
+
55
+ let hasAttempts = false;
56
+ for (const item of spec.items) {
57
+ const attempts = item.attempts ?? 1;
58
+ if (attempts < 2) continue;
59
+ hasAttempts = true;
60
+ const label = item.title;
61
+
62
+ if (!worktree) {
63
+ violations.push(`"${label}" requests ${attempts} attempts, but the workstream is not worktree-isolated — best-of-N needs isolated trees. Set --isolation worktree.`);
64
+ }
65
+ // Non-leaf best-of-N is allowed. The only structural requirement: if other
66
+ // items depend on this one, it must carry a stable id for the dependency
67
+ // edge to name — an anonymous best-of-N item cannot be a dependency target.
68
+ if (dependedUpon.has(itemKey(item)) && !item.id) {
69
+ violations.push(`another item depends on best-of-N item "${label}", but it has no stable id — give the item an id so the dependency can resolve to the winner (an anonymous best-of-N item cannot be a dependency target).`);
70
+ }
71
+ if (attempts > MAX_ATTEMPTS) {
72
+ notes.push(`"${label}" requests ${attempts} attempts — the engine caps best-of-N at ${MAX_ATTEMPTS}, so ${MAX_ATTEMPTS} siblings will run.`);
73
+ }
74
+ }
75
+
76
+ return { hasAttempts, violations, notes };
77
+ }