@pellux/goodvibes-tui 1.17.0 → 1.19.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 (104) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5025 -1636
  4. package/package.json +7 -7
  5. package/src/cli/endpoints.ts +50 -4
  6. package/src/cli/help.ts +29 -2
  7. package/src/cli/management-commands.ts +99 -23
  8. package/src/cli/management.ts +0 -2
  9. package/src/cli/service-posture.ts +5 -3
  10. package/src/cli/status.ts +27 -6
  11. package/src/cli/surface-command.ts +29 -15
  12. package/src/core/consolidation-receipt.ts +41 -0
  13. package/src/core/conversation-rendering.ts +14 -5
  14. package/src/core/memory-diagnostics-gateway.ts +99 -0
  15. package/src/core/memory-provenance.ts +67 -0
  16. package/src/core/memory-status.ts +118 -0
  17. package/src/core/pairing-devices.ts +49 -0
  18. package/src/core/pairing-handoff.ts +101 -0
  19. package/src/core/pairing-offers.ts +83 -0
  20. package/src/core/pairing-origin.ts +78 -0
  21. package/src/core/pairing-tailscale-gateway.ts +56 -0
  22. package/src/core/power-chip-source.ts +91 -0
  23. package/src/core/power-status.ts +95 -0
  24. package/src/core/stable-host.ts +171 -0
  25. package/src/core/stream-event-wiring.ts +21 -0
  26. package/src/core/turn-budget-outcome.ts +60 -0
  27. package/src/core/turn-cancellation.ts +39 -0
  28. package/src/core/turn-event-wiring.ts +6 -0
  29. package/src/core/voice-provision-gateway.ts +202 -0
  30. package/src/core/voice-provision-status.ts +197 -0
  31. package/src/daemon/cli.ts +127 -60
  32. package/src/input/command-registry.ts +21 -0
  33. package/src/input/commands/acp-runtime.ts +72 -0
  34. package/src/input/commands/devices-runtime.ts +109 -0
  35. package/src/input/commands/experience-runtime.ts +25 -4
  36. package/src/input/commands/git-runtime.ts +28 -4
  37. package/src/input/commands/graph-runtime.ts +45 -0
  38. package/src/input/commands/health-runtime.ts +15 -1
  39. package/src/input/commands/operator-rpc.ts +32 -11
  40. package/src/input/commands/power-runtime.ts +56 -0
  41. package/src/input/commands/qrcode-runtime.ts +14 -19
  42. package/src/input/commands/queue-runtime.ts +70 -0
  43. package/src/input/commands/workstream-attempts.ts +6 -1
  44. package/src/input/commands/workstream-runtime.ts +44 -25
  45. package/src/input/commands/worktree-runtime.ts +35 -8
  46. package/src/input/commands.ts +10 -0
  47. package/src/input/config-modal-types.ts +11 -0
  48. package/src/input/config-modal.ts +65 -2
  49. package/src/input/handler-shortcuts.ts +23 -0
  50. package/src/input/keybindings.ts +19 -1
  51. package/src/input/settings-modal-data.ts +31 -37
  52. package/src/input/settings-modal-types.ts +5 -2
  53. package/src/input/tts-settings-actions.ts +27 -8
  54. package/src/input/voice-local-settings.ts +52 -0
  55. package/src/main.ts +42 -42
  56. package/src/panels/builtin/operations.ts +54 -1
  57. package/src/panels/builtin/shared.ts +12 -0
  58. package/src/panels/builtin-modals.ts +49 -32
  59. package/src/panels/diff-panel.ts +34 -1
  60. package/src/panels/fleet-acts.ts +446 -0
  61. package/src/panels/fleet-gateway.ts +117 -0
  62. package/src/panels/fleet-observed-render.ts +128 -0
  63. package/src/panels/fleet-panel-format.ts +120 -4
  64. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  65. package/src/panels/fleet-panel.ts +60 -44
  66. package/src/panels/fleet-read-model.ts +34 -3
  67. package/src/panels/fleet-spawn.ts +266 -0
  68. package/src/panels/fleet-stop.ts +38 -2
  69. package/src/panels/fleet-tabs.ts +2 -2
  70. package/src/panels/memory-consolidation-gateway.ts +94 -0
  71. package/src/panels/modals/devices-modal.ts +161 -0
  72. package/src/panels/modals/memory-modal.ts +149 -5
  73. package/src/panels/modals/pairing-modal.ts +198 -68
  74. package/src/panels/modals/security-modal.ts +6 -0
  75. package/src/panels/workstream-graph-render.ts +123 -0
  76. package/src/renderer/composer-fragments.ts +69 -0
  77. package/src/renderer/settings-modal-helpers.ts +9 -0
  78. package/src/renderer/settings-modal.ts +4 -2
  79. package/src/renderer/shell-surface.ts +3 -0
  80. package/src/renderer/tool-call.ts +12 -6
  81. package/src/renderer/ui-factory.ts +32 -0
  82. package/src/runtime/bootstrap-command-context.ts +3 -1
  83. package/src/runtime/bootstrap-command-parts.ts +3 -1
  84. package/src/runtime/bootstrap-core.ts +6 -6
  85. package/src/runtime/bootstrap-shell.ts +7 -0
  86. package/src/runtime/code-index-services.ts +15 -1
  87. package/src/runtime/durability-services.ts +36 -1
  88. package/src/runtime/fleet-services.ts +41 -0
  89. package/src/runtime/idle-power-services.ts +85 -0
  90. package/src/runtime/index.ts +2 -0
  91. package/src/runtime/interaction-seams.ts +109 -0
  92. package/src/runtime/knowledge-services.ts +94 -0
  93. package/src/runtime/legacy-daemon-migration.ts +38 -6
  94. package/src/runtime/legacy-daemon-reconcile.ts +439 -0
  95. package/src/runtime/memory-governance-services.ts +79 -0
  96. package/src/runtime/notification-dispatch.ts +28 -0
  97. package/src/runtime/power-keepawake-remote.ts +75 -0
  98. package/src/runtime/process-lifecycle.ts +3 -1
  99. package/src/runtime/services.ts +105 -107
  100. package/src/runtime/ui-services.ts +3 -0
  101. package/src/runtime/voice-setup-services.ts +104 -0
  102. package/src/shell/blocking-input.ts +21 -141
  103. package/src/shell/recovery-input-helpers.ts +53 -166
  104. package/src/version.ts +11 -2
@@ -84,6 +84,25 @@ export interface CommandUiActions {
84
84
  /** Arm the one-key jump to a spawned CI fix-session (the surface attaches on the jump key; the user never retypes an id). */
85
85
  armFixSessionAttach?: (fixSessionId: string) => void;
86
86
  cancelGeneration?: () => void;
87
+ /**
88
+ * Cancel JUST the currently-running tool call (the live transcript row),
89
+ * leaving the turn to continue — the local-session equivalent of the
90
+ * sessions.toolCalls.cancel wire verb over the in-process orchestrator.
91
+ * Returns true when a running call was found and cancelled.
92
+ */
93
+ cancelToolCall?: () => boolean;
94
+ /** The still-undelivered mid-turn messages, in delivery order (the /queue editable list). */
95
+ listQueuedMessages?: () => ReadonlyArray<{ id: string; queuedAt: number; text: string }>;
96
+ /** Replace a still-queued message's text; false once delivered (immutable). */
97
+ editQueuedMessage?: (id: string, text: string) => boolean;
98
+ /** Remove a still-queued message before delivery; false once delivered. */
99
+ deleteQueuedMessage?: (id: string) => boolean;
100
+ /** Toggle the drill-in of the "used N memories" provenance chip (memory.showProvenance). */
101
+ toggleMemoryProvenance?: () => void;
102
+ /** The current host sleep-ownership projection (power.status.get, flattened) — chip, held-because, lid-split. */
103
+ getPowerState?: () => import('../core/power-status.ts').PowerSurfaceState;
104
+ /** Set the daemon-held keep-awake toggle (power.keepAwake.set), acquiring/releasing the inhibitor live; returns the new state. */
105
+ setKeepAwake?: (enabled: boolean) => Promise<import('../core/power-status.ts').PowerSurfaceState>;
87
106
  /** True while an LLM turn is actively streaming. Used to give Escape
88
107
  * cancel-turn precedence over a focused panel's own escape handling. */
89
108
  isGenerating?: () => boolean;
@@ -273,6 +292,8 @@ export interface CommandWorkspaceServices
273
292
  export interface CommandPlatformConfigServices {
274
293
  readonly config: DeepReadonly<GoodVibesConfig>;
275
294
  readonly configManager: ConfigManager;
295
+ /** Per-device pairing tokens — backs the /devices command family. */
296
+ readonly pairingTokens?: import('@pellux/goodvibes-sdk/platform/pairing').PairingTokenManager;
276
297
  /** Capability gate runtime — feature states derive from domain settings keys; surfaces read live/pending state here. */
277
298
  readonly featureFlagManager?: FeatureFlagManager;
278
299
  readonly voiceProviderRegistry?: VoiceProviderRegistry;
@@ -0,0 +1,72 @@
1
+ // ---------------------------------------------------------------------------
2
+ // acp-runtime.ts — the scripted path for hosting third-party coding agents,
3
+ // over the SAME verbs the Fleet panel's spawn affordance drives (acp.agents.list
4
+ // / acp.sessions.create). `/agents list` shows what the daemon discovered
5
+ // (read-only, quiet when none); `/agents host <agentId> [dir]` spawns one as a
6
+ // long-lived daemon session that shows up as an acp-agent fleet row. A working
7
+ // directory defaults to the current one; a known workspace root can be named
8
+ // instead — but no path is ever required beyond what the operator already has.
9
+ //
10
+ // A structured spawn failure ({binary, stage, message}) is printed verbatim,
11
+ // never left as a hung row: the daemon bounds the handshake and returns a
12
+ // 'failed' record with its three honest fields.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import type { CommandRegistry } from '../command-registry.ts';
16
+ import { requireShellPaths } from './runtime-services.ts';
17
+ import { describeOperatorRpcError, getOperatorRpc } from './operator-rpc.ts';
18
+
19
+ export function registerAcpRuntimeCommands(registry: CommandRegistry): void {
20
+ registry.register({
21
+ name: 'agents',
22
+ aliases: ['acp'],
23
+ description: 'Host third-party coding agents (Claude Code, Codex, opencode) as fleet rows over ACP',
24
+ usage: 'list | host <agentId> [directory]',
25
+ argsHint: '[list|host]',
26
+ async handler(args, ctx) {
27
+ const sub = (args[0] ?? 'list').toLowerCase();
28
+ const rpc = getOperatorRpc(ctx);
29
+ if (!rpc.available) { ctx.print(`[agents] ${rpc.reason}`); return; }
30
+
31
+ if (sub === 'list') {
32
+ try {
33
+ const { agents } = await rpc.sdk.operator.invoke('acp.agents.list', {});
34
+ if (agents.length === 0) {
35
+ ctx.print('[agents] No third-party ACP agents discovered. Install Claude Code, Codex, or opencode on PATH to host one.');
36
+ return;
37
+ }
38
+ ctx.print([
39
+ 'Discovered ACP agents (host with /agents host <id> [directory]):',
40
+ ...agents.map((a) => ` ${a.id} — ${a.title} (${a.binaryPath})`),
41
+ ].join('\n'));
42
+ } catch (error) {
43
+ ctx.print(`[agents list] ${describeOperatorRpcError(error)}`);
44
+ }
45
+ return;
46
+ }
47
+
48
+ if (sub === 'host') {
49
+ const agentId = args[1];
50
+ if (!agentId) {
51
+ ctx.print('Usage: /agents host <agentId> [directory] (agentId from /agents list; directory defaults to the current one)');
52
+ return;
53
+ }
54
+ const cwd = args[2] ?? requireShellPaths(ctx).workingDirectory;
55
+ try {
56
+ const { hosted, started } = await rpc.sdk.operator.invoke('acp.sessions.create', { agentId, cwd });
57
+ if (hosted.error) {
58
+ // Structured failure, verbatim — never a hung row.
59
+ ctx.print(`[agents host] Could not host ${hosted.title || agentId}: ${hosted.error.stage} stage failed for ${hosted.error.binary} — ${hosted.error.message}`);
60
+ return;
61
+ }
62
+ ctx.print(`[agents host] Hosting ${hosted.title || agentId} in ${cwd}${started ? '' : ' (queued)'} — it appears as an acp-agent row in the fleet; steer and stop it like any agent.`);
63
+ } catch (error) {
64
+ ctx.print(`[agents host] ${describeOperatorRpcError(error)}`);
65
+ }
66
+ return;
67
+ }
68
+
69
+ ctx.print('Usage: /agents list | /agents host <agentId> [directory]');
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,109 @@
1
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
2
+ import { formatDeviceLine, resolveTokenByIdPrefix } from '../../core/pairing-devices.ts';
3
+
4
+ /**
5
+ * Register the /devices command — manage the per-device pairing tokens minted by
6
+ * the pairing surfaces. Mirrors the pairing.tokens.* gateway verbs the daemon
7
+ * serves (list/rename/revoke/migrate/revoke-shared) so the terminal has the same
8
+ * capability as the web app's device manager.
9
+ *
10
+ * /devices list paired devices (name · created · last-seen · id)
11
+ * /devices rename <id> <name> rename a device token (id or unambiguous prefix)
12
+ * /devices revoke <id> revoke a device token immediately
13
+ * /devices migrate-shared [name] mint a per-device token off the legacy shared token
14
+ * /devices revoke-shared revoke the legacy shared token (it stops authenticating)
15
+ */
16
+ export function registerDevicesRuntimeCommands(registry: CommandRegistry): void {
17
+ registry.register({
18
+ name: 'devices',
19
+ aliases: ['device'],
20
+ description: 'Manage paired-device tokens (list, rename, revoke, migrate-shared)',
21
+ usage: '[list|rename <id> <name>|revoke <id>|migrate-shared [name]|revoke-shared]',
22
+ argsHint: '[list|rename|revoke|migrate-shared|revoke-shared]',
23
+ handler(args, ctx) {
24
+ const pairingTokens = ctx.platform.pairingTokens;
25
+ if (!pairingTokens) {
26
+ ctx.print('Device management is unavailable in this runtime (no pairing token service wired).');
27
+ return;
28
+ }
29
+ const sub = (args[0] ?? 'list').toLowerCase();
30
+
31
+ if (sub === 'list') {
32
+ renderList(ctx, pairingTokens);
33
+ return;
34
+ }
35
+
36
+ if (sub === 'rename') {
37
+ const idOrPrefix = args[1];
38
+ const name = args.slice(2).join(' ').trim();
39
+ if (!idOrPrefix || !name) {
40
+ ctx.print('Usage: /devices rename <id> <new name>');
41
+ return;
42
+ }
43
+ const resolved = resolveTokenByIdPrefix(pairingTokens.list(), idOrPrefix);
44
+ if (!resolved.ok) {
45
+ ctx.print(resolved.reason === 'ambiguous' ? `Ambiguous device id "${idOrPrefix}" — use more characters.` : `No device matches "${idOrPrefix}".`);
46
+ return;
47
+ }
48
+ pairingTokens.rename(resolved.token.id, name);
49
+ ctx.print(`Renamed device to "${name}".`);
50
+ return;
51
+ }
52
+
53
+ if (sub === 'revoke') {
54
+ const idOrPrefix = args[1];
55
+ if (!idOrPrefix) {
56
+ ctx.print('Usage: /devices revoke <id>');
57
+ return;
58
+ }
59
+ const resolved = resolveTokenByIdPrefix(pairingTokens.list(), idOrPrefix);
60
+ if (!resolved.ok) {
61
+ ctx.print(resolved.reason === 'ambiguous' ? `Ambiguous device id "${idOrPrefix}" — use more characters.` : `No device matches "${idOrPrefix}".`);
62
+ return;
63
+ }
64
+ const revoked = pairingTokens.revoke(resolved.token.id);
65
+ ctx.print(revoked ? `Revoked "${resolved.token.name}" — its token stops working immediately.` : `Device "${resolved.token.name}" was already absent.`);
66
+ return;
67
+ }
68
+
69
+ if (sub === 'migrate-shared') {
70
+ const name = args.slice(1).join(' ').trim() || 'migrated device';
71
+ const minted = pairingTokens.mintForMigration({ name });
72
+ ctx.print(
73
+ `Minted a per-device token "${minted.name}" (shown once): ${minted.token}\n` +
74
+ 'Pair this device with it, then run /devices revoke-shared to retire the legacy shared token.',
75
+ );
76
+ return;
77
+ }
78
+
79
+ if (sub === 'revoke-shared') {
80
+ if (pairingTokens.isLegacyRevoked()) {
81
+ ctx.print('The legacy shared token is already revoked.');
82
+ return;
83
+ }
84
+ pairingTokens.revokeLegacyShared();
85
+ ctx.print('Revoked the legacy shared token — devices still on it must re-pair with their own token.');
86
+ return;
87
+ }
88
+
89
+ ctx.print(`Unknown /devices subcommand: ${sub}. Use: list | rename | revoke | migrate-shared | revoke-shared`);
90
+ },
91
+ });
92
+ }
93
+
94
+ function renderList(ctx: CommandContext, pairingTokens: NonNullable<CommandContext['platform']['pairingTokens']>): void {
95
+ const list = pairingTokens.list();
96
+ const lines: string[] = ['Paired devices:'];
97
+ if (list.length === 0) {
98
+ lines.push(' (none yet — pair one with /pair)');
99
+ } else {
100
+ for (const token of list) lines.push(` ${formatDeviceLine(token)}`);
101
+ }
102
+ lines.push(
103
+ '',
104
+ pairingTokens.isLegacyRevoked()
105
+ ? 'Legacy shared token: revoked.'
106
+ : 'Legacy shared token: active — run /devices migrate-shared then /devices revoke-shared to retire it.',
107
+ );
108
+ ctx.print(lines.join('\n'));
109
+ }
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import type { CommandRegistry } from '../command-registry.ts';
4
4
  import { requirePanelManager, requireShellPaths } from './runtime-services.ts';
5
+ import { createVoiceProvisionGateway, renderVoiceProvision, runVoiceSetupWithProgress, VOICE_SETUP_ANNOUNCEMENT } from '../../core/voice-provision-gateway.ts';
5
6
 
6
7
  interface VoiceBundle {
7
8
  readonly version: 1;
@@ -222,11 +223,31 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
222
223
 
223
224
  registry.register({
224
225
  name: 'voice',
225
- description: 'Review or toggle always-speak mode (same switch as /tts on|off) and package portable voice metadata',
226
- usage: '[review|enable|disable|bundle export <path>|bundle inspect <path>]',
227
- handler(args, ctx) {
226
+ description: 'Review or toggle always-speak mode, provision the managed local voice runtime (setup/status), and package portable voice metadata',
227
+ usage: '[review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]',
228
+ async handler(args, ctx) {
228
229
  const shellPaths = requireShellPaths(ctx);
229
230
  const sub = (args[0] ?? 'review').toLowerCase();
231
+ if (sub === 'status' || sub === 'setup') {
232
+ // The one-act managed install returns a final receipt (no per-phase
233
+ // streaming over the verb), so the announcement block prints first and
234
+ // the receipt/failure block follows once the (possibly long,
235
+ // multi-hundred-MB) install resolves.
236
+ const resolution = createVoiceProvisionGateway({
237
+ configManager: ctx.platform.configManager,
238
+ // Lazy: the daemon-disabled / no-base-URL refusals resolve before shell paths.
239
+ homeDirectory: () => requireShellPaths(ctx).homeDirectory,
240
+ });
241
+ if (sub === 'setup') {
242
+ // Print the up-front announcement, then drive the install with live
243
+ // per-component progress polled from voice.local.status.
244
+ if (resolution.available) ctx.print(VOICE_SETUP_ANNOUNCEMENT);
245
+ await runVoiceSetupWithProgress(resolution, { print: (block) => ctx.print(block) });
246
+ return;
247
+ }
248
+ ctx.print(await renderVoiceProvision(sub, resolution));
249
+ return;
250
+ }
230
251
  if (sub === 'review') {
231
252
  const enabled = Boolean(ctx.platform.configManager.get('ui.voiceEnabled') ?? false);
232
253
  ctx.print([
@@ -273,7 +294,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
273
294
  return;
274
295
  }
275
296
  }
276
- ctx.print('Usage: /voice [review|enable|disable|bundle export <path>|bundle inspect <path>]');
297
+ ctx.print('Usage: /voice [review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]');
277
298
  },
278
299
  });
279
300
  }
@@ -1,6 +1,6 @@
1
1
  import type { CommandRegistry } from '../command-registry.ts';
2
2
  import { GitService } from '@pellux/goodvibes-sdk/platform/git';
3
- import { requireShellPaths } from './runtime-services.ts';
3
+ import { requireShellPaths, requirePanelManager } from './runtime-services.ts';
4
4
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
5
5
 
6
6
  export function registerGitRuntimeCommands(registry: CommandRegistry): void {
@@ -64,10 +64,34 @@ export function registerGitRuntimeCommands(registry: CommandRegistry): void {
64
64
  break;
65
65
  }
66
66
  case 'diff': {
67
+ // Route the full, uncapped working-tree diff (GitService.diffStructured
68
+ // — parsed per-file/per-hunk with no size limit) into the real diff
69
+ // panel. The old path sliced the raw text at 4,000 chars and printed a
70
+ // stub; a large diff now renders complete.
67
71
  try {
68
- const diffText = await git.diff();
69
- if (!diffText.trim()) ctx.print('No unstaged changes.');
70
- else ctx.print(diffText.length > 4000 ? `${diffText.slice(0, 4000)}\n\n...(diff truncated)` : diffText);
72
+ const structured = await git.diffStructured();
73
+ if (structured.files.length === 0) {
74
+ ctx.print('No unstaged changes.');
75
+ break;
76
+ }
77
+ const { DiffPanel } = await import('../../panels/diff-panel.ts');
78
+ const pm = requirePanelManager(ctx);
79
+ let panel = pm.getAllOpen().find((p) => p.id === 'diff');
80
+ if (!panel) {
81
+ try {
82
+ panel = pm.open('diff');
83
+ } catch {
84
+ ctx.print('Could not open diff panel.');
85
+ break;
86
+ }
87
+ }
88
+ pm.activateById('diff');
89
+ if (!pm.isVisible()) pm.show();
90
+ ctx.focusPanels?.();
91
+ (panel as InstanceType<typeof DiffPanel>).loadStructuredDiff(structured);
92
+ const fileWord = structured.files.length === 1 ? 'file' : 'files';
93
+ ctx.print(`Diff panel updated: ${structured.files.length} ${fileWord}, +${structured.additions} -${structured.deletions} (complete, uncapped).`);
94
+ ctx.renderRequest();
71
95
  } catch (e) {
72
96
  ctx.print(`Git diff failed: ${summarizeError(e)}`);
73
97
  }
@@ -0,0 +1,45 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+ import { getOperatorRpc, describeOperatorRpcError } from './operator-rpc.ts';
3
+ import { renderWorkstreamGraphLines } from '../../panels/workstream-graph-render.ts';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // /graph — a workstream's task graph (fleet.graph.get) rendered legibly.
7
+ //
8
+ // The observability-layer surface: nodes, edges, per-node states (ready /
9
+ // running / blocked-with-"waiting on: X" / stalled / orphaned) and the elastic
10
+ // pool posture ("N ready, M running, at cap (fleet.maxSize=N)") — the shape of
11
+ // the dependency graph WITHOUT opening any transcript. The fix-phase chain's
12
+ // task graph is one such workstream.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function renderWidth(): number {
16
+ const cols = typeof process !== 'undefined' && process.stdout && process.stdout.columns ? process.stdout.columns : 80;
17
+ return Math.max(40, cols - 2);
18
+ }
19
+
20
+ export function registerGraphRuntimeCommands(registry: CommandRegistry): void {
21
+ registry.register({
22
+ name: 'graph',
23
+ description: "Show a workstream's task graph — nodes, edges, states, and pool posture",
24
+ usage: '<workstreamId>',
25
+ argsHint: '<workstreamId>',
26
+ async handler(args, ctx) {
27
+ const workstreamId = (args[0] ?? '').trim();
28
+ if (!workstreamId) {
29
+ ctx.print('Usage: /graph <workstreamId> — renders that workstream\'s task graph (nodes, edges, states, pool).');
30
+ return;
31
+ }
32
+ const rpc = getOperatorRpc(ctx);
33
+ if (!rpc.available) {
34
+ ctx.print(`[graph] ${rpc.reason}`);
35
+ return;
36
+ }
37
+ try {
38
+ const snapshot = await rpc.sdk.operator.invoke('fleet.graph.get', { workstreamId });
39
+ ctx.print(renderWorkstreamGraphLines(snapshot, renderWidth()).join('\n'));
40
+ } catch (error) {
41
+ ctx.print(`[graph] ${describeOperatorRpcError(error)}`);
42
+ }
43
+ },
44
+ });
45
+ }
@@ -21,6 +21,8 @@ import {
21
21
  } from './runtime-services.ts';
22
22
  import { getOperatorRpc, describeOperatorRpcError } from './operator-rpc.ts';
23
23
  import { formatQuotaSnapshotLine, renderMetricMap, telemetryScopeRefusalLine } from './health-metrics-format.ts';
24
+ import { createMemoryDiagnosticsGateway, renderMemoryDiagnostics } from '../../core/memory-diagnostics-gateway.ts';
25
+ import { requireShellPaths } from './runtime-services.ts';
24
26
 
25
27
  function renderSandboxHealthSummary(configManager: ConfigManager): string[] {
26
28
  const backend = String(configManager.get('sandbox.vmBackend') ?? 'local');
@@ -42,7 +44,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
42
44
  name: 'health',
43
45
  aliases: ['doctor'],
44
46
  description: 'Health workspace for startup posture, service readiness, sandbox posture, and provider health',
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)',
47
+ usage: '[report|review|open|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|memory|metrics|continuity|worktrees|maintenance|term|repair [domain]] — bare and report stay a cross-domain transcript report (see also /health provider for the providers modal)',
46
48
  async handler(args, ctx) {
47
49
  const sub = (args[0] ?? 'review').toLowerCase();
48
50
  const readModels = requireReadModels(ctx);
@@ -52,6 +54,18 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
52
54
  return;
53
55
  }
54
56
 
57
+ if (sub === 'memory') {
58
+ // The MemoryGovernor snapshot (ops.memory.get): tier, budget vs RSS,
59
+ // per-cache footprints, paused jobs, tripwire — read-only, honest 501
60
+ // when a daemon predates the governor verb.
61
+ const resolution = createMemoryDiagnosticsGateway({
62
+ configManager: ctx.platform.configManager,
63
+ homeDirectory: () => requireShellPaths(ctx).homeDirectory,
64
+ });
65
+ ctx.print(await renderMemoryDiagnostics(resolution));
66
+ return;
67
+ }
68
+
55
69
  if (sub === 'services') {
56
70
  const registry = requireServiceRegistry(ctx);
57
71
  const all = registry.getAll();
@@ -27,6 +27,7 @@ import { join } from 'node:path';
27
27
  import { createGoodVibesSdk } from '@pellux/goodvibes-sdk';
28
28
  import type { GoodVibesSdk } from '@pellux/goodvibes-sdk';
29
29
  import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
30
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
30
31
  import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
31
32
  import { getOrCreateCompanionToken } from '@pellux/goodvibes-sdk/platform/pairing';
32
33
  import type { CommandContext } from '../command-registry.ts';
@@ -44,8 +45,7 @@ export interface OperatorRpcAvailable {
44
45
 
45
46
  export type OperatorRpc = OperatorRpcUnavailable | OperatorRpcAvailable;
46
47
 
47
- function resolveControlPlaneBaseUrl(context: CommandContext): string | null {
48
- const configManager = context.platform.configManager;
48
+ function resolveControlPlaneBaseUrl(configManager: ConfigManager): string | null {
49
49
  const explicit = String(configManager.get('controlPlane.baseUrl') ?? '').trim();
50
50
  if (explicit) return explicit;
51
51
  const host = String(configManager.get('controlPlane.host') ?? '').trim();
@@ -55,27 +55,48 @@ function resolveControlPlaneBaseUrl(context: CommandContext): string | null {
55
55
  }
56
56
 
57
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.
58
+ * Context-free core of {@link getOperatorRpc}: resolve (or honestly refuse to
59
+ * resolve) an operator SDK client wired to this workspace's control-plane
60
+ * daemon from just the two things the resolution actually needs — the config
61
+ * manager and the home directory. Shared by the command layer (via
62
+ * getOperatorRpc) and the Fleet panel's gateway (fleet-gateway.ts) so both
63
+ * reach the same daemon over the same generic invoke path, never two divergent
64
+ * clients. Refusal reasons are surfaced verbatim so callers print them directly.
62
65
  */
63
- export function getOperatorRpc(context: CommandContext): OperatorRpc {
64
- const configManager = context.platform.configManager;
66
+ export function resolveOperatorRpc(deps: { readonly configManager: ConfigManager; readonly homeDirectory: string | (() => string) }): OperatorRpc {
67
+ const { configManager } = deps;
65
68
  if (!resolveDaemonEnabled(configManager)) {
66
69
  return { available: false, reason: 'the daemon is disabled (daemon.enabled=false) — no operator surface to reach. Enable it in /settings, then retry.' };
67
70
  }
68
- const baseUrl = resolveControlPlaneBaseUrl(context);
71
+ const baseUrl = resolveControlPlaneBaseUrl(configManager);
69
72
  if (!baseUrl) {
70
73
  return { available: false, reason: 'no control-plane base URL is configured (controlPlane.baseUrl / controlPlane.host+port) — cannot reach the operator surface.' };
71
74
  }
72
- const shellPaths = requireShellPaths(context);
73
- const daemonHomeDir = join(shellPaths.homeDirectory, '.goodvibes', 'daemon');
75
+ // Resolve the home directory only AFTER the static refusals — a caller whose
76
+ // shell paths are not wired (a disabled-daemon path) must still get the honest
77
+ // unavailable reason above, never a "shell paths not wired" throw.
78
+ const homeDirectory = typeof deps.homeDirectory === 'function' ? deps.homeDirectory() : deps.homeDirectory;
79
+ const daemonHomeDir = join(homeDirectory, '.goodvibes', 'daemon');
74
80
  const token = getOrCreateCompanionToken('tui', { daemonHomeDir }).token;
75
81
  const sdk = createGoodVibesSdk({ baseUrl, authToken: token });
76
82
  return { available: true, sdk };
77
83
  }
78
84
 
85
+ /**
86
+ * Resolve (or honestly refuse to resolve) an operator SDK client wired to
87
+ * this workspace's control-plane daemon. Reasons for refusal are surfaced
88
+ * verbatim to the caller so `/ci`, `/checkin`, and `/principals` can print
89
+ * them directly rather than inventing their own wording.
90
+ */
91
+ export function getOperatorRpc(context: CommandContext): OperatorRpc {
92
+ return resolveOperatorRpc({
93
+ configManager: context.platform.configManager,
94
+ // Lazy: only resolved once the daemon-enabled + base-URL refusals pass, so a
95
+ // disabled-daemon context with no shell paths still returns the honest reason.
96
+ homeDirectory: () => requireShellPaths(context).homeDirectory,
97
+ });
98
+ }
99
+
79
100
  /**
80
101
  * Render a thrown operator-rpc error honestly: a 404 means this daemon has
81
102
  * not wired a handler for the verb (a live local runtime that simply doesn't
@@ -0,0 +1,56 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+ import { powerStatusLines } from '../../core/power-status.ts';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // /power — host sleep ownership (the ops/status idiom surface for power.*).
6
+ //
7
+ // Shows the honest account of sleep ownership — the "sleep disabled" chip
8
+ // meaning, each "held because X" work-inhibition reason, and the lid-split note
9
+ // rendered VERBATIM when the OS could block idle-sleep but not lid-close
10
+ // suspend — and toggles the daemon-held keep-awake switch. Owner ruling: the
11
+ // always-visible chip IS the safety mechanism, so there is no timer and no
12
+ // AC-only option — keep-awake is exactly one on/off toggle.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export function registerPowerRuntimeCommands(registry: CommandRegistry): void {
16
+ registry.register({
17
+ name: 'power',
18
+ description: 'Host sleep ownership — show status and toggle keep-awake',
19
+ usage: '[status | on | off | toggle]',
20
+ argsHint: '[status|on|off|toggle]',
21
+ async handler(args, ctx) {
22
+ const sub = (args[0] ?? 'status').toLowerCase();
23
+
24
+ if (sub === 'status' || args.length === 0) {
25
+ const state = ctx.getPowerState?.();
26
+ if (!state) {
27
+ ctx.print('Power status is unavailable in this session.');
28
+ return;
29
+ }
30
+ const lines = ['Host sleep ownership:', ...powerStatusLines(state).map((l) => ` ${l}`)];
31
+ lines.push(state.keepAwake
32
+ ? 'Keep-awake is ON. Turn it off with /power off.'
33
+ : 'Keep-awake is OFF. Turn it on with /power on.');
34
+ ctx.print(lines.join('\n'));
35
+ return;
36
+ }
37
+
38
+ if (sub === 'on' || sub === 'off' || sub === 'toggle') {
39
+ if (!ctx.setKeepAwake || !ctx.getPowerState) {
40
+ ctx.print('Keep-awake cannot be changed in this session.');
41
+ return;
42
+ }
43
+ const current = ctx.getPowerState().keepAwake;
44
+ const next = sub === 'on' ? true : sub === 'off' ? false : !current;
45
+ const state = await ctx.setKeepAwake(next);
46
+ const lines = [state.keepAwake ? 'Keep-awake ON — this host will not idle-sleep.' : 'Keep-awake OFF.'];
47
+ // Surface the honest lid-split note verbatim if the SDK served one.
48
+ if (state.note) lines.push(` ${state.note}`);
49
+ ctx.print(lines.join('\n'));
50
+ return;
51
+ }
52
+
53
+ ctx.print('Usage: /power [status | on | off | toggle]');
54
+ },
55
+ });
56
+ }
@@ -1,36 +1,31 @@
1
- import { join } from 'node:path';
2
1
  import type { CommandRegistry } from '../command-registry.ts';
3
- import { openModalCommand, requireShellPaths } from './runtime-services.ts';
4
- import { regenerateCompanionToken } from '@pellux/goodvibes-sdk/platform/pairing';
2
+ import { openModalCommand } from './runtime-services.ts';
5
3
 
6
4
  /**
7
5
  * Register the /qrcode command.
8
6
  *
9
- * `/qrcode` (no args) opens the companion-app pairing modal (QR code + URL/
10
- * token/username). `/qrcode regenerate` rotates the companion pairing token:
11
- * it re-keys the shared operator-token store, so the previously-issued token is
12
- * rejected on the next auth and a fresh QR must be scanned to reconnect.
7
+ * `/qrcode` (no args, aliases /qr /pair) opens the device-pairing modal: a QR of
8
+ * the `#pair=<token>` deep link plus the offer set. Each open mints its own
9
+ * named per-device token, so `/qrcode regenerate` no longer rotates a shared
10
+ * token — it simply re-opens the modal, which mints a fresh token and QR. The
11
+ * previously-shown token stays valid until you revoke it in
12
+ * /settings → security → devices.
13
13
  */
14
14
  export function registerQrcodeRuntimeCommands(registry: CommandRegistry): void {
15
15
  registry.register({
16
16
  name: 'qrcode',
17
17
  aliases: ['qr', 'pair'],
18
- description: 'Open the companion-app pairing modal (QR code), or regenerate the pairing token',
18
+ description: 'Open the device-pairing modal (QR deep link + offers); each open mints a fresh device token',
19
19
  usage: '[regenerate]',
20
20
  argsHint: '[regenerate]',
21
21
  handler(args, ctx) {
22
22
  if (args[0]?.toLowerCase() === 'regenerate') {
23
- const shellPaths = requireShellPaths(ctx);
24
- const daemonHomeDir = join(shellPaths.homeDirectory, '.goodvibes', 'daemon');
25
- try {
26
- const rotated = regenerateCompanionToken({ daemonHomeDir });
27
- ctx.print(
28
- 'Pairing token rotated. The previous token is now rejected — ' +
29
- `re-scan the QR (new peer ${rotated.peerId.slice(0, 8)}…) to reconnect the companion app.`,
30
- );
31
- } catch (error) {
32
- ctx.print(`Could not regenerate the pairing token: ${String(error)}`);
33
- }
23
+ // Each pairing-modal open mints its own token, so "regenerate" is just a
24
+ // fresh open: the re-pull mints a new named token + QR in place. The
25
+ // prior token is not rotated out from under a live device — revoke it
26
+ // explicitly in /settings → security → devices when you no longer need it.
27
+ ctx.print('Opening a fresh pairing QR — it mints a new device token. Revoke old ones in /settings → security → devices.');
28
+ openModalCommand(ctx, 'pairing-modal');
34
29
  return;
35
30
  }
36
31
  openModalCommand(ctx, 'pairing-modal');