@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.
- package/CHANGELOG.md +49 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +4596 -1732
- package/package.json +7 -7
- package/src/cli/management-commands.ts +82 -15
- package/src/cli/management.ts +0 -2
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-rendering.ts +14 -5
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +21 -0
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +39 -0
- package/src/core/turn-event-wiring.ts +6 -0
- package/src/daemon/cli.ts +47 -56
- package/src/input/command-registry.ts +21 -0
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +44 -25
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -0
- package/src/input/config-modal-types.ts +11 -0
- package/src/input/config-modal.ts +65 -2
- package/src/input/handler-shortcuts.ts +23 -0
- package/src/input/keybindings.ts +19 -1
- package/src/input/settings-modal-data.ts +31 -37
- package/src/input/settings-modal-types.ts +5 -2
- package/src/main.ts +42 -42
- package/src/panels/builtin/operations.ts +54 -1
- package/src/panels/builtin/shared.ts +12 -0
- package/src/panels/builtin-modals.ts +49 -32
- package/src/panels/diff-panel.ts +34 -1
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +120 -4
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +60 -44
- package/src/panels/fleet-read-model.ts +34 -3
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-stop.ts +38 -2
- package/src/panels/fleet-tabs.ts +2 -2
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/memory-modal.ts +149 -5
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/security-modal.ts +6 -0
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/settings-modal-helpers.ts +9 -0
- package/src/renderer/settings-modal.ts +4 -2
- package/src/renderer/shell-surface.ts +3 -0
- package/src/renderer/tool-call.ts +12 -6
- package/src/renderer/ui-factory.ts +32 -0
- package/src/runtime/bootstrap-command-context.ts +3 -1
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +6 -6
- package/src/runtime/bootstrap-shell.ts +7 -0
- package/src/runtime/durability-services.ts +36 -1
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +38 -37
- package/src/runtime/ui-services.ts +3 -0
- package/src/shell/blocking-input.ts +21 -141
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/version.ts +1 -1
package/src/daemon/cli.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { homedir
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
334
|
-
const
|
|
335
|
-
|
|
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(
|
|
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
|
|
69
|
-
if (
|
|
70
|
-
|
|
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(
|
|
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
|
-
*
|
|
59
|
-
* this workspace's control-plane
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
64
|
-
const 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(
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
10
|
-
* token
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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');
|