@pellux/goodvibes-tui 1.23.0 → 1.24.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 +14 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -3
- package/src/cli/bundle-command.ts +4 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/management-utils.ts +4 -0
- package/src/cluster/commands.ts +382 -0
- package/src/cluster/remote-daemon-target.ts +225 -0
- package/src/cluster/render.ts +268 -0
- package/src/core/conversation-line-cache.ts +11 -6
- package/src/core/conversation-render-context.ts +122 -0
- package/src/core/conversation-rendering.ts +86 -89
- package/src/daemon/cli.ts +71 -2
- package/src/daemon/handlers/inbox/index.ts +63 -3
- package/src/daemon/handlers/inbox/poller.ts +54 -8
- package/src/input/command-args-hint.ts +2 -0
- package/src/input/commands/cluster-runtime.ts +53 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-data.ts +6 -0
- package/src/main.ts +3 -0
- package/src/renderer/conversation-surface.ts +23 -6
- package/src/renderer/conversation-tree.ts +142 -93
- package/src/renderer/tool-call.ts +13 -12
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/daemon-handler-composition.ts +80 -0
- package/src/runtime/disposal-wiring.ts +66 -0
- package/src/runtime/durability-services.ts +7 -3
- package/src/runtime/embedded-service-factories.ts +19 -1
- package/src/runtime/process-lifecycle.ts +9 -1
- package/src/runtime/remote-execution-composition.ts +62 -0
- package/src/runtime/services.ts +48 -48
- package/src/verification/verification-ledger.ts +38 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-daemon-target.ts — how a subcommand reaches a daemon.
|
|
3
|
+
*
|
|
4
|
+
* Lives under src/cluster/ rather than src/daemon/ so the TUI can use it too:
|
|
5
|
+
* `/cluster` and the `cluster` subcommands must reach the same daemon the same
|
|
6
|
+
* way, and the architecture rules (rightly) forbid the input layer from
|
|
7
|
+
* importing a CLI entrypoint.
|
|
8
|
+
*
|
|
9
|
+
* ── THE CONVENTION ────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* This is the first daemon subcommand that talks to a RUNNING daemon rather
|
|
12
|
+
* than to systemd, so it establishes the pattern every later one should follow.
|
|
13
|
+
* If you are adding `goodvibes-daemon <something>` that needs the daemon
|
|
14
|
+
* itself, use this module rather than inventing a second way.
|
|
15
|
+
*
|
|
16
|
+
* --host <name> default: the control plane's configured host, which for a
|
|
17
|
+
* default install is 127.0.0.1
|
|
18
|
+
* --port <n> default: the control plane's configured port
|
|
19
|
+
* --token <t> default: this machine's operator token, read from
|
|
20
|
+
* <daemon home>/operator-tokens.json
|
|
21
|
+
*
|
|
22
|
+
* Authentication is `Authorization: Bearer <operator token>` against the
|
|
23
|
+
* control plane — the same credential the TUI and the web UI already use.
|
|
24
|
+
*
|
|
25
|
+
* The defaults are the whole point. A homelab box is headless: the operator has
|
|
26
|
+
* SSHed into it and wants `cluster status` to work with no flags at all. Flags
|
|
27
|
+
* exist for the other case — driving a machine in the next room from a laptop —
|
|
28
|
+
* and for scripting.
|
|
29
|
+
*
|
|
30
|
+
* What this module deliberately does NOT do: hold any knowledge of what the
|
|
31
|
+
* verbs mean. It resolves a target, makes a request, and returns the parsed
|
|
32
|
+
* body. Every decision about what the answer MEANS belongs to the daemon.
|
|
33
|
+
*/
|
|
34
|
+
import { readControlPlaneBinding } from '@pellux/goodvibes-sdk/platform/config';
|
|
35
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
36
|
+
import { readOperatorTokenFile } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
37
|
+
|
|
38
|
+
/** Where to send a verb, and what to authenticate with. */
|
|
39
|
+
export interface RemoteDaemonTarget {
|
|
40
|
+
readonly baseUrl: string;
|
|
41
|
+
readonly token: string;
|
|
42
|
+
/** True when the target is this machine's own daemon, for message wording. */
|
|
43
|
+
readonly isLocal: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The flags every remote-capable daemon subcommand accepts. */
|
|
47
|
+
export interface RemoteTargetFlags {
|
|
48
|
+
readonly host?: string | undefined;
|
|
49
|
+
readonly port?: number | undefined;
|
|
50
|
+
readonly token?: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type RemoteTargetResolution =
|
|
54
|
+
| { readonly ok: true; readonly target: RemoteDaemonTarget }
|
|
55
|
+
| { readonly ok: false; readonly error: string; readonly fix: string };
|
|
56
|
+
|
|
57
|
+
export interface ResolveRemoteTargetInput {
|
|
58
|
+
readonly flags: RemoteTargetFlags;
|
|
59
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
60
|
+
readonly daemonHomeDir: string;
|
|
61
|
+
/** Injectable so tests never read a real token file. */
|
|
62
|
+
readonly readToken?: ((daemonHomeDir: string) => string | undefined) | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pull the token out of the operator-token file's contents.
|
|
67
|
+
*
|
|
68
|
+
* `readOperatorTokenFile` hands back the FILE, and the file is a JSON record —
|
|
69
|
+
* `{ token, peerId, createdAt }` — not a bare token. Sending the whole document
|
|
70
|
+
* as a bearer credential produces a 401 that looks exactly like a stale token,
|
|
71
|
+
* which is a genuinely confusing way to fail. A file that is not JSON is
|
|
72
|
+
* treated as a bare token, which is what a hand-written one would be.
|
|
73
|
+
*/
|
|
74
|
+
export function extractOperatorToken(contents: string | undefined): string | undefined {
|
|
75
|
+
const trimmed = contents?.trim();
|
|
76
|
+
if (!trimmed) return undefined;
|
|
77
|
+
if (!trimmed.startsWith('{')) return trimmed;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(trimmed) as { token?: unknown };
|
|
80
|
+
return typeof parsed.token === 'string' && parsed.token.length > 0 ? parsed.token : undefined;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Work out which daemon to talk to.
|
|
88
|
+
*
|
|
89
|
+
* A missing token is a refusal rather than an anonymous attempt: sending an
|
|
90
|
+
* unauthenticated request would come back 401 and the operator would be left
|
|
91
|
+
* guessing whether the daemon was down or the credential was missing.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveRemoteDaemonTarget(input: ResolveRemoteTargetInput): RemoteTargetResolution {
|
|
94
|
+
const binding = readControlPlaneBinding((key) => input.configManager.get(key as never));
|
|
95
|
+
const host = input.flags.host?.trim() || binding.host;
|
|
96
|
+
const port = input.flags.port ?? binding.port;
|
|
97
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `'${String(port)}' is not a usable port`,
|
|
101
|
+
fix: 'pass --port with a number between 1 and 65535',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isLocal = host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0.0.0.0';
|
|
106
|
+
const explicitToken = input.flags.token?.trim();
|
|
107
|
+
const token = explicitToken
|
|
108
|
+
|| extractOperatorToken((input.readToken ?? readOperatorTokenFile)(input.daemonHomeDir));
|
|
109
|
+
if (!token) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: 'no operator token was found for this machine',
|
|
113
|
+
fix: isLocal
|
|
114
|
+
? 'start the daemon once so it creates its operator token, or pass --token'
|
|
115
|
+
: 'pass --token with the operator token from the machine you are trying to reach',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { ok: true, target: { baseUrl: buildBaseUrl(host, port, binding.tlsMode), token, isLocal } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the URL to CALL.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately not `deriveControlPlaneBaseUrl`. That function answers a
|
|
126
|
+
* different question — where a daemon should BIND — and it applies the
|
|
127
|
+
* `hostMode` policy while doing so: with the default `local` mode it forces
|
|
128
|
+
* 127.0.0.1 and discards the host it was given. For a client that is precisely
|
|
129
|
+
* wrong, and it silently turned `--host 10.0.0.7` into a call to this machine.
|
|
130
|
+
*
|
|
131
|
+
* Here the host the operator typed is the host, full stop. Only the scheme
|
|
132
|
+
* comes from configuration.
|
|
133
|
+
*/
|
|
134
|
+
function buildBaseUrl(host: string, port: number, tlsMode: string | undefined): string {
|
|
135
|
+
const scheme = tlsMode && tlsMode !== 'off' && tlsMode !== 'none' ? 'https' : 'http';
|
|
136
|
+
// An IPv6 literal has to be bracketed or the port reads as part of the address.
|
|
137
|
+
const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
138
|
+
return `${scheme}://${authority}:${port}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one thing this module needs from `fetch`.
|
|
143
|
+
*
|
|
144
|
+
* Narrower than `typeof fetch` on purpose: the global carries runtime-specific
|
|
145
|
+
* extras (Bun adds `preconnect`) that a test double has no business
|
|
146
|
+
* implementing, and requiring them would push every test into an `any`.
|
|
147
|
+
*/
|
|
148
|
+
export type DaemonFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
149
|
+
|
|
150
|
+
/** What a verb call came back with. */
|
|
151
|
+
export type DaemonVerbOutcome<T> =
|
|
152
|
+
| { readonly ok: true; readonly data: T }
|
|
153
|
+
| { readonly ok: false; readonly error: string; readonly fix: string };
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Call a daemon verb.
|
|
157
|
+
*
|
|
158
|
+
* Every failure shape becomes an `error`/`fix` pair in plain language, because
|
|
159
|
+
* this is the layer where "ECONNREFUSED" has to turn into something an operator
|
|
160
|
+
* can act on. The daemon's OWN refusals already carry a fix and are passed
|
|
161
|
+
* through unchanged rather than being reworded here.
|
|
162
|
+
*/
|
|
163
|
+
export async function callDaemonVerb<T>(
|
|
164
|
+
target: RemoteDaemonTarget,
|
|
165
|
+
path: string,
|
|
166
|
+
init: { method: 'GET' | 'POST'; body?: unknown } = { method: 'GET' },
|
|
167
|
+
fetchImpl: DaemonFetch = fetch,
|
|
168
|
+
): Promise<DaemonVerbOutcome<T>> {
|
|
169
|
+
const where = target.isLocal ? 'the daemon on this machine' : `the daemon at ${target.baseUrl}`;
|
|
170
|
+
let response: Response;
|
|
171
|
+
try {
|
|
172
|
+
response = await fetchImpl(`${target.baseUrl}${path}`, {
|
|
173
|
+
method: init.method,
|
|
174
|
+
headers: {
|
|
175
|
+
Authorization: `Bearer ${target.token}`,
|
|
176
|
+
...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
177
|
+
},
|
|
178
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
error: `could not reach ${where}`,
|
|
184
|
+
fix: target.isLocal
|
|
185
|
+
? 'check the daemon is running: goodvibes-daemon service-status'
|
|
186
|
+
: `check that machine is switched on and reachable, and that its daemon is listening on ${target.baseUrl} `
|
|
187
|
+
+ `(${error instanceof Error ? error.message : 'no further detail'})`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (response.status === 401 || response.status === 403) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
error: `${where} refused the operator token`,
|
|
195
|
+
fix: target.isLocal
|
|
196
|
+
? 'the token may be stale — restart the daemon, or pass --token'
|
|
197
|
+
: 'pass --token with the operator token from that machine (its <daemon home>/operator-tokens.json)',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (response.status === 404) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: `${where} does not know this command`,
|
|
204
|
+
fix: 'that daemon is running an older build — update it, then try again',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let payload: unknown;
|
|
209
|
+
try {
|
|
210
|
+
payload = await response.json();
|
|
211
|
+
} catch {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: `${where} sent a reply this build could not read`,
|
|
215
|
+
fix: 'check that both machines are running the same version',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const body = payload as { ok?: unknown; data?: unknown; error?: unknown; fix?: unknown };
|
|
219
|
+
if (body.ok === true) return { ok: true, data: body.data as T };
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: typeof body.error === 'string' ? body.error : `${where} refused the request`,
|
|
223
|
+
fix: typeof body.fix === 'string' ? body.fix : 'run `cluster status` to see the current state',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.ts — turning the daemon's answers into lines a person reads.
|
|
3
|
+
*
|
|
4
|
+
* Every function here is pure: structured data in, strings out. The verb layer
|
|
5
|
+
* decided what is true; this decides how to say it. That split is what lets
|
|
6
|
+
* `--json` print the same data with no second code path, and what lets the TUI
|
|
7
|
+
* and a web UI render the same answers their own way.
|
|
8
|
+
*/
|
|
9
|
+
import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
10
|
+
import type {
|
|
11
|
+
RotateKeyResult,
|
|
12
|
+
CreateGroupResult,
|
|
13
|
+
DiscoveredGroup,
|
|
14
|
+
ForgetNodeResult,
|
|
15
|
+
GroupStatusReport,
|
|
16
|
+
JoinGroupResult,
|
|
17
|
+
JoinKeyResult,
|
|
18
|
+
NodesResult,
|
|
19
|
+
} from '@pellux/goodvibes-sdk/platform/cluster';
|
|
20
|
+
|
|
21
|
+
/** How long ago, in words. Never a raw timestamp — nobody reads epoch millis. */
|
|
22
|
+
export function describeAge(at: number, now: number): string {
|
|
23
|
+
const seconds = Math.max(0, Math.round((now - at) / 1_000));
|
|
24
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
25
|
+
const minutes = Math.round(seconds / 60);
|
|
26
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
27
|
+
const hours = Math.round(minutes / 60);
|
|
28
|
+
if (hours < 48) return `${hours}h ago`;
|
|
29
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pad(value: string, width: number): string {
|
|
33
|
+
return value.length >= width ? value : value + ' '.repeat(width - value.length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `cluster status`.
|
|
38
|
+
*
|
|
39
|
+
* Leads with the thing the operator came for — the role of THIS machine and
|
|
40
|
+
* which surfaces it holds — and finishes with the one sentence of advice, if
|
|
41
|
+
* there is anything to do.
|
|
42
|
+
*/
|
|
43
|
+
export function renderStatus(report: GroupStatusReport, now: number): string[] {
|
|
44
|
+
if (report.membership !== 'member') {
|
|
45
|
+
return [
|
|
46
|
+
report.membership === 'no-group'
|
|
47
|
+
? 'this machine is not in a group'
|
|
48
|
+
: 'this machine has group key material that could not be read',
|
|
49
|
+
`machine: ${report.nodeName} (${report.nodeId})`,
|
|
50
|
+
`version: ${report.version}`,
|
|
51
|
+
...(report.advice ? ['', report.advice] : []),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
const lines = [
|
|
55
|
+
`group: ${report.groupName ?? '(unnamed)'} [${report.groupId ?? 'unknown'}]`,
|
|
56
|
+
`machine: ${report.nodeName} (${report.nodeId})`,
|
|
57
|
+
`members: ${report.memberCount}`,
|
|
58
|
+
`version: ${report.version}`,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
if (report.surfaces === null) {
|
|
62
|
+
lines.push('surfaces: not reported by this daemon');
|
|
63
|
+
} else if (report.surfaces.length === 0) {
|
|
64
|
+
lines.push('surfaces: none held by this machine right now');
|
|
65
|
+
} else {
|
|
66
|
+
lines.push('surfaces held by this machine:');
|
|
67
|
+
for (const holding of report.surfaces) {
|
|
68
|
+
lines.push(` ${holding.surfaceId} — ${holding.reason}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push(
|
|
73
|
+
'',
|
|
74
|
+
`group key: generation ${report.keyGeneration ?? 0}, rotates every ${report.rotationHours}h`,
|
|
75
|
+
` ${report.keyGenerationsHeld} of at most ${report.keyGenerationCap} generations kept; `
|
|
76
|
+
+ `accepting ${report.acceptedGenerations.join(' and ') || 'none'}`,
|
|
77
|
+
);
|
|
78
|
+
if (report.removedNodeCount > 0) {
|
|
79
|
+
lines.push(`removed machines on record: ${report.removedNodeCount}`);
|
|
80
|
+
}
|
|
81
|
+
if (report.replication) {
|
|
82
|
+
const replication = report.replication;
|
|
83
|
+
lines.push(
|
|
84
|
+
'',
|
|
85
|
+
`shared settings: ${replication.entries} setting${replication.entries === 1 ? '' : 's'} and `
|
|
86
|
+
+ `${replication.secrets} credential${replication.secrets === 1 ? '' : 's'} at revision ${replication.revision}`,
|
|
87
|
+
);
|
|
88
|
+
if (replication.tombstones > 0) {
|
|
89
|
+
lines.push(` ${replication.tombstones} deleted setting(s) still on record`);
|
|
90
|
+
}
|
|
91
|
+
if (replication.lastAppliedFrom) {
|
|
92
|
+
lines.push(
|
|
93
|
+
` last change from ${replication.lastAppliedFrom}`
|
|
94
|
+
+ (replication.lastAppliedAt ? ` ${describeAge(replication.lastAppliedAt, now)}` : ''),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (replication.pendingProposals > 0) {
|
|
98
|
+
lines.push(` ${replication.pendingProposals} change(s) waiting for the responsible machine`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (report.wire) {
|
|
102
|
+
lines.push(
|
|
103
|
+
`traffic: ${report.wire.sent} sent, ${report.wire.received} received; dropped `
|
|
104
|
+
+ `${report.wire.droppedOtherGroup} from other groups, ${report.wire.droppedBadSignature} unverified, `
|
|
105
|
+
+ `${report.wire.droppedOldGeneration} on an old key, ${report.wire.droppedMalformed} malformed`
|
|
106
|
+
+ (report.wire.droppedNoGroup > 0
|
|
107
|
+
? `; ${report.wire.droppedNoGroup} not sent because this machine is in no group`
|
|
108
|
+
: ''),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (report.advice) lines.push('', report.advice);
|
|
112
|
+
// Deliberately absent, and it should stay absent: the join key, the group
|
|
113
|
+
// key, any generation of it, this machine's private keys, and the VALUE of
|
|
114
|
+
// any replicated credential. `cluster key` is the only path to the join key
|
|
115
|
+
// and nothing shows the rest at all.
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `cluster nodes`. */
|
|
120
|
+
export function renderNodes(result: NodesResult, now: number): string[] {
|
|
121
|
+
const lines = [`group: ${result.groupName} [${result.groupId}]`, ''];
|
|
122
|
+
if (result.members.length === 0) {
|
|
123
|
+
lines.push('no machines in this group yet');
|
|
124
|
+
} else {
|
|
125
|
+
const width = Math.max(...result.members.map((member) => member.displayName.length), 12);
|
|
126
|
+
lines.push(`${pad('MACHINE', width)} ${pad('NODE ID', 38)} LAST SEEN`);
|
|
127
|
+
for (const member of result.members) {
|
|
128
|
+
const suffix = member.isThisMachine ? ' (this machine)' : '';
|
|
129
|
+
lines.push(
|
|
130
|
+
`${pad(member.displayName, width)} ${pad(member.nodeId, 38)} ${describeAge(member.lastSeenAt, now)}${suffix}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (result.removed.length > 0) {
|
|
135
|
+
lines.push('', 'removed from this group:');
|
|
136
|
+
for (const entry of result.removed) {
|
|
137
|
+
lines.push(` ${entry.nodeId} ${describeAge(entry.removedAt, now)} (${entry.reason})`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return lines;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `cluster create`. */
|
|
144
|
+
export function renderCreated(result: CreateGroupResult): string[] {
|
|
145
|
+
return [
|
|
146
|
+
`created the group "${result.groupName}" [${result.groupId}]`,
|
|
147
|
+
'',
|
|
148
|
+
result.generatedKey
|
|
149
|
+
? 'a join key was generated for you. Run `cluster key` on this machine at any'
|
|
150
|
+
: 'your join phrase is in use. Run `cluster key` on this machine at any',
|
|
151
|
+
'time to see it again — it is not a one-time reveal.',
|
|
152
|
+
'',
|
|
153
|
+
'to add another machine: run `cluster join` on it and give it this key.',
|
|
154
|
+
'',
|
|
155
|
+
`the group name ("${result.groupName}") is visible to anything on this network that`,
|
|
156
|
+
'is listening for goodvibes groups. Change it any time with `cluster rename`.',
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `cluster join`. */
|
|
161
|
+
export function renderJoined(result: JoinGroupResult): string[] {
|
|
162
|
+
return [
|
|
163
|
+
`joined "${result.groupName}" [${result.groupId}]`,
|
|
164
|
+
`the group now has ${result.memberCount} machine${result.memberCount === 1 ? '' : 's'}.`,
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `cluster forget`. */
|
|
169
|
+
export function renderForgotten(result: ForgetNodeResult): string[] {
|
|
170
|
+
return [
|
|
171
|
+
`removed ${result.displayName} (${result.nodeId}) from the group`,
|
|
172
|
+
`the group key was replaced immediately, so that machine's copy no longer works.`,
|
|
173
|
+
`${result.memberCount} machine${result.memberCount === 1 ? '' : 's'} remain${result.memberCount === 1 ? 's' : ''}.`,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The groups seen advertising themselves on this network. */
|
|
178
|
+
export function renderDiscovered(groups: readonly DiscoveredGroup[], now: number): string[] {
|
|
179
|
+
if (groups.length === 0) {
|
|
180
|
+
return [
|
|
181
|
+
'no goodvibes groups are advertising themselves on this network',
|
|
182
|
+
'',
|
|
183
|
+
'if the machine you want to join is switched on and has clustering enabled,',
|
|
184
|
+
'check that both machines are on the same network and that it is not blocking',
|
|
185
|
+
'multicast. You can also join by id directly: cluster join --group <id> --key <key>',
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
const width = Math.max(...groups.map((group) => group.displayName.length), 10);
|
|
189
|
+
const lines = [`${pad('GROUP', width)} ${pad('ID', 18)} MACHINES VERSION SEEN`];
|
|
190
|
+
for (const group of groups) {
|
|
191
|
+
lines.push(
|
|
192
|
+
`${pad(group.displayName, width)} ${pad(group.groupId, 18)} ${pad(String(group.nodeCount), 8)} `
|
|
193
|
+
+ `${pad(group.version, 7)} ${describeAge(group.lastSeenAt, now)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ask the terminal to put the join key on the clipboard, using OSC 52.
|
|
201
|
+
*
|
|
202
|
+
* OSC 52 is the only clipboard mechanism that works through SSH, which is the
|
|
203
|
+
* case that matters: the operator is on a headless box in a cupboard. It is
|
|
204
|
+
* also not universally supported and gives no acknowledgement, so this NEVER
|
|
205
|
+
* claims success — it says what it attempted, and the key is printed either
|
|
206
|
+
* way. Silently doing nothing would be the worst of the three options.
|
|
207
|
+
*/
|
|
208
|
+
export function clipboardEscapeSequence(value: string): string {
|
|
209
|
+
return `]52;c;${Buffer.from(value, 'utf8').toString('base64')}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface JoinKeyRendering {
|
|
213
|
+
/** Lines to print. */
|
|
214
|
+
readonly lines: string[];
|
|
215
|
+
/** The raw escape sequence to write, or null when the output is not a terminal. */
|
|
216
|
+
readonly clipboardSequence: string | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function renderJoinKey(result: JoinKeyResult, isTerminal: boolean): JoinKeyRendering {
|
|
220
|
+
return {
|
|
221
|
+
lines: [
|
|
222
|
+
`join key for "${result.groupName}" [${result.groupId}]`,
|
|
223
|
+
'',
|
|
224
|
+
` ${result.joinKey}`,
|
|
225
|
+
'',
|
|
226
|
+
...(isTerminal
|
|
227
|
+
? ['copied to the clipboard, if this terminal accepts clipboard writes over its connection.',
|
|
228
|
+
'if nothing was copied, the key is printed above.']
|
|
229
|
+
: ['(not a terminal, so no clipboard copy was attempted)']),
|
|
230
|
+
'',
|
|
231
|
+
`to add a machine: run cluster join --group ${result.groupId} --key <the key above>`,
|
|
232
|
+
],
|
|
233
|
+
clipboardSequence: isTerminal ? clipboardEscapeSequence(result.joinKey) : null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `cluster rotate`. */
|
|
238
|
+
export function renderRotated(result: RotateKeyResult): string[] {
|
|
239
|
+
return [
|
|
240
|
+
`the group key is now generation ${result.keyGeneration}`,
|
|
241
|
+
result.immediate
|
|
242
|
+
? 'the previous key stopped being accepted immediately, and the key the group signs with'
|
|
243
|
+
: 'the previous key stays accepted for a few minutes so nothing is interrupted while',
|
|
244
|
+
result.immediate
|
|
245
|
+
? 'was replaced too. A machine that is asleep right now will ask to come back when it wakes.'
|
|
246
|
+
: `the other ${Math.max(0, result.memberCount - 1)} machine(s) pick the new one up.`,
|
|
247
|
+
'',
|
|
248
|
+
`accepting generation${result.acceptedGenerations.length === 1 ? '' : 's'} `
|
|
249
|
+
+ `${result.acceptedGenerations.join(' and ')}`,
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The join key as a QR code, for typing it into a phone or a second terminal.
|
|
255
|
+
*
|
|
256
|
+
* This reuses the daemon's existing QR renderer verbatim — the same one the
|
|
257
|
+
* pairing flow already prints to a terminal — rather than introducing any new
|
|
258
|
+
* machinery for it. Opt-in behind `--qr`, because a QR block is tall and most
|
|
259
|
+
* of the time the operator is copying the key with the mouse or the clipboard.
|
|
260
|
+
*/
|
|
261
|
+
export function renderJoinKeyQr(joinKey: string): string[] {
|
|
262
|
+
return [renderQrToString(generateQrMatrix(joinKey))];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A refusal, in the shape every failing subcommand prints. */
|
|
266
|
+
export function renderFailure(error: string, fix: string): string[] {
|
|
267
|
+
return [`cluster: ${error}`, ` ${fix}`];
|
|
268
|
+
}
|
|
@@ -54,8 +54,9 @@ import {
|
|
|
54
54
|
renderConversationToolCallNode,
|
|
55
55
|
renderConversationToolMessage,
|
|
56
56
|
renderConversationUserMessage,
|
|
57
|
-
|
|
57
|
+
collectToolCallOutcomes,
|
|
58
58
|
isTurnCollapsed,
|
|
59
|
+
type ToolCallOutcome,
|
|
59
60
|
} from './conversation-rendering.ts';
|
|
60
61
|
import {
|
|
61
62
|
buildRenderPlan,
|
|
@@ -149,10 +150,12 @@ interface KeyMeta {
|
|
|
149
150
|
* invalidate the entry, rather than waiting for every call in the message to
|
|
150
151
|
* complete before any of them re-renders as done.
|
|
151
152
|
*/
|
|
152
|
-
function pendingToolKeyOf(m: Message,
|
|
153
|
+
function pendingToolKeyOf(m: Message, outcomes: ReadonlyMap<string, ToolCallOutcome>): string | undefined {
|
|
153
154
|
if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) return undefined;
|
|
155
|
+
// The OUTCOME, not merely ran/not-ran: a call whose result turns out to be a
|
|
156
|
+
// failure must repaint its glyph from ◌ to ✗, which a boolean would miss.
|
|
154
157
|
return m.toolCalls
|
|
155
|
-
.map((tc) => `${tc.id ?? ''}:${tc.id !== undefined &&
|
|
158
|
+
.map((tc) => `${tc.id ?? ''}:${(tc.id !== undefined && outcomes.get(tc.id)) || 'pending'}`)
|
|
156
159
|
.join('|');
|
|
157
160
|
}
|
|
158
161
|
|
|
@@ -338,7 +341,8 @@ export class MessageLineCache {
|
|
|
338
341
|
|
|
339
342
|
// Tool calls with no matching tool-result message are still pending; the
|
|
340
343
|
// render context and the cache key both depend on this. (item 2c.)
|
|
341
|
-
const
|
|
344
|
+
const toolCallOutcomes = collectToolCallOutcomes(messages);
|
|
345
|
+
const completedToolCallIds = new Set(toolCallOutcomes.keys());
|
|
342
346
|
const assistantTurns = computeAssistantTurns(messages, msgIndexOffset);
|
|
343
347
|
// Structural plan: a permutation of the slice (results lifted under their
|
|
344
348
|
// calls, spawned agents spliced under the call that spawned them), rebuilt
|
|
@@ -346,7 +350,7 @@ export class MessageLineCache {
|
|
|
346
350
|
const plan = buildRenderPlan(messages, msgIndexOffset, {
|
|
347
351
|
resolveAgentSnapshot: context.resolveAgentSnapshot,
|
|
348
352
|
});
|
|
349
|
-
const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, assistantTurns };
|
|
353
|
+
const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, toolCallOutcomes, assistantTurns };
|
|
350
354
|
|
|
351
355
|
const touched = new Set<string>();
|
|
352
356
|
const turnHeaderLines = new Map<string, number>();
|
|
@@ -373,7 +377,7 @@ export class MessageLineCache {
|
|
|
373
377
|
const trailingBlank = !next || next.depth === 0;
|
|
374
378
|
const uncacheable = isRoot && node.kind === 'message' && node.absIdx === streamingPlaceholderAbsIdx;
|
|
375
379
|
const kind = isRoot ? context.messageKindRegistry.get(node.absIdx) : undefined;
|
|
376
|
-
const pendingToolKey = pendingToolKeyOf(node.message,
|
|
380
|
+
const pendingToolKey = pendingToolKeyOf(node.message, toolCallOutcomes);
|
|
377
381
|
|
|
378
382
|
if (!uncacheable) {
|
|
379
383
|
const existing = this.entries.get(node.id);
|
|
@@ -482,6 +486,7 @@ export class MessageLineCache {
|
|
|
482
486
|
configManager: context.configManager,
|
|
483
487
|
splashOptions: context.splashOptions,
|
|
484
488
|
completedToolCallIds: context.completedToolCallIds,
|
|
489
|
+
toolCallOutcomes: context.toolCallOutcomes,
|
|
485
490
|
assistantTurns: context.assistantTurns,
|
|
486
491
|
resolveAgentSnapshot: context.resolveAgentSnapshot,
|
|
487
492
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-render-context.ts — the inputs a transcript row render reads,
|
|
3
|
+
* and the small pure derivations over them.
|
|
4
|
+
*
|
|
5
|
+
* Kept apart from conversation-rendering.ts (which draws) so that both the
|
|
6
|
+
* drawing module and the per-row modules that hang off it can depend on the
|
|
7
|
+
* SHAPE of a render without depending on each other. Every import here is
|
|
8
|
+
* type-only, which is what makes this a leaf of the import graph.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Line } from '../types/grid.ts';
|
|
12
|
+
import type { BlockMeta } from './conversation-types.ts';
|
|
13
|
+
import type { AssistantTurnMembership } from './conversation-turn-structure.ts';
|
|
14
|
+
import type { SplashOptions } from '../utils/splash-lines.ts';
|
|
15
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
16
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
17
|
+
// SystemMessageKind imported from runtime directly to avoid cycle:
|
|
18
|
+
// conversation-rendering.ts → system-message-router.ts → conversation.ts → conversation-rendering.ts
|
|
19
|
+
import type { SystemMessageKind } from '@/runtime/index.ts';
|
|
20
|
+
|
|
21
|
+
type Message = ConversationMessageSnapshot;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How a tool call settled, as read from the result message it produced.
|
|
25
|
+
*
|
|
26
|
+
* The transcript stores a failure as content leading with `Error: ` (see the
|
|
27
|
+
* SDK's ConversationManager.addToolResults), and a per-call user cancellation
|
|
28
|
+
* as the more specific `Error: cancelled by user`. Reading the outcome —
|
|
29
|
+
* rather than only "did a result arrive" — is what lets the CALL row show
|
|
30
|
+
* ✓ / ✕ / ⊘ honestly instead of a ✓ that means nothing more than "it finished".
|
|
31
|
+
*/
|
|
32
|
+
export type ToolCallOutcome = 'ok' | 'error' | 'cancelled';
|
|
33
|
+
|
|
34
|
+
function outcomeOfToolContent(content: string): ToolCallOutcome {
|
|
35
|
+
if (/^Error: cancelled by user\b/.test(content)) return 'cancelled';
|
|
36
|
+
if (/^Error: /.test(content)) return 'error';
|
|
37
|
+
return 'ok';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Collect, per tool-call id, how that call settled — for the calls that have a
|
|
42
|
+
* matching tool-result message in the given slice. A call absent from the map
|
|
43
|
+
* has not run yet (e.g. it is still awaiting an approval decision) and renders
|
|
44
|
+
* with the pending glyph. (item 2c.)
|
|
45
|
+
*/
|
|
46
|
+
export function collectToolCallOutcomes(messages: readonly Message[]): Map<string, ToolCallOutcome> {
|
|
47
|
+
const outcomes = new Map<string, ToolCallOutcome>();
|
|
48
|
+
for (const message of messages) {
|
|
49
|
+
if (message.role === 'tool' && message.callId) {
|
|
50
|
+
outcomes.set(message.callId, outcomeOfToolContent(message.content));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return outcomes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ids of tool calls that have a matching tool-result message — i.e. the tools
|
|
58
|
+
* that actually ran, regardless of how they settled. Derived from
|
|
59
|
+
* collectToolCallOutcomes so the two can never disagree about what "ran" means.
|
|
60
|
+
*/
|
|
61
|
+
export function collectCompletedToolCallIds(messages: readonly Message[]): Set<string> {
|
|
62
|
+
return new Set(collectToolCallOutcomes(messages).keys());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ConversationRenderContext {
|
|
66
|
+
readonly history: {
|
|
67
|
+
addLine: (line: Line) => void;
|
|
68
|
+
addLines: (lines: Line[]) => void;
|
|
69
|
+
getLineCount: () => number;
|
|
70
|
+
};
|
|
71
|
+
readonly blockRegistry: BlockMeta[];
|
|
72
|
+
readonly collapseState: Map<string, boolean>;
|
|
73
|
+
readonly errorLineRegistry: number[];
|
|
74
|
+
/** Maps message index → SystemMessageKind for typed system messages. */
|
|
75
|
+
readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
|
|
76
|
+
readonly configManager: ConfigManager | null;
|
|
77
|
+
readonly splashOptions: SplashOptions;
|
|
78
|
+
/**
|
|
79
|
+
* Tool-call ids that have a corresponding tool-result message (i.e. the tool
|
|
80
|
+
* actually ran). An assistant tool call whose id is NOT in this set is still
|
|
81
|
+
* awaiting a decision (e.g. an approval prompt) and renders with a pending
|
|
82
|
+
* glyph instead of the completed ✓. When undefined (single-message callers
|
|
83
|
+
* without sibling context), every tool call renders as done — the prior
|
|
84
|
+
* behaviour. (item 2c.)
|
|
85
|
+
*/
|
|
86
|
+
readonly completedToolCallIds?: ReadonlySet<string>;
|
|
87
|
+
/**
|
|
88
|
+
* How each settled tool call turned out, keyed by call id (see
|
|
89
|
+
* collectToolCallOutcomes). Lets a call row show ✕ for a failure and ⊘ for a
|
|
90
|
+
* cancellation instead of a blanket ✓ the moment any result arrives. When
|
|
91
|
+
* undefined the row falls back to completedToolCallIds' ran/not-ran split.
|
|
92
|
+
*/
|
|
93
|
+
readonly toolCallOutcomes?: ReadonlyMap<string, ToolCallOutcome>;
|
|
94
|
+
/**
|
|
95
|
+
* Which assistant messages share one `● assistant` header, and what that
|
|
96
|
+
* header must say (see conversation-turn-structure.ts). Keyed by absolute
|
|
97
|
+
* index for every assistant message in a run AND every tool-result message
|
|
98
|
+
* the run's calls produced, so a collapsed turn can hide its results too.
|
|
99
|
+
* A message absent from this map (or an undefined map) renders standalone,
|
|
100
|
+
* exactly as it did before turn merging.
|
|
101
|
+
*/
|
|
102
|
+
readonly assistantTurns?: ReadonlyMap<number, AssistantTurnMembership>;
|
|
103
|
+
/**
|
|
104
|
+
* Live snapshot reader for a spawned agent's own conversation, used to
|
|
105
|
+
* splice that agent's rows in beneath the call that spawned it (see
|
|
106
|
+
* conversation-turn-structure.ts). Undefined disables nesting entirely and
|
|
107
|
+
* the transcript renders exactly as it would without subagents.
|
|
108
|
+
*/
|
|
109
|
+
readonly resolveAgentSnapshot?: (agentId: string) => readonly Message[] | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a turn's branches are hidden. Turns default to EXPANDED (unlike
|
|
114
|
+
* every other collapsible block here, which defaults to collapsed): a turn
|
|
115
|
+
* collapsed by default would hide the activity the transcript exists to show.
|
|
116
|
+
*/
|
|
117
|
+
export function isTurnCollapsed(
|
|
118
|
+
turn: AssistantTurnMembership | undefined,
|
|
119
|
+
collapseState: ReadonlyMap<string, boolean>,
|
|
120
|
+
): boolean {
|
|
121
|
+
return turn !== undefined && (collapseState.get(turn.turnKey) ?? false);
|
|
122
|
+
}
|