@pellux/goodvibes-tui 1.13.1 → 1.15.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 +104 -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 +412 -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 +109 -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,67 @@
1
+ /**
2
+ * Pure formatters for the `/health metrics` surface (daemon runtime.metrics.get
3
+ * and quota.snapshot.get). Kept separate from the command wiring so the honesty
4
+ * bars are unit-testable without a live daemon: scope refusals are named, empty
5
+ * maps say "none reported" rather than showing zeros, and an unobserved quota
6
+ * renders as an explicit "no signal yet" instead of a fabricated full quota.
7
+ */
8
+ import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
9
+
10
+ /** Cap on how many entries of a single metric map we list before summarizing the rest. */
11
+ export const METRIC_MAP_LIST_CAP = 40;
12
+
13
+ /** Render one runtime-metric map as an indented `key: value` block, honestly capped. */
14
+ export function renderMetricMap(label: string, map: Record<string, unknown> | undefined): string[] {
15
+ const entries = Object.entries(map ?? {}).sort(([a], [b]) => a.localeCompare(b));
16
+ if (entries.length === 0) return [` ${label}: none reported`];
17
+ const lines = [` ${label}: ${entries.length}`];
18
+ for (const [key, value] of entries.slice(0, METRIC_MAP_LIST_CAP)) {
19
+ const rendered = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
20
+ const trimmed = rendered.length > 120 ? `${rendered.slice(0, 117)}...` : rendered;
21
+ lines.push(` ${key}: ${trimmed}`);
22
+ }
23
+ if (entries.length > METRIC_MAP_LIST_CAP) {
24
+ lines.push(` (+${entries.length - METRIC_MAP_LIST_CAP} more)`);
25
+ }
26
+ return lines;
27
+ }
28
+
29
+ /**
30
+ * When a telemetry verb fails because the companion token lacks the required
31
+ * scope, say exactly that — never render zeros in place of missing authority.
32
+ * Returns the honest line, or null when the error is not a scope refusal.
33
+ */
34
+ export function telemetryScopeRefusalLine(error: unknown, what: string): string | null {
35
+ if (error instanceof GoodVibesSdkError && (error.status === 401 || error.status === 403)) {
36
+ return ` ${what}: unavailable — the companion token lacks the read:telemetry scope this data requires (daemon returned ${error.status}).`;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** The shape of quota.snapshot.get we render (a superset-safe subset of the SDK type). */
42
+ export interface QuotaSnapshotView {
43
+ readonly hasSignal: boolean;
44
+ readonly remaining?: number | undefined;
45
+ readonly limit?: number | undefined;
46
+ readonly resetAt?: number | undefined;
47
+ readonly activeCooldownMs?: number | undefined;
48
+ readonly recentRateLimitCount: number;
49
+ }
50
+
51
+ /**
52
+ * Render the remaining-quota line for a provider. `hasSignal:false` becomes an
53
+ * explicit "no rate-limit signal observed yet", never zeros or a full quota.
54
+ */
55
+ export function formatQuotaSnapshotLine(provider: string, quota: QuotaSnapshotView): string {
56
+ if (!quota.hasSignal) {
57
+ return ` quota (${provider}): no rate-limit signal observed yet (${quota.recentRateLimitCount} recent rate-limit event(s)).`;
58
+ }
59
+ const parts: string[] = [];
60
+ if (quota.remaining !== undefined) {
61
+ parts.push(`remaining ${quota.remaining}${quota.limit !== undefined ? `/${quota.limit}` : ''}`);
62
+ }
63
+ if (quota.resetAt !== undefined) parts.push(`resets ${new Date(quota.resetAt).toISOString()}`);
64
+ if (quota.activeCooldownMs !== undefined && quota.activeCooldownMs > 0) parts.push(`cooldown ${quota.activeCooldownMs}ms`);
65
+ parts.push(`${quota.recentRateLimitCount} recent rate-limit event(s)`);
66
+ return ` quota (${provider}): ${parts.join(', ')}.`;
67
+ }
@@ -19,6 +19,8 @@ import {
19
19
  requireSubscriptionManager,
20
20
  requireSessionMemoryStore,
21
21
  } from './runtime-services.ts';
22
+ import { getOperatorRpc, describeOperatorRpcError } from './operator-rpc.ts';
23
+ import { formatQuotaSnapshotLine, renderMetricMap, telemetryScopeRefusalLine } from './health-metrics-format.ts';
22
24
 
23
25
  function renderSandboxHealthSummary(configManager: ConfigManager): string[] {
24
26
  const backend = String(configManager.get('sandbox.vmBackend') ?? 'local');
@@ -34,12 +36,13 @@ function renderSandboxHealthSummary(configManager: ConfigManager): string[] {
34
36
  return lines;
35
37
  }
36
38
 
39
+
37
40
  export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
38
41
  registry.register({
39
42
  name: 'health',
40
43
  aliases: ['doctor'],
41
44
  description: 'Health workspace for startup posture, service readiness, sandbox posture, and provider health',
42
- usage: '[report|review|open|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|continuity|worktrees|maintenance|term|repair [domain]] — bare and report stay a cross-domain transcript report (see also /health provider for the providers modal)',
45
+ usage: '[report|review|open|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|metrics|continuity|worktrees|maintenance|term|repair [domain]] — bare and report stay a cross-domain transcript report (see also /health provider for the providers modal)',
43
46
  async handler(args, ctx) {
44
47
  const sub = (args[0] ?? 'review').toLowerCase();
45
48
  const readModels = requireReadModels(ctx);
@@ -217,6 +220,56 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
217
220
  return;
218
221
  }
219
222
 
223
+ if (sub === 'metrics' || sub === 'runtime' || sub === 'quota') {
224
+ const rpc = getOperatorRpc(ctx);
225
+ if (!rpc.available) {
226
+ ctx.print([
227
+ 'Health Review: Runtime Metrics',
228
+ ` unavailable: ${rpc.reason}`,
229
+ ' next: connect a daemon (control plane) to read live runtime metrics.',
230
+ ].join('\n'));
231
+ return;
232
+ }
233
+
234
+ const lines: string[] = ['Health Review: Runtime Metrics'];
235
+
236
+ // Process-wide runtime metrics snapshot (runtime.metrics.get). Requires
237
+ // the read:telemetry scope; a scope refusal is rendered as such, never
238
+ // as zero counters.
239
+ try {
240
+ const metrics = await rpc.sdk.operator.invoke('runtime.metrics.get', {});
241
+ lines.push(...renderMetricMap('counters', metrics.counters as Record<string, unknown>));
242
+ lines.push(...renderMetricMap('gauges', metrics.gauges as Record<string, unknown>));
243
+ lines.push(...renderMetricMap('histograms', metrics.histograms as Record<string, unknown>));
244
+ const byModel = Object.keys(metrics.toolFormat?.byModel ?? {}).length;
245
+ const byClass = Object.keys(metrics.toolFormat?.byClass ?? {}).length;
246
+ lines.push(` tool-format telemetry: ${byModel} model(s), ${byClass} class(es)`);
247
+ } catch (error) {
248
+ const scopeLine = telemetryScopeRefusalLine(error, 'runtime metrics');
249
+ lines.push(scopeLine ?? ` runtime metrics: ${describeOperatorRpcError(error)}`);
250
+ }
251
+
252
+ // Remaining rate-limit quota for the active provider (quota.snapshot.get).
253
+ // hasSignal:false is an honest "no observation yet", never a fabricated
254
+ // full quota. An explicit provider may be passed: /health metrics <provider>.
255
+ const provider = (args[1] ?? ctx.session.runtime.provider ?? '').trim();
256
+ if (provider) {
257
+ try {
258
+ const quota = await rpc.sdk.operator.invoke('quota.snapshot.get', { provider });
259
+ lines.push(formatQuotaSnapshotLine(provider, quota));
260
+ } catch (error) {
261
+ const scopeLine = telemetryScopeRefusalLine(error, `quota (${provider})`);
262
+ lines.push(scopeLine ?? ` quota (${provider}): ${describeOperatorRpcError(error)}`);
263
+ }
264
+ } else {
265
+ lines.push(' quota: no active provider to query (pass one: /health metrics <provider>).');
266
+ }
267
+
268
+ lines.push(' next: /health accounts');
269
+ ctx.print(lines.join('\n'));
270
+ return;
271
+ }
272
+
220
273
  if (sub === 'continuity') {
221
274
  const continuity = readModels.continuity.getSnapshot();
222
275
  const returnMode = String(ctx.platform.configManager.get('behavior.returnContextMode') ?? 'off');
@@ -455,6 +508,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
455
508
  ' /health auth',
456
509
  ' /health settings',
457
510
  ' /health intelligence',
511
+ ' /health metrics',
458
512
  ' /health remote',
459
513
  ' /health maintenance',
460
514
  ' /health worktrees',
@@ -19,6 +19,7 @@ import { handleRecallExport, handleRecallHandoffExport, handleRecallHandoffImpor
19
19
  import { handleRecallGet, handleRecallLink, handleRecallList, handleRecallRemove, handleRecallSearch, handleRecallVector } from './recall-query.ts';
20
20
  import { handleRecallExplain, handleRecallInjections, handleRecallPromote, handleRecallQueue, handleRecallReview } from './recall-review.ts';
21
21
  import { VALID_CLASSES, VALID_REVIEW_STATES, VALID_SCOPES } from './recall-shared.ts';
22
+ import { handleRecallFilesApply, handleRecallFilesReview, handleRecallFilesSync } from './recall-files.ts';
22
23
 
23
24
  // ── Top-level command ─────────────────────────────────────────────────────────
24
25
 
@@ -57,6 +58,9 @@ function printRecallUsage(context: CommandContext): void {
57
58
  ' review <id> <state> [--confidence <n>] [--by <name>] [--reason <text>]',
58
59
  ' stale <id> [reason...] — Mark a record stale with an operator reason',
59
60
  ' contradict <id> [reason...] — Mark a record contradicted with an operator reason',
61
+ ' files sync [--dir <path>] — Project standing (project/team) records to git-backed markdown files',
62
+ ' files review [--dir <path>] — Diff the projection directory against the store; prints proposals, applies nothing',
63
+ ' files apply <id> [<id> ...] | --all [--dir <path>] — Apply only the named proposals from the last review, through the store\'s own update/delete',
60
64
  ' explain <task...> [--scope <path> ...] — Show the knowledge records that would be injected for a task',
61
65
  ' injections [agentId] — Show per-turn passive knowledge injection records; no id shows the main session, an id shows that spawned agent',
62
66
  ' promote <id> <scope> — Promote a memory record into session|project|team scope',
@@ -111,6 +115,25 @@ export const recallCommand: SlashCommand = {
111
115
  await handleRecallCapture(rest, context);
112
116
  break;
113
117
 
118
+ case 'files': {
119
+ const [filesSub, ...filesRest] = rest;
120
+ switch (filesSub) {
121
+ case 'sync':
122
+ await handleRecallFilesSync(filesRest, context);
123
+ break;
124
+ case 'review':
125
+ await handleRecallFilesReview(filesRest, context);
126
+ break;
127
+ case 'apply':
128
+ await handleRecallFilesApply(filesRest, context);
129
+ break;
130
+ default:
131
+ context.print('[recall] Usage: /recall files <sync|review|apply> ...');
132
+ break;
133
+ }
134
+ break;
135
+ }
136
+
114
137
  case 'get':
115
138
  case 'show':
116
139
  await handleRecallGet(rest, context);
@@ -0,0 +1,95 @@
1
+ /**
2
+ * operator-rpc.ts
3
+ *
4
+ * Generic operator-verb dispatch for gateway methods that do not (yet) have a
5
+ * named facade on the in-process `OperatorClient` (`ctx.clients.operator`,
6
+ * always DirectTransport-backed in this TUI — see bootstrap-shell.ts). The
7
+ * initiative cluster (`ci.*`, `checkin.*`, `principals.*`,
8
+ * `channels.profiles.*`) ships in the operator contract but has not been
9
+ * promoted to that narrow facade, so commands reach it the same way any
10
+ * external operator client would: a real HTTP round-trip to the currently
11
+ * configured control-plane daemon, using `createGoodVibesSdk`'s generic
12
+ * `sdk.operator.invoke(methodId, input)` (typed against the installed
13
+ * contract — see `@pellux/goodvibes-contracts`).
14
+ *
15
+ * This is deliberately a fresh, lightweight connection per call (no pooling,
16
+ * no persistent socket) — these are low-frequency, operator-initiated admin
17
+ * calls, not hot-path session traffic. `getOperatorRpc` never throws: it
18
+ * returns `{ available: false, reason }` up front for every case the caller
19
+ * can decide about statically (daemon disabled, no reachable base URL), so
20
+ * commands can print an honest "not available" line instead of guessing.
21
+ * Once a call is made, a non-2xx response (including the 404 a not-yet-wired
22
+ * verb returns) surfaces to the caller as a thrown `GoodVibesSdkError` —
23
+ * callers should catch and render `describeOperatorRpcError` rather than
24
+ * pretend the request succeeded.
25
+ */
26
+ import { join } from 'node:path';
27
+ import { createGoodVibesSdk } from '@pellux/goodvibes-sdk';
28
+ import type { GoodVibesSdk } from '@pellux/goodvibes-sdk';
29
+ import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
30
+ import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
31
+ import { getOrCreateCompanionToken } from '@pellux/goodvibes-sdk/platform/pairing';
32
+ import type { CommandContext } from '../command-registry.ts';
33
+ import { requireShellPaths } from './runtime-services.ts';
34
+
35
+ export interface OperatorRpcUnavailable {
36
+ readonly available: false;
37
+ readonly reason: string;
38
+ }
39
+
40
+ export interface OperatorRpcAvailable {
41
+ readonly available: true;
42
+ readonly sdk: GoodVibesSdk;
43
+ }
44
+
45
+ export type OperatorRpc = OperatorRpcUnavailable | OperatorRpcAvailable;
46
+
47
+ function resolveControlPlaneBaseUrl(context: CommandContext): string | null {
48
+ const configManager = context.platform.configManager;
49
+ const explicit = String(configManager.get('controlPlane.baseUrl') ?? '').trim();
50
+ if (explicit) return explicit;
51
+ const host = String(configManager.get('controlPlane.host') ?? '').trim();
52
+ const port = configManager.get('controlPlane.port');
53
+ if (host && typeof port === 'number' && port > 0) return `http://${host}:${port}`;
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Resolve (or honestly refuse to resolve) an operator SDK client wired to
59
+ * this workspace's control-plane daemon. Reasons for refusal are surfaced
60
+ * verbatim to the caller so `/ci`, `/checkin`, and `/principals` can print
61
+ * them directly rather than inventing their own wording.
62
+ */
63
+ export function getOperatorRpc(context: CommandContext): OperatorRpc {
64
+ const configManager = context.platform.configManager;
65
+ if (!resolveDaemonEnabled(configManager)) {
66
+ return { available: false, reason: 'the daemon is disabled (daemon.enabled=false) — no operator surface to reach. Enable it in /settings, then retry.' };
67
+ }
68
+ const baseUrl = resolveControlPlaneBaseUrl(context);
69
+ if (!baseUrl) {
70
+ return { available: false, reason: 'no control-plane base URL is configured (controlPlane.baseUrl / controlPlane.host+port) — cannot reach the operator surface.' };
71
+ }
72
+ const shellPaths = requireShellPaths(context);
73
+ const daemonHomeDir = join(shellPaths.homeDirectory, '.goodvibes', 'daemon');
74
+ const token = getOrCreateCompanionToken('tui', { daemonHomeDir }).token;
75
+ const sdk = createGoodVibesSdk({ baseUrl, authToken: token });
76
+ return { available: true, sdk };
77
+ }
78
+
79
+ /**
80
+ * Render a thrown operator-rpc error honestly: a 404 means this daemon has
81
+ * not wired a handler for the verb (a live local runtime that simply doesn't
82
+ * implement it yet), never "no result" or a fabricated empty state.
83
+ */
84
+ export function describeOperatorRpcError(error: unknown): string {
85
+ if (error instanceof GoodVibesSdkError) {
86
+ if (error.status === 404) {
87
+ return 'the connected daemon returned 404 — this operator verb is not wired up on that daemon yet.';
88
+ }
89
+ if (error.status === 401 || error.status === 403) {
90
+ return `the connected daemon rejected the request (${error.status}): ${error.message}`;
91
+ }
92
+ return `operator request failed${error.status ? ` (${error.status})` : ''}: ${error.message}`;
93
+ }
94
+ return error instanceof Error ? error.message : String(error);
95
+ }
@@ -0,0 +1,22 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+
3
+ /**
4
+ * /palette — open the fuzzy command palette, a searchable picker over every
5
+ * registered slash command. The palette itself is generated live from the
6
+ * registry (see openCommandPalette in shell/ui-openers.ts); this command is
7
+ * just the discoverable front door, mirroring the Ctrl+K chord.
8
+ */
9
+ export function registerPaletteRuntimeCommands(registry: CommandRegistry): void {
10
+ registry.register({
11
+ name: 'palette',
12
+ aliases: ['k'],
13
+ description: 'Open the command palette to search and run any slash command',
14
+ handler(_args, ctx) {
15
+ if (!ctx.openCommandPalette) {
16
+ ctx.print('The command palette is unavailable in this context.');
17
+ return;
18
+ }
19
+ ctx.openCommandPalette();
20
+ },
21
+ });
22
+ }
@@ -0,0 +1,264 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import type { ConfigManager, ConfigKey } from '../../config/index.ts';
3
+ import { CONFIG_SCHEMA } from '../../config/index.ts';
4
+ import { SHIPPED_CREDENTIAL_READ_RULES } from '@pellux/goodvibes-sdk/platform/permissions';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // /permissions provenance panel.
8
+ //
9
+ // Shows every permission-relevant setting in effect AND where each value came
10
+ // from. Provenance is taken from the platform's own ConfigManager.
11
+ // describeConfigKeySource (the recorded on-disk tier: shared / project / global
12
+ // / default) — never a guess. Because CLI --config overrides mutate the config
13
+ // in memory WITHOUT touching any file, we additionally compare the live value
14
+ // against the recorded on-disk / built-in-default value: when they diverge, the
15
+ // value was changed at runtime and its true origin is NOT recorded on disk, so
16
+ // the line is labelled exactly that rather than mis-attributed to a file.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** A permission-relevant setting, its live value, and where that value came from. */
20
+ export interface PermissionProvenanceRow {
21
+ readonly key: string;
22
+ readonly label: string;
23
+ /** The effective (live) value the running session uses. */
24
+ readonly value: unknown;
25
+ /** Human origin: a config file + path, "built-in default", or an "origin not recorded" label. */
26
+ readonly origin: string;
27
+ readonly originPath: string | null;
28
+ /** False when the origin is genuinely not recorded anywhere (runtime override, unreadable file, unknown key). */
29
+ readonly recorded: boolean;
30
+ /** True when the live value diverges from the recorded on-disk / default value (an in-memory runtime override). */
31
+ readonly overridden: boolean;
32
+ /** The recorded on-disk / default value, shown alongside the effective value when overridden. */
33
+ readonly recordedValue?: unknown;
34
+ readonly note?: string;
35
+ }
36
+
37
+ /** A shipped (SDK-managed, code-embedded) policy rule — not driven by any config key, so it has no ConfigKey-based tier. */
38
+ export interface ShippedPolicyRuleRow {
39
+ readonly id: string;
40
+ readonly effect: 'allow' | 'deny';
41
+ readonly description: string;
42
+ readonly toolPattern: string;
43
+ readonly pathPatterns: readonly string[];
44
+ }
45
+
46
+ export interface PermissionProvenance {
47
+ readonly sessionMode: string;
48
+ readonly sessionModeLabel: string;
49
+ readonly rows: readonly PermissionProvenanceRow[];
50
+ /**
51
+ * Shipped managed policy rules baked into the SDK's PermissionManager
52
+ * (currently: default-deny reads of well-known credential stores). These
53
+ * are NOT config-key-driven — there is no on/off setting and no file they
54
+ * could be recorded in — so they cannot use the shared/project/global/
55
+ * default tier model above. Listed separately with an honest "shipped
56
+ * default" origin rather than force-fit into a ConfigKey row.
57
+ */
58
+ readonly shippedRules: readonly ShippedPolicyRuleRow[];
59
+ }
60
+
61
+ /** The permission-relevant settings, in the order the panel lists them. */
62
+ const PERMISSION_KEYS: readonly { readonly key: string; readonly label: string; readonly note?: string }[] = [
63
+ { key: 'permissions.mode', label: 'Permission mode', note: 'also the current session mode' },
64
+ { key: 'permissions.backgroundAgents', label: 'Background-agent mode' },
65
+ { key: 'behavior.autoApprove', label: 'Auto-approve everything' },
66
+ { key: 'featureFlags.permissions-policy-engine', label: 'Runtime policy engine (flag)' },
67
+ { key: 'permissions.tools.read', label: 'Tool rule: read' },
68
+ { key: 'permissions.tools.write', label: 'Tool rule: write' },
69
+ { key: 'permissions.tools.edit', label: 'Tool rule: edit' },
70
+ { key: 'permissions.tools.exec', label: 'Tool rule: exec' },
71
+ { key: 'permissions.tools.find', label: 'Tool rule: find' },
72
+ { key: 'permissions.tools.fetch', label: 'Tool rule: fetch' },
73
+ { key: 'permissions.tools.analyze', label: 'Tool rule: analyze' },
74
+ { key: 'permissions.tools.inspect', label: 'Tool rule: inspect' },
75
+ { key: 'permissions.tools.agent', label: 'Tool rule: agent' },
76
+ { key: 'permissions.tools.state', label: 'Tool rule: state' },
77
+ { key: 'permissions.tools.workflow', label: 'Tool rule: workflow' },
78
+ { key: 'permissions.tools.registry', label: 'Tool rule: registry' },
79
+ { key: 'permissions.tools.delegate', label: 'Tool rule: delegate' },
80
+ { key: 'permissions.tools.mcp', label: 'Tool rule: mcp' },
81
+ ];
82
+
83
+ function permissionModeLabel(mode: unknown): string {
84
+ if (mode === 'prompt') return 'Ask before powerful actions';
85
+ if (mode === 'allow-all') return 'Allow everything';
86
+ if (mode === 'plan') return 'Plan only (read-only)';
87
+ if (mode === 'accept-edits') return 'Auto-accept file edits';
88
+ if (mode === 'custom') return 'Custom rules';
89
+ return String(mode ?? 'unknown');
90
+ }
91
+
92
+ /** Read a dotted key out of a parsed JSON object; segments may themselves contain hyphens. */
93
+ function readDotPath(obj: unknown, key: string): { present: boolean; value: unknown } {
94
+ let cursor: unknown = obj;
95
+ for (const part of key.split('.')) {
96
+ if (cursor == null || typeof cursor !== 'object' || !(part in (cursor as object))) {
97
+ return { present: false, value: undefined };
98
+ }
99
+ cursor = (cursor as Record<string, unknown>)[part];
100
+ }
101
+ return { present: true, value: cursor };
102
+ }
103
+
104
+ /** The value a config key resolves to from a single on-disk file, or null when the file is unreadable. */
105
+ function fileValue(path: string, key: string): { present: boolean; value: unknown } | null {
106
+ try {
107
+ return readDotPath(JSON.parse(readFileSync(path, 'utf-8')), key);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function schemaDefault(key: string): { known: boolean; value: unknown } {
114
+ const setting = CONFIG_SCHEMA.find((s) => s.key === key);
115
+ return setting ? { known: true, value: setting.default } : { known: false, value: undefined };
116
+ }
117
+
118
+ function sameValue(a: unknown, b: unknown): boolean {
119
+ return JSON.stringify(a) === JSON.stringify(b);
120
+ }
121
+
122
+ type ConfigProvenanceManager = Pick<ConfigManager, 'get' | 'describeConfigKeySource' | 'getConfigPath' | 'getProjectConfigPath'>;
123
+
124
+ function buildRow(
125
+ configManager: ConfigProvenanceManager,
126
+ entry: { key: string; label: string; note?: string },
127
+ ): PermissionProvenanceRow {
128
+ const { key, label, note } = entry;
129
+ let live: unknown;
130
+ try {
131
+ live = configManager.get(key as ConfigKey);
132
+ } catch {
133
+ live = undefined;
134
+ }
135
+
136
+ let src: ReturnType<ConfigManager['describeConfigKeySource']> | null = null;
137
+ try {
138
+ src = configManager.describeConfigKeySource(key as ConfigKey);
139
+ } catch {
140
+ src = null;
141
+ }
142
+
143
+ const base = { key, label, value: live, ...(note ? { note } : {}) };
144
+
145
+ // No recorded tier for this key (e.g. a feature-flag key outside the typed schema).
146
+ if (!src) {
147
+ return { ...base, origin: 'origin not recorded (key is outside the config schema)', originPath: null, recorded: false, overridden: false };
148
+ }
149
+
150
+ const tier = src.tier;
151
+ const globalPath = configManager.getConfigPath();
152
+ const projectPath = configManager.getProjectConfigPath();
153
+ const originPath = tier === 'shared' ? src.sharedTierPath
154
+ : tier === 'project' ? (projectPath ?? null)
155
+ : tier === 'global' ? globalPath
156
+ : null;
157
+
158
+ // The value the recorded layer actually holds on disk (or the built-in default).
159
+ let recordedValue: unknown;
160
+ let recordedSource: string;
161
+ if (tier === 'default') {
162
+ const def = schemaDefault(key);
163
+ recordedValue = def.value;
164
+ recordedSource = def.known ? 'built-in default' : 'built-in default (not in schema)';
165
+ } else if (originPath) {
166
+ const fv = fileValue(originPath, key);
167
+ if (!fv || !fv.present) {
168
+ // The tier says this file carries the key, but we could not read it back —
169
+ // do not guess; report the origin as unrecorded.
170
+ return { ...base, origin: `origin not recorded (${tier} config file could not be read back)`, originPath, recorded: false, overridden: false };
171
+ }
172
+ recordedValue = fv.value;
173
+ recordedSource = `${tier} config file`;
174
+ } else {
175
+ return { ...base, origin: `origin not recorded (no path for ${tier} tier)`, originPath: null, recorded: false, overridden: false };
176
+ }
177
+
178
+ const overridden = !sameValue(live, recordedValue);
179
+ if (overridden) {
180
+ // A CLI --config override (or any in-memory set that did not persist) sits on
181
+ // top of the recorded value. There is no on-disk record of where it came from.
182
+ return {
183
+ ...base,
184
+ origin: 'runtime override (in-memory; origin not recorded on disk)',
185
+ originPath,
186
+ recorded: false,
187
+ overridden: true,
188
+ recordedValue,
189
+ };
190
+ }
191
+
192
+ return { ...base, origin: recordedSource, originPath, recorded: true, overridden: false, recordedValue };
193
+ }
194
+
195
+ function buildShippedRuleRows(): ShippedPolicyRuleRow[] {
196
+ return SHIPPED_CREDENTIAL_READ_RULES.map((rule) => ({
197
+ id: rule.id,
198
+ effect: rule.effect,
199
+ description: rule.description ?? '(no description)',
200
+ toolPattern: Array.isArray(rule.toolPattern) ? rule.toolPattern.join(', ') : rule.toolPattern,
201
+ pathPatterns: 'pathPatterns' in rule ? rule.pathPatterns : [],
202
+ }));
203
+ }
204
+
205
+ export function buildPermissionProvenance(configManager: ConfigProvenanceManager): PermissionProvenance {
206
+ const mode = configManager.get('permissions.mode' as ConfigKey);
207
+ return {
208
+ sessionMode: String(mode ?? 'prompt'),
209
+ sessionModeLabel: permissionModeLabel(mode),
210
+ rows: PERMISSION_KEYS.map((entry) => buildRow(configManager, entry)),
211
+ shippedRules: buildShippedRuleRows(),
212
+ };
213
+ }
214
+
215
+ function displayValue(value: unknown): string {
216
+ if (value === undefined) return '(unset)';
217
+ if (typeof value === 'string') return value.length === 0 ? '(empty)' : value;
218
+ return JSON.stringify(value);
219
+ }
220
+
221
+ /** Render the provenance panel as plain lines for the command surface. */
222
+ export function renderPermissionProvenance(provenance: PermissionProvenance): string {
223
+ const lines: string[] = [
224
+ 'Permissions — settings in effect and where each came from',
225
+ ` session mode: ${provenance.sessionMode} (${provenance.sessionModeLabel})`,
226
+ '',
227
+ 'Every line is traceable to its recorded origin. A value changed at runtime',
228
+ '(e.g. via --config) is not recorded on disk and is labelled as such.',
229
+ '',
230
+ ];
231
+ const keyWidth = Math.max(...provenance.rows.map((r) => r.label.length));
232
+ for (const row of provenance.rows) {
233
+ const flag = row.overridden ? ' *' : row.recorded ? '' : ' ?';
234
+ const valueText = displayValue(row.value);
235
+ lines.push(` ${row.label.padEnd(keyWidth)} : ${valueText}${flag}`);
236
+ if (row.overridden) {
237
+ lines.push(` ${' '.repeat(keyWidth)} origin: ${row.origin}`);
238
+ lines.push(` ${' '.repeat(keyWidth)} on disk: ${displayValue(row.recordedValue)} (${row.originPath ?? 'no file'})`);
239
+ } else {
240
+ const pathText = row.originPath ? ` (${row.originPath})` : '';
241
+ lines.push(` ${' '.repeat(keyWidth)} origin: ${row.origin}${pathText}`);
242
+ }
243
+ if (row.note) lines.push(` ${' '.repeat(keyWidth)} note: ${row.note}`);
244
+ }
245
+ lines.push('');
246
+ lines.push(' Legend: * = runtime override (not recorded on disk) ? = origin not recorded');
247
+ lines.push(' Tiers: shared / project / global config file, or the built-in default.');
248
+
249
+ if (provenance.shippedRules.length > 0) {
250
+ lines.push('');
251
+ lines.push('Shipped policy rules — baked into the SDK, not driven by any config key');
252
+ lines.push('(origin: shipped default; a user allow-rule always wins over these):');
253
+ for (const rule of provenance.shippedRules) {
254
+ lines.push(` ${rule.id} [${rule.effect}] tools: ${rule.toolPattern}`);
255
+ lines.push(` ${rule.description}`);
256
+ lines.push(` origin: shipped default (SDK-managed policy rule)`);
257
+ if (rule.pathPatterns.length > 0) {
258
+ lines.push(` paths: ${rule.pathPatterns.join(', ')}`);
259
+ }
260
+ }
261
+ }
262
+
263
+ return lines.join('\n');
264
+ }
@@ -0,0 +1,21 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+ import { buildPermissionProvenance, renderPermissionProvenance } from './permissions-provenance.ts';
3
+
4
+ /**
5
+ * `/permissions` — the permission provenance panel. Prints every
6
+ * permission-relevant setting in effect and where each value came from
7
+ * (default / config file / runtime override / session mode), plus the current
8
+ * session mode. Read-only: it changes no config. See permissions-provenance.ts
9
+ * for how origin is resolved from the platform's own ConfigManager.
10
+ */
11
+ export function registerPermissionsRuntimeCommands(registry: CommandRegistry): void {
12
+ registry.register({
13
+ name: 'permissions',
14
+ aliases: ['perms'],
15
+ description: 'Show permission settings in effect and where each value came from (provenance)',
16
+ handler(_args, ctx) {
17
+ const provenance = buildPermissionProvenance(ctx.platform.configManager);
18
+ ctx.print(renderPermissionProvenance(provenance));
19
+ },
20
+ });
21
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  } from '@pellux/goodvibes-sdk/platform/knowledge';
7
7
  import type { CommandRegistry } from '../command-registry.ts';
8
8
  import { openModalCommand, requirePlanManager, requireSessionLineageTracker } from './runtime-services.ts';
9
+ import { togglePlanMode, permissionModeLabel, type PermissionModeValue } from '../../core/permission-mode.ts';
9
10
 
10
11
  /**
11
12
  * Single-token verbs that look like a `/plan` subcommand but are not real ones.
@@ -60,7 +61,8 @@ function formatNextQuestion(question: ProjectPlanningQuestion | undefined): stri
60
61
 
61
62
  export function registerPlanningRuntimeCommands(registry: CommandRegistry): void {
62
63
  registry.register({
63
- name: 'plan',
64
+ name: 'project-plan',
65
+ aliases: ['planning'],
64
66
  description: 'Inspect or seed TUI-owned project planning state',
65
67
  usage: '[panel | approve | dismiss | answer <n> <text> | list | show <id> | mode | explain | override <strategy> | status | clear | <planning goal>]',
66
68
  argsHint: '[panel|approve|status|<goal>]',
@@ -314,4 +316,29 @@ export function registerPlanningRuntimeCommands(registry: CommandRegistry): void
314
316
  );
315
317
  },
316
318
  });
319
+
320
+ // /plan now enters/toggles the SESSION PERMISSION plan mode (read-only
321
+ // planning posture) — distinct from the project-planning manager above, which
322
+ // moved to /project-plan (alias /planning). The change goes through the SDK
323
+ // config surface (permissions.mode), the same value the PermissionManager
324
+ // reads and Shift+Tab cycles, so plan mode is one concept across surfaces.
325
+ registry.register({
326
+ name: 'plan',
327
+ description: 'Enter or exit plan mode — a read-only planning posture where writes, commands, and network calls are blocked',
328
+ usage: '[on | off | toggle]',
329
+ argsHint: '[on|off]',
330
+ handler(args, ctx) {
331
+ const configManager = ctx.platform.configManager;
332
+ const current = configManager.get('permissions.mode') as PermissionModeValue | undefined;
333
+ const sub = (args[0] ?? 'toggle').toLowerCase();
334
+ const next: PermissionModeValue =
335
+ sub === 'on' || sub === 'enter' ? 'plan'
336
+ : sub === 'off' || sub === 'exit' ? 'prompt'
337
+ : togglePlanMode(current);
338
+ configManager.set('permissions.mode', next);
339
+ ctx.print(next === 'plan'
340
+ ? '[Permissions] Plan mode ON — read-only: writes, commands, and network calls are blocked until you exit (/plan off or Shift+Tab).'
341
+ : `[Permissions] Plan mode OFF — mode: ${permissionModeLabel(next)}.`);
342
+ },
343
+ });
317
344
  }