@pellux/goodvibes-tui 1.17.0 → 1.18.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 (84) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +4596 -1732
  4. package/package.json +7 -7
  5. package/src/cli/management-commands.ts +82 -15
  6. package/src/cli/management.ts +0 -2
  7. package/src/core/consolidation-receipt.ts +41 -0
  8. package/src/core/conversation-rendering.ts +14 -5
  9. package/src/core/memory-provenance.ts +67 -0
  10. package/src/core/pairing-devices.ts +49 -0
  11. package/src/core/pairing-handoff.ts +101 -0
  12. package/src/core/pairing-offers.ts +83 -0
  13. package/src/core/pairing-origin.ts +78 -0
  14. package/src/core/pairing-tailscale-gateway.ts +56 -0
  15. package/src/core/power-chip-source.ts +91 -0
  16. package/src/core/power-status.ts +95 -0
  17. package/src/core/stable-host.ts +171 -0
  18. package/src/core/stream-event-wiring.ts +21 -0
  19. package/src/core/turn-budget-outcome.ts +60 -0
  20. package/src/core/turn-cancellation.ts +39 -0
  21. package/src/core/turn-event-wiring.ts +6 -0
  22. package/src/daemon/cli.ts +47 -56
  23. package/src/input/command-registry.ts +21 -0
  24. package/src/input/commands/acp-runtime.ts +72 -0
  25. package/src/input/commands/devices-runtime.ts +109 -0
  26. package/src/input/commands/git-runtime.ts +28 -4
  27. package/src/input/commands/graph-runtime.ts +45 -0
  28. package/src/input/commands/operator-rpc.ts +32 -11
  29. package/src/input/commands/power-runtime.ts +56 -0
  30. package/src/input/commands/qrcode-runtime.ts +14 -19
  31. package/src/input/commands/queue-runtime.ts +70 -0
  32. package/src/input/commands/workstream-attempts.ts +6 -1
  33. package/src/input/commands/workstream-runtime.ts +44 -25
  34. package/src/input/commands/worktree-runtime.ts +35 -8
  35. package/src/input/commands.ts +10 -0
  36. package/src/input/config-modal-types.ts +11 -0
  37. package/src/input/config-modal.ts +65 -2
  38. package/src/input/handler-shortcuts.ts +23 -0
  39. package/src/input/keybindings.ts +19 -1
  40. package/src/input/settings-modal-data.ts +31 -37
  41. package/src/input/settings-modal-types.ts +5 -2
  42. package/src/main.ts +42 -42
  43. package/src/panels/builtin/operations.ts +54 -1
  44. package/src/panels/builtin/shared.ts +12 -0
  45. package/src/panels/builtin-modals.ts +49 -32
  46. package/src/panels/diff-panel.ts +34 -1
  47. package/src/panels/fleet-acts.ts +446 -0
  48. package/src/panels/fleet-gateway.ts +117 -0
  49. package/src/panels/fleet-observed-render.ts +128 -0
  50. package/src/panels/fleet-panel-format.ts +120 -4
  51. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  52. package/src/panels/fleet-panel.ts +60 -44
  53. package/src/panels/fleet-read-model.ts +34 -3
  54. package/src/panels/fleet-spawn.ts +266 -0
  55. package/src/panels/fleet-stop.ts +38 -2
  56. package/src/panels/fleet-tabs.ts +2 -2
  57. package/src/panels/memory-consolidation-gateway.ts +94 -0
  58. package/src/panels/modals/devices-modal.ts +161 -0
  59. package/src/panels/modals/memory-modal.ts +149 -5
  60. package/src/panels/modals/pairing-modal.ts +198 -68
  61. package/src/panels/modals/security-modal.ts +6 -0
  62. package/src/panels/workstream-graph-render.ts +123 -0
  63. package/src/renderer/composer-fragments.ts +69 -0
  64. package/src/renderer/settings-modal-helpers.ts +9 -0
  65. package/src/renderer/settings-modal.ts +4 -2
  66. package/src/renderer/shell-surface.ts +3 -0
  67. package/src/renderer/tool-call.ts +12 -6
  68. package/src/renderer/ui-factory.ts +32 -0
  69. package/src/runtime/bootstrap-command-context.ts +3 -1
  70. package/src/runtime/bootstrap-command-parts.ts +3 -1
  71. package/src/runtime/bootstrap-core.ts +6 -6
  72. package/src/runtime/bootstrap-shell.ts +7 -0
  73. package/src/runtime/durability-services.ts +36 -1
  74. package/src/runtime/fleet-services.ts +41 -0
  75. package/src/runtime/idle-power-services.ts +85 -0
  76. package/src/runtime/index.ts +2 -0
  77. package/src/runtime/interaction-seams.ts +109 -0
  78. package/src/runtime/power-keepawake-remote.ts +75 -0
  79. package/src/runtime/process-lifecycle.ts +3 -1
  80. package/src/runtime/services.ts +38 -37
  81. package/src/runtime/ui-services.ts +3 -0
  82. package/src/shell/blocking-input.ts +21 -141
  83. package/src/shell/recovery-input-helpers.ts +53 -166
  84. package/src/version.ts +1 -1
package/src/daemon/cli.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { homedir, networkInterfaces } from 'node:os';
1
+ import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
- import { readFileSync } from 'node:fs';
4
3
  import { ConfigManager, resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
5
4
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
6
5
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
@@ -8,17 +7,20 @@ import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureState
8
7
  import { createRuntimeStore } from '../runtime/store/index.ts';
9
8
  import { createRuntimeServices } from '../runtime/services.ts';
10
9
  import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
10
+ import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
11
11
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
13
  import {
14
14
  getOrCreateCompanionToken,
15
15
  pruneStaleOperatorTokens,
16
- buildCompanionConnectionInfo,
17
- encodeConnectionPayload,
18
- formatConnectionBlock,
16
+ buildPairingHandoffLink,
17
+ describeOriginPosture,
19
18
  generateQrMatrix,
20
19
  renderQrToString,
21
20
  } from '@pellux/goodvibes-sdk/platform/pairing';
21
+ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
22
+ import { availablePairingOffers } from '../core/pairing-handoff.ts';
23
+ import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../core/pairing-offers.ts';
22
24
  import { workspaceOperatorTokenCandidates } from '../runtime/operator-token-cleanup.ts';
23
25
  import {
24
26
  scan,
@@ -51,36 +53,6 @@ type DaemonCliTokens = {
51
53
  readonly httpToken: string | undefined;
52
54
  };
53
55
 
54
- function getLocalNetworkIp(): string {
55
- try {
56
- const nets = networkInterfaces();
57
- for (const name of Object.keys(nets)) {
58
- for (const net of nets[name] ?? []) {
59
- if (net.family === 'IPv4' && !net.internal) {
60
- return net.address;
61
- }
62
- }
63
- }
64
- } catch {
65
- return 'localhost';
66
- }
67
- return 'localhost';
68
- }
69
-
70
- function readBootstrapPassword(credentialPath: string): string | undefined {
71
- try {
72
- const content = readFileSync(credentialPath, 'utf-8');
73
- for (const line of content.split('\n')) {
74
- if (line.startsWith('password=')) {
75
- return line.slice('password='.length).trim();
76
- }
77
- }
78
- } catch {
79
- // credential file may not exist yet
80
- }
81
- return undefined;
82
- }
83
-
84
56
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
85
57
  return {
86
58
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
@@ -204,6 +176,17 @@ async function main(): Promise<void> {
204
176
  getConversationTitle: () => 'goodvibes daemon',
205
177
  workingDir,
206
178
  homeDirectory,
179
+ // The standalone daemon observes externally-launched coding-agent sessions
180
+ // on the host read-only (fleet visibility + steer; never counted, never
181
+ // stopped). Daemon-side only — the interactive process reads this snapshot
182
+ // rather than double-detecting. Mirrors the SDK daemon cli.
183
+ observeExternalAgents: true,
184
+ // Opt into the REAL host power seam (Linux logind: systemd-inhibit children
185
+ // + the dbus-monitor sleep-edge watcher) so the standalone daemon holds live
186
+ // keep-awake/idle-inhibit. SDK 1.9.0's runtime-services factory defaults to
187
+ // the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
188
+ // the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
189
+ powerSeam: createHostPowerSeam(),
207
190
  });
208
191
 
209
192
  // Load persisted providers from disk so the provider registry is pre-populated
@@ -312,29 +295,37 @@ async function main(): Promise<void> {
312
295
  httpListener: config.get('danger.httpListener'),
313
296
  });
314
297
 
315
- // Print companion connection info + QR code to stdout.
316
- // Use the config-driven control plane port, not a hardcoded default.
317
- const daemonPort = config.get('controlPlane.port');
318
- const configuredDaemonHost = String(process.env.GOODVIBES_DAEMON_HOST ?? getLocalNetworkIp());
319
- const daemonHost = configuredDaemonHost === '0.0.0.0' || configuredDaemonHost === '::'
320
- ? getLocalNetworkIp()
321
- : configuredDaemonHost;
322
- const daemonUrl = `http://${daemonHost}:${daemonPort}`;
323
- const bootstrapPassword = readBootstrapPassword(userAuth.getBootstrapCredentialPath());
324
- const connectionInfo = buildCompanionConnectionInfo({
325
- daemonUrl,
326
- token: companionTokenRecord.token,
327
- password: bootstrapPassword,
328
- // Without this the SDK defaults the printed banner header to v0.0.0 —
329
- // the visible version must be this binary's real version.
330
- version: VERSION,
331
- surface: 'tui',
298
+ // Print a device-pairing QR to stdout. The QR encodes the canonical
299
+ // `#pair=<token>` deep link the web app consumes — a camera scan opens it
300
+ // already signed in. No raw JSON connection blob is printed. This is also the
301
+ // one place web.publicBaseUrl is frozen from the stable-name resolution (never
302
+ // clobbering a user-set value), so the printed origin survives a DHCP change.
303
+ // The daemon's shared companion token rides as the pairing token; a device can
304
+ // later migrate to its own per-device token from /settings → security → devices.
305
+ const webOrigin = ensurePublicBaseUrl(config);
306
+ const offers = availablePairingOffers({
307
+ relayEnabled: config.get('relay.enabled') === true,
308
+ stepUpAvailable: true,
332
309
  });
333
- const payload = encodeConnectionPayload(connectionInfo);
334
- const qrMatrix = generateQrMatrix(payload);
335
- const qrString = renderQrToString(qrMatrix);
310
+ const deepLink = buildPairingHandoffLink({ webOrigin: webOrigin.origin, token: companionTokenRecord.token, offers });
311
+ const qrString = renderQrToString(generateQrMatrix(deepLink));
312
+ // The banner renders the SAME SDK posture the pairing verb carries: the labeled
313
+ // capability list, and the one honest LAN line only when the posture holds it.
314
+ const posture = describeOriginPosture(webOrigin.origin);
315
+ const capabilities = formatPostureCapabilities(posture);
316
+ const notice = pairingPostureNotice(posture);
317
+ const bannerLines = [
318
+ `GoodVibes daemon ${VERSION} — scan to pair a device (opens the web app signed in):`,
319
+ '',
320
+ ` ${webOrigin.origin}`,
321
+ '',
322
+ ...(offers.length > 0 ? ['Offers (each declinable in the web app):', ...formatPairingOffers(offers), ''] : []),
323
+ ...(capabilities.length > 0 ? ['This device will get:', ...capabilities, ''] : []),
324
+ ...(notice ? [notice, ''] : []),
325
+ qrString,
326
+ ];
336
327
  // eslint-disable-next-line no-console
337
- console.log(formatConnectionBlock(connectionInfo, qrString));
328
+ console.log(bannerLines.join('\n'));
338
329
  }
339
330
 
340
331
  void main().catch(async (error) => {
@@ -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
+ }
@@ -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
+ }
@@ -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');