@vellumai/cli 0.6.5 → 0.6.6
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/package.json +1 -1
- package/src/commands/retire.ts +23 -0
- package/src/commands/terminal.ts +437 -0
- package/src/index.ts +3 -0
- package/src/lib/terminal-client.ts +177 -0
package/package.json
CHANGED
package/src/commands/retire.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
1
4
|
import {
|
|
2
5
|
findAssistantByName,
|
|
6
|
+
loadAllAssistants,
|
|
3
7
|
removeAssistantEntry,
|
|
4
8
|
} from "../lib/assistant-config";
|
|
5
9
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
10
|
+
import { getConfigDir } from "../lib/environments/paths";
|
|
11
|
+
import { getCurrentEnvironment } from "../lib/environments/resolve";
|
|
6
12
|
import {
|
|
7
13
|
authHeaders,
|
|
8
14
|
getPlatformUrl,
|
|
@@ -246,4 +252,21 @@ async function retireInner(): Promise<void> {
|
|
|
246
252
|
|
|
247
253
|
removeAssistantEntry(name);
|
|
248
254
|
console.log(`Removed ${name} from config.`);
|
|
255
|
+
|
|
256
|
+
// When no assistants remain, remove the dock-display-name sentinel so
|
|
257
|
+
// the next build.sh run falls back to "Vellum" instead of using the
|
|
258
|
+
// retired assistant's name.
|
|
259
|
+
if (loadAllAssistants().length === 0) {
|
|
260
|
+
const dockLabelFile = join(
|
|
261
|
+
getConfigDir(getCurrentEnvironment()),
|
|
262
|
+
"dock-display-name",
|
|
263
|
+
);
|
|
264
|
+
if (existsSync(dockLabelFile)) {
|
|
265
|
+
try {
|
|
266
|
+
unlinkSync(dockLabelFile);
|
|
267
|
+
} catch {
|
|
268
|
+
// Best-effort — the macOS app will also reset this on next launch.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
249
272
|
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum terminal` — Interactive shell into a managed assistant container.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the local tty to a platform terminal session (K8s exec) so the
|
|
5
|
+
* user can interact with their assistant's sandbox from iTerm2 or any
|
|
6
|
+
* local terminal emulator.
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* vellum terminal — Interactive shell
|
|
10
|
+
* vellum terminal attach <name> — Attach to a tmux session
|
|
11
|
+
* vellum terminal list — List tmux sessions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
findAssistantByName,
|
|
16
|
+
loadLatestAssistant,
|
|
17
|
+
resolveCloud,
|
|
18
|
+
} from "../lib/assistant-config.js";
|
|
19
|
+
import { getPlatformUrl, readPlatformToken } from "../lib/platform-client.js";
|
|
20
|
+
import {
|
|
21
|
+
closeTerminalSession,
|
|
22
|
+
createTerminalSession,
|
|
23
|
+
resizeTerminalSession,
|
|
24
|
+
sendTerminalInput,
|
|
25
|
+
subscribeTerminalEvents,
|
|
26
|
+
} from "../lib/terminal-client.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function printHelp(): void {
|
|
33
|
+
console.log("Usage: vellum terminal [subcommand] [options]");
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log(
|
|
36
|
+
"Open an interactive terminal session into a managed assistant container.",
|
|
37
|
+
);
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log("Subcommands:");
|
|
40
|
+
console.log(" (none) Interactive shell");
|
|
41
|
+
console.log(
|
|
42
|
+
" attach <name> Attach to a tmux session inside the container",
|
|
43
|
+
);
|
|
44
|
+
console.log(
|
|
45
|
+
" list List tmux sessions running inside the container",
|
|
46
|
+
);
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log("Options:");
|
|
49
|
+
console.log(
|
|
50
|
+
" <name> Name of the assistant (defaults to active)",
|
|
51
|
+
);
|
|
52
|
+
console.log(
|
|
53
|
+
" --assistant <name> Explicit assistant name (alternative to positional)",
|
|
54
|
+
);
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log("Examples:");
|
|
57
|
+
console.log(" vellum terminal");
|
|
58
|
+
console.log(" vellum terminal attach my-session");
|
|
59
|
+
console.log(" vellum terminal list");
|
|
60
|
+
console.log(" vellum terminal --assistant my-assistant");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ResolvedAssistant {
|
|
64
|
+
assistantId: string;
|
|
65
|
+
token: string;
|
|
66
|
+
platformUrl: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveAssistant(nameArg?: string): ResolvedAssistant {
|
|
70
|
+
const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
|
|
71
|
+
|
|
72
|
+
if (!entry) {
|
|
73
|
+
if (nameArg) {
|
|
74
|
+
console.error(`No assistant instance found with name '${nameArg}'.`);
|
|
75
|
+
} else {
|
|
76
|
+
console.error("No assistant instance found. Run `vellum hatch` first.");
|
|
77
|
+
}
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cloud = resolveCloud(entry);
|
|
82
|
+
if (cloud !== "vellum") {
|
|
83
|
+
if (cloud === "local") {
|
|
84
|
+
console.error(
|
|
85
|
+
"This assistant runs locally on your machine. You can access it directly.",
|
|
86
|
+
);
|
|
87
|
+
} else if (cloud === "docker") {
|
|
88
|
+
console.error(
|
|
89
|
+
`Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
console.error(
|
|
93
|
+
`'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const token = readPlatformToken();
|
|
100
|
+
if (!token) {
|
|
101
|
+
console.error(
|
|
102
|
+
"Not logged in. Run `vellum login` first to authenticate with the platform.",
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
assistantId: entry.assistantId,
|
|
109
|
+
token,
|
|
110
|
+
platformUrl: getPlatformUrl(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Interactive session
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
async function interactiveSession(
|
|
119
|
+
assistant: ResolvedAssistant,
|
|
120
|
+
initialCommand?: string,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const cols = process.stdout.columns || 80;
|
|
123
|
+
const rows = process.stdout.rows || 24;
|
|
124
|
+
|
|
125
|
+
console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
|
|
126
|
+
|
|
127
|
+
const { session_id: sessionId } = await createTerminalSession(
|
|
128
|
+
assistant.token,
|
|
129
|
+
assistant.assistantId,
|
|
130
|
+
cols,
|
|
131
|
+
rows,
|
|
132
|
+
assistant.platformUrl,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// --- TTY raw mode setup ---
|
|
136
|
+
const wasRaw = process.stdin.isRaw;
|
|
137
|
+
if (process.stdin.isTTY) {
|
|
138
|
+
process.stdin.setRawMode(true);
|
|
139
|
+
}
|
|
140
|
+
process.stdin.resume();
|
|
141
|
+
process.stdin.setEncoding("utf-8");
|
|
142
|
+
|
|
143
|
+
// Abort controller for the SSE stream
|
|
144
|
+
const abortController = new AbortController();
|
|
145
|
+
let exiting = false;
|
|
146
|
+
|
|
147
|
+
// --- Cleanup function (idempotent) ---
|
|
148
|
+
async function cleanup(): Promise<void> {
|
|
149
|
+
if (exiting) return;
|
|
150
|
+
exiting = true;
|
|
151
|
+
|
|
152
|
+
// Restore tty
|
|
153
|
+
if (process.stdin.isTTY) {
|
|
154
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
155
|
+
}
|
|
156
|
+
process.stdin.pause();
|
|
157
|
+
|
|
158
|
+
// Abort SSE stream
|
|
159
|
+
abortController.abort();
|
|
160
|
+
|
|
161
|
+
// Close remote session (best-effort)
|
|
162
|
+
try {
|
|
163
|
+
await closeTerminalSession(
|
|
164
|
+
assistant.token,
|
|
165
|
+
assistant.assistantId,
|
|
166
|
+
sessionId,
|
|
167
|
+
assistant.platformUrl,
|
|
168
|
+
);
|
|
169
|
+
} catch {
|
|
170
|
+
// Best-effort cleanup
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Signal handlers ---
|
|
175
|
+
const onSigInt = () => {
|
|
176
|
+
cleanup().then(() => process.exit(0));
|
|
177
|
+
};
|
|
178
|
+
const onSigTerm = () => {
|
|
179
|
+
cleanup().then(() => process.exit(0));
|
|
180
|
+
};
|
|
181
|
+
process.on("SIGINT", onSigInt);
|
|
182
|
+
process.on("SIGTERM", onSigTerm);
|
|
183
|
+
|
|
184
|
+
// --- SIGWINCH (terminal resize) ---
|
|
185
|
+
const onResize = () => {
|
|
186
|
+
const newCols = process.stdout.columns || 80;
|
|
187
|
+
const newRows = process.stdout.rows || 24;
|
|
188
|
+
resizeTerminalSession(
|
|
189
|
+
assistant.token,
|
|
190
|
+
assistant.assistantId,
|
|
191
|
+
sessionId,
|
|
192
|
+
newCols,
|
|
193
|
+
newRows,
|
|
194
|
+
assistant.platformUrl,
|
|
195
|
+
).catch(() => {
|
|
196
|
+
// Resize failures are non-fatal
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
process.stdout.on("resize", onResize);
|
|
200
|
+
|
|
201
|
+
// --- Input: stdin → remote ---
|
|
202
|
+
let inputBuffer = "";
|
|
203
|
+
let inputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
const INPUT_DEBOUNCE_MS = 30;
|
|
205
|
+
|
|
206
|
+
function flushInput(): void {
|
|
207
|
+
if (inputBuffer.length === 0) return;
|
|
208
|
+
const data = inputBuffer;
|
|
209
|
+
inputBuffer = "";
|
|
210
|
+
sendTerminalInput(
|
|
211
|
+
assistant.token,
|
|
212
|
+
assistant.assistantId,
|
|
213
|
+
sessionId,
|
|
214
|
+
data,
|
|
215
|
+
assistant.platformUrl,
|
|
216
|
+
).catch((err) => {
|
|
217
|
+
if (!exiting) {
|
|
218
|
+
console.error(`\r\nInput error: ${err.message}\r\n`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
process.stdin.on("data", (chunk: string) => {
|
|
224
|
+
if (exiting) return;
|
|
225
|
+
inputBuffer += chunk;
|
|
226
|
+
if (inputTimer) clearTimeout(inputTimer);
|
|
227
|
+
inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// --- Send initial command (for `attach` subcommand) ---
|
|
231
|
+
if (initialCommand) {
|
|
232
|
+
// Brief delay to let the shell initialize
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
234
|
+
await sendTerminalInput(
|
|
235
|
+
assistant.token,
|
|
236
|
+
assistant.assistantId,
|
|
237
|
+
sessionId,
|
|
238
|
+
initialCommand + "\r",
|
|
239
|
+
assistant.platformUrl,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Output: remote SSE → stdout ---
|
|
244
|
+
try {
|
|
245
|
+
for await (const event of subscribeTerminalEvents(
|
|
246
|
+
assistant.token,
|
|
247
|
+
assistant.assistantId,
|
|
248
|
+
sessionId,
|
|
249
|
+
assistant.platformUrl,
|
|
250
|
+
abortController.signal,
|
|
251
|
+
)) {
|
|
252
|
+
if (exiting) break;
|
|
253
|
+
// Decode base64 output and write raw bytes to stdout
|
|
254
|
+
const bytes = Buffer.from(event.data, "base64");
|
|
255
|
+
process.stdout.write(bytes);
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (!exiting) {
|
|
259
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
260
|
+
// AbortError is expected on cleanup
|
|
261
|
+
if (!msg.includes("abort")) {
|
|
262
|
+
console.error(`\r\nConnection lost: ${msg}\r\n`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
await cleanup();
|
|
267
|
+
|
|
268
|
+
// Remove listeners
|
|
269
|
+
process.off("SIGINT", onSigInt);
|
|
270
|
+
process.off("SIGTERM", onSigTerm);
|
|
271
|
+
process.stdout.off("resize", onResize);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// List tmux sessions
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
async function listTmuxSessions(assistant: ResolvedAssistant): Promise<void> {
|
|
280
|
+
const cols = 120;
|
|
281
|
+
const rows = 24;
|
|
282
|
+
|
|
283
|
+
const { session_id: sessionId } = await createTerminalSession(
|
|
284
|
+
assistant.token,
|
|
285
|
+
assistant.assistantId,
|
|
286
|
+
cols,
|
|
287
|
+
rows,
|
|
288
|
+
assistant.platformUrl,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const abortController = new AbortController();
|
|
292
|
+
const output: string[] = [];
|
|
293
|
+
let commandSent = false;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const timeout = setTimeout(() => abortController.abort(), 5000);
|
|
297
|
+
|
|
298
|
+
const streamPromise = (async () => {
|
|
299
|
+
for await (const event of subscribeTerminalEvents(
|
|
300
|
+
assistant.token,
|
|
301
|
+
assistant.assistantId,
|
|
302
|
+
sessionId,
|
|
303
|
+
assistant.platformUrl,
|
|
304
|
+
abortController.signal,
|
|
305
|
+
)) {
|
|
306
|
+
const text = Buffer.from(event.data, "base64").toString("utf-8");
|
|
307
|
+
output.push(text);
|
|
308
|
+
|
|
309
|
+
// Wait for shell prompt before sending command
|
|
310
|
+
if (!commandSent) {
|
|
311
|
+
const joined = output.join("");
|
|
312
|
+
if (
|
|
313
|
+
joined.includes("$") ||
|
|
314
|
+
joined.includes("#") ||
|
|
315
|
+
joined.includes("%")
|
|
316
|
+
) {
|
|
317
|
+
commandSent = true;
|
|
318
|
+
await sendTerminalInput(
|
|
319
|
+
assistant.token,
|
|
320
|
+
assistant.assistantId,
|
|
321
|
+
sessionId,
|
|
322
|
+
'tmux list-sessions 2>/dev/null || echo "No tmux sessions found"; exit\r',
|
|
323
|
+
assistant.platformUrl,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
|
|
330
|
+
await streamPromise.catch(() => {});
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
} catch {
|
|
333
|
+
// Expected — abort or stream end
|
|
334
|
+
} finally {
|
|
335
|
+
abortController.abort();
|
|
336
|
+
await closeTerminalSession(
|
|
337
|
+
assistant.token,
|
|
338
|
+
assistant.assistantId,
|
|
339
|
+
sessionId,
|
|
340
|
+
assistant.platformUrl,
|
|
341
|
+
).catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Parse and display results
|
|
345
|
+
const raw = output.join("");
|
|
346
|
+
// Strip ANSI escape sequences for clean parsing
|
|
347
|
+
const clean = raw.replace(
|
|
348
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
|
|
349
|
+
/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
|
|
350
|
+
"",
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Find tmux output lines (format: "session_name: N windows ...")
|
|
354
|
+
const lines = clean.split("\n");
|
|
355
|
+
const sessionLines = lines.filter(
|
|
356
|
+
(l) =>
|
|
357
|
+
/^\S+:\s+\d+\s+windows?/.test(l.trim()) ||
|
|
358
|
+
l.includes("No tmux sessions found"),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (sessionLines.length === 0) {
|
|
362
|
+
console.log("No tmux sessions found.");
|
|
363
|
+
} else {
|
|
364
|
+
for (const line of sessionLines) {
|
|
365
|
+
console.log(line.trim());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Main entry point
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
export async function terminal(): Promise<void> {
|
|
375
|
+
const args = process.argv.slice(3);
|
|
376
|
+
|
|
377
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
378
|
+
printHelp();
|
|
379
|
+
process.exit(0);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Parse arguments
|
|
383
|
+
//
|
|
384
|
+
// Accepted forms:
|
|
385
|
+
// vellum terminal [--assistant <name>]
|
|
386
|
+
// vellum terminal list [--assistant <name>]
|
|
387
|
+
// vellum terminal attach <session> [--assistant <name>]
|
|
388
|
+
let subcommand: string | undefined;
|
|
389
|
+
let assistantName: string | undefined;
|
|
390
|
+
let tmuxSessionName: string | undefined;
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i < args.length; i++) {
|
|
393
|
+
if (args[i] === "--assistant" && args[i + 1]) {
|
|
394
|
+
assistantName = args[++i];
|
|
395
|
+
} else if (args[i].startsWith("-")) {
|
|
396
|
+
// Skip unknown flags
|
|
397
|
+
continue;
|
|
398
|
+
} else if (!subcommand) {
|
|
399
|
+
// First positional — subcommand or assistant name
|
|
400
|
+
if (args[i] === "list" || args[i] === "attach") {
|
|
401
|
+
subcommand = args[i];
|
|
402
|
+
} else {
|
|
403
|
+
assistantName = args[i];
|
|
404
|
+
}
|
|
405
|
+
} else if (subcommand === "attach" && !tmuxSessionName) {
|
|
406
|
+
// Second positional after "attach" — tmux session name
|
|
407
|
+
tmuxSessionName = args[i];
|
|
408
|
+
} else if (!assistantName) {
|
|
409
|
+
// Trailing positional after subcommand args — assistant name
|
|
410
|
+
assistantName = args[i];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const assistant = resolveAssistant(assistantName);
|
|
415
|
+
|
|
416
|
+
if (subcommand === "list") {
|
|
417
|
+
await listTmuxSessions(assistant);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (subcommand === "attach") {
|
|
422
|
+
if (!tmuxSessionName) {
|
|
423
|
+
console.error("Usage: vellum terminal attach <session-name>");
|
|
424
|
+
console.error(
|
|
425
|
+
"\nUse 'vellum terminal list' to see available tmux sessions.",
|
|
426
|
+
);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
// Shell-escape the session name to handle spaces/metacharacters
|
|
430
|
+
const escaped = tmuxSessionName.replace(/'/g, "'\\''");
|
|
431
|
+
await interactiveSession(assistant, `tmux attach -t '${escaped}'`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Default: interactive shell
|
|
436
|
+
await interactiveSession(assistant);
|
|
437
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { setup } from "./commands/setup";
|
|
|
20
20
|
import { sleep } from "./commands/sleep";
|
|
21
21
|
import { ssh } from "./commands/ssh";
|
|
22
22
|
import { teleport } from "./commands/teleport";
|
|
23
|
+
import { terminal } from "./commands/terminal";
|
|
23
24
|
import { tunnel } from "./commands/tunnel";
|
|
24
25
|
import { upgrade } from "./commands/upgrade";
|
|
25
26
|
import { use } from "./commands/use";
|
|
@@ -54,6 +55,7 @@ const commands = {
|
|
|
54
55
|
sleep,
|
|
55
56
|
ssh,
|
|
56
57
|
teleport,
|
|
58
|
+
terminal,
|
|
57
59
|
tunnel,
|
|
58
60
|
upgrade,
|
|
59
61
|
use,
|
|
@@ -91,6 +93,7 @@ function printHelp(): void {
|
|
|
91
93
|
console.log(" sleep Stop the assistant process");
|
|
92
94
|
console.log(" ssh SSH into a remote assistant instance");
|
|
93
95
|
console.log(" teleport Transfer assistant data between environments");
|
|
96
|
+
console.log(" terminal Open a terminal into a managed assistant container");
|
|
94
97
|
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
95
98
|
console.log(" upgrade Upgrade an assistant to a newer version");
|
|
96
99
|
console.log(" use Set the active assistant for commands");
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform terminal API client.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Django terminal session endpoints that proxy through vembda to
|
|
5
|
+
* open K8s exec streams into managed assistant containers. Same transport
|
|
6
|
+
* the web UI's xterm.js terminal uses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { authHeaders, getPlatformUrl } from "./platform-client.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Create / Close
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export async function createTerminalSession(
|
|
16
|
+
token: string,
|
|
17
|
+
assistantId: string,
|
|
18
|
+
cols: number,
|
|
19
|
+
rows: number,
|
|
20
|
+
platformUrl?: string,
|
|
21
|
+
): Promise<{ session_id: string }> {
|
|
22
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
23
|
+
const response = await fetch(
|
|
24
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
|
|
25
|
+
{
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: await authHeaders(token, platformUrl),
|
|
28
|
+
body: JSON.stringify({ cols, rows }),
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const detail = await response.text().catch(() => "");
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Failed to create terminal session (${response.status}): ${detail || response.statusText}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return (await response.json()) as { session_id: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function closeTerminalSession(
|
|
41
|
+
token: string,
|
|
42
|
+
assistantId: string,
|
|
43
|
+
sessionId: string,
|
|
44
|
+
platformUrl?: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
47
|
+
const response = await fetch(
|
|
48
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/`,
|
|
49
|
+
{
|
|
50
|
+
method: "DELETE",
|
|
51
|
+
headers: await authHeaders(token, platformUrl),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
// 404 = already closed, treat as success
|
|
55
|
+
if (!response.ok && response.status !== 404) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Failed to close terminal session (${response.status}): ${response.statusText}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Input / Resize
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export async function sendTerminalInput(
|
|
67
|
+
token: string,
|
|
68
|
+
assistantId: string,
|
|
69
|
+
sessionId: string,
|
|
70
|
+
data: string,
|
|
71
|
+
platformUrl?: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
74
|
+
const response = await fetch(
|
|
75
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/input/`,
|
|
76
|
+
{
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: await authHeaders(token, platformUrl),
|
|
79
|
+
body: JSON.stringify({ data }),
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Failed to send terminal input (${response.status}): ${response.statusText}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function resizeTerminalSession(
|
|
90
|
+
token: string,
|
|
91
|
+
assistantId: string,
|
|
92
|
+
sessionId: string,
|
|
93
|
+
cols: number,
|
|
94
|
+
rows: number,
|
|
95
|
+
platformUrl?: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
98
|
+
const response = await fetch(
|
|
99
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/resize/`,
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: await authHeaders(token, platformUrl),
|
|
103
|
+
body: JSON.stringify({ cols, rows }),
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Failed to resize terminal (${response.status}): ${response.statusText}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// SSE event stream
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface TerminalOutputEvent {
|
|
118
|
+
seq: number;
|
|
119
|
+
/** Base64-encoded PTY output bytes. */
|
|
120
|
+
data: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Subscribe to the terminal output SSE stream. Yields parsed events as they
|
|
125
|
+
* arrive. The generator completes when the stream ends or is aborted.
|
|
126
|
+
*/
|
|
127
|
+
export async function* subscribeTerminalEvents(
|
|
128
|
+
token: string,
|
|
129
|
+
assistantId: string,
|
|
130
|
+
sessionId: string,
|
|
131
|
+
platformUrl?: string,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): AsyncGenerator<TerminalOutputEvent> {
|
|
134
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
135
|
+
const response = await fetch(
|
|
136
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/events/`,
|
|
137
|
+
{
|
|
138
|
+
headers: await authHeaders(token, platformUrl),
|
|
139
|
+
signal,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!response.ok || !response.body) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`SSE connection failed (${response.status}): ${response.statusText}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const reader = response.body.getReader();
|
|
150
|
+
const decoder = new TextDecoder();
|
|
151
|
+
let buffer = "";
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
while (true) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
|
|
158
|
+
buffer += decoder.decode(value, { stream: true });
|
|
159
|
+
const lines = buffer.split("\n");
|
|
160
|
+
// Keep the last incomplete line in the buffer
|
|
161
|
+
buffer = lines.pop() ?? "";
|
|
162
|
+
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const trimmed = line.trimEnd();
|
|
165
|
+
if (trimmed.startsWith("data: ")) {
|
|
166
|
+
try {
|
|
167
|
+
yield JSON.parse(trimmed.slice(6)) as TerminalOutputEvent;
|
|
168
|
+
} catch {
|
|
169
|
+
// Skip malformed SSE frames
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
reader.releaseLock();
|
|
176
|
+
}
|
|
177
|
+
}
|