paseo-terminal-integration 0.2.0 → 0.4.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/README.md +48 -37
- package/client/code-block-actions.tsx +367 -0
- package/client/legacy.tsx +60 -0
- package/client/reveal-run.ts +24 -0
- package/client/run-card.tsx +20 -34
- package/client/sdk-compat.ts +34 -0
- package/client/settings-screen.tsx +7 -20
- package/index.client.tsx +26 -90
- package/index.server.ts +19 -17
- package/package.json +29 -28
- package/server/attachments.ts +3 -1
- package/server/runner.ts +183 -17
- package/shared/code-blocks.ts +13 -1
- package/shared/contracts.ts +26 -30
- package/shared/settings.ts +1 -8
- package/shared/text.ts +1 -1
- package/client/panel-opener.ts +0 -29
- package/client/terminal-panel.tsx +0 -273
- package/client/xterm-modules.d.ts +0 -13
- package/client/xterm-view.tsx +0 -222
- package/server/streams.ts +0 -228
package/server/runner.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { constants as osConstants, tmpdir } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
7
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
6
8
|
import { interpreterFor, type Interpreter } from "../shared/code-blocks";
|
|
7
|
-
import { TERMINAL_NAME, type RunSnapshot } from "../shared/contracts";
|
|
9
|
+
import { TERMINAL_NAME, type RunMode, type RunSnapshot } from "../shared/contracts";
|
|
8
10
|
import { fenced, trimBlankEdges } from "../shared/text";
|
|
9
11
|
|
|
10
12
|
type Paseo = PluginHandlerContext["paseo"];
|
|
@@ -23,6 +25,9 @@ const TAIL_LINES = 6;
|
|
|
23
25
|
const REPORT_MAX_LINES = 300;
|
|
24
26
|
const REPORT_MAX_CHARS = 24_000;
|
|
25
27
|
const FINISHED_RUN_LIMIT = 200;
|
|
28
|
+
const BACKGROUND_MAX_LINES = 5_000;
|
|
29
|
+
const BACKGROUND_MAX_LINE_CHARS = 16_000;
|
|
30
|
+
const BACKGROUND_FLUSH_MS = 1_000;
|
|
26
31
|
|
|
27
32
|
// sendKeys() expands only a few key names, so control characters are written as raw bytes.
|
|
28
33
|
const CTRL_C = "\x03";
|
|
@@ -39,6 +44,7 @@ const EXTENSIONS: Record<Interpreter, string> = {
|
|
|
39
44
|
interface Run {
|
|
40
45
|
id: string;
|
|
41
46
|
key: string;
|
|
47
|
+
mode: RunMode;
|
|
42
48
|
agentId: string;
|
|
43
49
|
workspaceId: string;
|
|
44
50
|
cwd: string;
|
|
@@ -57,6 +63,7 @@ interface Run {
|
|
|
57
63
|
error: string | null;
|
|
58
64
|
cancelRequested: boolean;
|
|
59
65
|
terminal: TerminalHandle | null;
|
|
66
|
+
process: ChildProcess | null;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export interface StartRunInput {
|
|
@@ -65,9 +72,13 @@ export interface StartRunInput {
|
|
|
65
72
|
lang: string;
|
|
66
73
|
code: string;
|
|
67
74
|
send: boolean;
|
|
75
|
+
mode: RunMode;
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Runs code blocks in a per-workspace Paseo terminal, or as background processes, and reports
|
|
80
|
+
* output back to the agent.
|
|
81
|
+
*/
|
|
71
82
|
export class TerminalRunner {
|
|
72
83
|
private readonly runs = new Map<string, Run>();
|
|
73
84
|
private readonly lanes = new Map<string, Promise<void>>();
|
|
@@ -90,6 +101,7 @@ export class TerminalRunner {
|
|
|
90
101
|
const run: Run = {
|
|
91
102
|
id: randomUUID().slice(0, 8),
|
|
92
103
|
key: input.key,
|
|
104
|
+
mode: input.mode,
|
|
93
105
|
agentId: input.agentId,
|
|
94
106
|
workspaceId: agent.workspaceId,
|
|
95
107
|
cwd: agent.cwd,
|
|
@@ -108,13 +120,21 @@ export class TerminalRunner {
|
|
|
108
120
|
error: null,
|
|
109
121
|
cancelRequested: false,
|
|
110
122
|
terminal: null,
|
|
123
|
+
process: null,
|
|
111
124
|
};
|
|
112
125
|
this.runs.delete(input.key);
|
|
113
126
|
this.runs.set(input.key, run);
|
|
114
127
|
this.pruneFinished();
|
|
115
128
|
|
|
116
|
-
|
|
117
|
-
|
|
129
|
+
if (run.mode === "background") {
|
|
130
|
+
// Background runs never touch the terminal, so they skip its queue.
|
|
131
|
+
void this.execute(run);
|
|
132
|
+
} else {
|
|
133
|
+
const lane = (this.lanes.get(run.workspaceId) ?? Promise.resolve()).then(() =>
|
|
134
|
+
this.execute(run),
|
|
135
|
+
);
|
|
136
|
+
this.lanes.set(run.workspaceId, lane);
|
|
137
|
+
}
|
|
118
138
|
return snapshot(run);
|
|
119
139
|
}
|
|
120
140
|
|
|
@@ -134,7 +154,13 @@ export class TerminalRunner {
|
|
|
134
154
|
this.finish(run, "canceled");
|
|
135
155
|
} else if (run.status === "running" && !run.cancelRequested) {
|
|
136
156
|
run.cancelRequested = true;
|
|
137
|
-
run.
|
|
157
|
+
const child = run.process;
|
|
158
|
+
if (child) {
|
|
159
|
+
signalGroup(child, "SIGINT");
|
|
160
|
+
setTimeout(() => signalGroup(child, "SIGKILL"), CANCEL_GRACE_MS).unref();
|
|
161
|
+
} else {
|
|
162
|
+
run.terminal?.write(CTRL_C);
|
|
163
|
+
}
|
|
138
164
|
}
|
|
139
165
|
return snapshot(run);
|
|
140
166
|
}
|
|
@@ -153,6 +179,7 @@ export class TerminalRunner {
|
|
|
153
179
|
stop(): void {
|
|
154
180
|
this.stopped = true;
|
|
155
181
|
for (const run of this.runs.values()) {
|
|
182
|
+
if (run.process) signalGroup(run.process, "SIGTERM");
|
|
156
183
|
if (run.status === "queued" || run.status === "running") {
|
|
157
184
|
run.error = "Plugin stopped before the command finished";
|
|
158
185
|
this.finish(run, "failed");
|
|
@@ -165,11 +192,15 @@ export class TerminalRunner {
|
|
|
165
192
|
run.status = "running";
|
|
166
193
|
run.startedAt = Date.now();
|
|
167
194
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
if (run.mode === "background") {
|
|
196
|
+
await this.runInBackground(run);
|
|
197
|
+
} else {
|
|
198
|
+
const terminal = await this.ensureTerminal(run.workspaceId, run.cwd);
|
|
199
|
+
run.terminal = terminal;
|
|
200
|
+
const wrapper = await writeScripts(run);
|
|
201
|
+
terminal.write(`${CTRL_U} bash ${shellQuote(wrapper)}\r`);
|
|
202
|
+
await this.watch(run, terminal);
|
|
203
|
+
}
|
|
173
204
|
} catch (error) {
|
|
174
205
|
run.error = error instanceof Error ? error.message : String(error);
|
|
175
206
|
this.finish(run, "failed");
|
|
@@ -227,6 +258,68 @@ export class TerminalRunner {
|
|
|
227
258
|
}
|
|
228
259
|
}
|
|
229
260
|
|
|
261
|
+
/** Runs the block as a child process with no terminal, stderr merged into stdout. */
|
|
262
|
+
private async runInBackground(run: Run): Promise<void> {
|
|
263
|
+
const codeFile = await writeCode(run);
|
|
264
|
+
const output = new OutputLines();
|
|
265
|
+
const sync = setInterval(() => (run.output = output.lines()), POLL_MS);
|
|
266
|
+
let timedOut = false;
|
|
267
|
+
try {
|
|
268
|
+
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
269
|
+
// One pipe for both streams keeps their order, as a terminal shows it. The command gets
|
|
270
|
+
// its own process group, so Stop reaches its children too, and no controlling terminal or
|
|
271
|
+
// stdin, so a password prompt fails instead of waiting for input.
|
|
272
|
+
const child = spawn("/bin/sh", ["-c", 'exec "$@" 2>&1', "sh", run.interpreter, codeFile], {
|
|
273
|
+
cwd: run.cwd,
|
|
274
|
+
env: backgroundEnv(),
|
|
275
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
276
|
+
detached: true,
|
|
277
|
+
});
|
|
278
|
+
run.process = child;
|
|
279
|
+
const limit = setTimeout(() => {
|
|
280
|
+
timedOut = true;
|
|
281
|
+
signalGroup(child, "SIGKILL");
|
|
282
|
+
}, MAX_RUN_MS);
|
|
283
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
284
|
+
const decoder = new StringDecoder("utf8");
|
|
285
|
+
stream.on("data", (chunk: Buffer) => output.write(decoder.write(chunk)));
|
|
286
|
+
}
|
|
287
|
+
child.once("error", (error) => {
|
|
288
|
+
clearTimeout(limit);
|
|
289
|
+
reject(new Error(`Could not start the command in ${run.cwd}: ${error.message}`));
|
|
290
|
+
});
|
|
291
|
+
child.once("exit", (code, signal) => {
|
|
292
|
+
clearTimeout(limit);
|
|
293
|
+
const status = code ?? 128 + (signal ? osConstants.signals[signal] : 0);
|
|
294
|
+
// Output still in the pipes arrives before "close", but a leftover background job that
|
|
295
|
+
// holds them open must not keep the run going.
|
|
296
|
+
const flushed = setTimeout(() => {
|
|
297
|
+
child.stdout.destroy();
|
|
298
|
+
child.stderr.destroy();
|
|
299
|
+
resolve(status);
|
|
300
|
+
}, BACKGROUND_FLUSH_MS);
|
|
301
|
+
child.once("close", () => {
|
|
302
|
+
clearTimeout(flushed);
|
|
303
|
+
resolve(status);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
run.output = trimBlankEdges(output.lines());
|
|
308
|
+
run.truncated = output.dropped > 0;
|
|
309
|
+
if (timedOut) {
|
|
310
|
+
run.error = "Stopped: the command ran longer than 6 hours";
|
|
311
|
+
this.finish(run, "failed");
|
|
312
|
+
} else {
|
|
313
|
+
run.exitCode = exitCode;
|
|
314
|
+
this.finish(run, run.cancelRequested ? "canceled" : "done");
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
clearInterval(sync);
|
|
318
|
+
run.process = null;
|
|
319
|
+
await rm(codeFile, { force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
230
323
|
private async ensureTerminal(workspaceId: string, cwd: string): Promise<TerminalHandle> {
|
|
231
324
|
const { entries } = await this.paseo.terminals.list({ workspaceId });
|
|
232
325
|
const existing = entries.find((entry) => entry.name === TERMINAL_NAME);
|
|
@@ -289,7 +382,10 @@ function finishedCleanly(run: Run): boolean {
|
|
|
289
382
|
function snapshot(run: Run): RunSnapshot {
|
|
290
383
|
return {
|
|
291
384
|
runId: run.id,
|
|
385
|
+
workspaceId: run.workspaceId,
|
|
386
|
+
terminalId: run.terminal?.id ?? null,
|
|
292
387
|
key: run.key,
|
|
388
|
+
mode: run.mode,
|
|
293
389
|
status: run.status,
|
|
294
390
|
exitCode: run.exitCode,
|
|
295
391
|
startedAt: run.startedAt,
|
|
@@ -308,15 +404,14 @@ function snapshot(run: Run): RunSnapshot {
|
|
|
308
404
|
* of the input line. The wrapper prints concealed begin/end markers around the command's output.
|
|
309
405
|
*/
|
|
310
406
|
async function writeScripts(run: Run): Promise<string> {
|
|
311
|
-
|
|
312
|
-
const codeFile = path.join(SCRIPT_DIR, `${run.id}.${EXTENSIONS[run.interpreter]}`);
|
|
407
|
+
const codeFile = await writeCode(run);
|
|
313
408
|
const wrapper = path.join(SCRIPT_DIR, `${run.id}.run.sh`);
|
|
314
|
-
const shell =
|
|
409
|
+
const shell =
|
|
410
|
+
run.interpreter === "bash" || run.interpreter === "zsh" || run.interpreter === "fish";
|
|
315
411
|
const echo = shell
|
|
316
412
|
? `sed 's/^/$ /' -- ${shellQuote(codeFile)}`
|
|
317
413
|
: `printf '# %s\\n' ${shellQuote(run.interpreter)}; cat -- ${shellQuote(codeFile)}`;
|
|
318
414
|
|
|
319
|
-
await writeFile(codeFile, `${run.code}\n`, "utf8");
|
|
320
415
|
await writeFile(
|
|
321
416
|
wrapper,
|
|
322
417
|
[
|
|
@@ -338,6 +433,13 @@ async function writeScripts(run: Run): Promise<string> {
|
|
|
338
433
|
return wrapper;
|
|
339
434
|
}
|
|
340
435
|
|
|
436
|
+
async function writeCode(run: Run): Promise<string> {
|
|
437
|
+
await mkdir(SCRIPT_DIR, { recursive: true });
|
|
438
|
+
const codeFile = path.join(SCRIPT_DIR, `${run.id}.${EXTENSIONS[run.interpreter]}`);
|
|
439
|
+
await writeFile(codeFile, `${run.code}\n`, "utf8");
|
|
440
|
+
return codeFile;
|
|
441
|
+
}
|
|
442
|
+
|
|
341
443
|
export async function cleanupScripts(): Promise<void> {
|
|
342
444
|
await rm(SCRIPT_DIR, { recursive: true, force: true });
|
|
343
445
|
}
|
|
@@ -353,7 +455,11 @@ function formatReport(run: Run): string {
|
|
|
353
455
|
: "still running; partial output";
|
|
354
456
|
|
|
355
457
|
let lines = run.output;
|
|
356
|
-
let note = run.truncated
|
|
458
|
+
let note = !run.truncated
|
|
459
|
+
? ""
|
|
460
|
+
: run.mode === "background"
|
|
461
|
+
? " (earlier lines were dropped)"
|
|
462
|
+
: " (earlier lines scrolled out of the terminal)";
|
|
357
463
|
if (lines.length > REPORT_MAX_LINES) {
|
|
358
464
|
note = ` (last ${REPORT_MAX_LINES} of ${lines.length} lines)`;
|
|
359
465
|
lines = lines.slice(-REPORT_MAX_LINES);
|
|
@@ -364,8 +470,12 @@ function formatReport(run: Run): string {
|
|
|
364
470
|
note = ` (last ${REPORT_MAX_CHARS} characters)`;
|
|
365
471
|
}
|
|
366
472
|
|
|
473
|
+
const where =
|
|
474
|
+
run.mode === "background"
|
|
475
|
+
? "in the background, with no terminal"
|
|
476
|
+
: `in the Paseo terminal "${TERMINAL_NAME}"`;
|
|
367
477
|
return [
|
|
368
|
-
`I ran this
|
|
478
|
+
`I ran this ${where} (cwd \`${run.cwd}\`), ${outcome}:`,
|
|
369
479
|
"",
|
|
370
480
|
fenced(run.code, run.lang),
|
|
371
481
|
"",
|
|
@@ -385,6 +495,62 @@ function withoutInterruptEcho(lines: string[]): string[] {
|
|
|
385
495
|
return trimBlankEdges(lines.slice(0, end));
|
|
386
496
|
}
|
|
387
497
|
|
|
498
|
+
/** Terminals get the plugin's environment without the flag that runs Electron apps as Node. */
|
|
499
|
+
function backgroundEnv(): NodeJS.ProcessEnv {
|
|
500
|
+
const { ELECTRON_RUN_AS_NODE: _electronAsNode, ...env } = process.env;
|
|
501
|
+
// Python block-buffers a pipe; unbuffered output keeps the card's tail live.
|
|
502
|
+
return { ...env, TERM: "dumb", PYTHONUNBUFFERED: "1" };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function signalGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
|
506
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
|
507
|
+
try {
|
|
508
|
+
process.kill(-child.pid, signal);
|
|
509
|
+
} catch {
|
|
510
|
+
// The group is already gone.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Terminal output cleaning deliberately matches ANSI and control bytes.
|
|
515
|
+
// oxlint-disable-next-line no-control-regex
|
|
516
|
+
const ANSI_ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/g;
|
|
517
|
+
// oxlint-disable-next-line no-control-regex
|
|
518
|
+
const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
519
|
+
|
|
520
|
+
/** Program output as a terminal would show it, without colors: `\r` rewrites the current line. */
|
|
521
|
+
class OutputLines {
|
|
522
|
+
private readonly finished: string[] = [];
|
|
523
|
+
private current = "";
|
|
524
|
+
dropped = 0;
|
|
525
|
+
|
|
526
|
+
write(text: string): void {
|
|
527
|
+
const [first, ...rest] = text.replace(ANSI_ESCAPE, "").replace(CONTROL_CHARS, "").split("\n");
|
|
528
|
+
this.current += first;
|
|
529
|
+
for (const part of rest) {
|
|
530
|
+
this.finished.push(overwrite(this.current));
|
|
531
|
+
this.current = part;
|
|
532
|
+
}
|
|
533
|
+
if (this.current.length > BACKGROUND_MAX_LINE_CHARS) {
|
|
534
|
+
this.current = overwrite(this.current).slice(-BACKGROUND_MAX_LINE_CHARS);
|
|
535
|
+
}
|
|
536
|
+
const excess = this.finished.length - BACKGROUND_MAX_LINES;
|
|
537
|
+
if (excess > 0) {
|
|
538
|
+
this.finished.splice(0, excess);
|
|
539
|
+
this.dropped += excess;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
lines(): string[] {
|
|
544
|
+
return this.current ? [...this.finished, overwrite(this.current)] : [...this.finished];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Each segment after a carriage return overwrites the line from its start. */
|
|
549
|
+
function overwrite(line: string): string {
|
|
550
|
+
if (!line.includes("\r")) return line;
|
|
551
|
+
return line.split("\r").reduce((shown, segment) => segment + shown.slice(segment.length));
|
|
552
|
+
}
|
|
553
|
+
|
|
388
554
|
function lastIndexWhere<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
|
389
555
|
for (let index = items.length - 1; index >= 0; index--) {
|
|
390
556
|
if (predicate(items[index])) return index;
|
package/shared/code-blocks.ts
CHANGED
|
@@ -73,7 +73,8 @@ export function runnableBlocks(markdown: string): CodeBlock[] {
|
|
|
73
73
|
function normalizeCommand(block: CodeBlock): string {
|
|
74
74
|
const lines = block.code.split("\n");
|
|
75
75
|
const prompted = lines.filter((line) => /^\s*\$ /.test(line));
|
|
76
|
-
const allPrompted =
|
|
76
|
+
const allPrompted =
|
|
77
|
+
prompted.length > 0 && prompted.length === lines.filter((l) => l.trim()).length;
|
|
77
78
|
if (!PROMPT_LANGUAGES.has(block.lang) && !allPrompted) return block.code;
|
|
78
79
|
if (prompted.length === 0) return block.code;
|
|
79
80
|
return prompted.map((line) => line.replace(/^\s*\$ /, "")).join("\n");
|
|
@@ -84,3 +85,14 @@ function stripIndent(line: string, indent: number): string {
|
|
|
84
85
|
while (index < indent && line[index] === " ") index++;
|
|
85
86
|
return line.slice(index);
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
export const RUNNABLE_LANGUAGES = Object.keys(INTERPRETERS);
|
|
90
|
+
|
|
91
|
+
/** Receives one already-parsed fence from Paseo, never scans Markdown. */
|
|
92
|
+
export function runnableBlock(code: string, language: string): CodeBlock | null {
|
|
93
|
+
const lang = language.toLowerCase();
|
|
94
|
+
if (!interpreterFor(lang)) return null;
|
|
95
|
+
const normalized = normalizeCommand({ code, lang }).trim();
|
|
96
|
+
if (!normalized || normalized.length > 8_000) return null;
|
|
97
|
+
return { lang, code: normalized };
|
|
98
|
+
}
|
package/shared/contracts.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { panelLocations } from "./settings";
|
|
4
3
|
|
|
5
4
|
export const TERMINAL_NAME = "Agent commands";
|
|
6
5
|
|
|
6
|
+
export function inlineRunKey(
|
|
7
|
+
hostId: string,
|
|
8
|
+
agentId: string,
|
|
9
|
+
messageId: string,
|
|
10
|
+
blockIndex: number,
|
|
11
|
+
): string {
|
|
12
|
+
return JSON.stringify([hostId, agentId, messageId, blockIndex]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// COMPAT(legacyRunCards): added in the inline-actions fork, remove after 2027-04-01.
|
|
7
16
|
export const RUN_CARD_KIND = "terminal-run-card";
|
|
8
17
|
export const RUN_CARD_VERSION = 1;
|
|
9
18
|
|
|
@@ -17,9 +26,16 @@ export function runKey(cardId: string, blockIndex: number): string {
|
|
|
17
26
|
return `${cardId}:${blockIndex}`;
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
/** `terminal` types the block into "Agent commands"; `background` runs it with no terminal and no stdin. */
|
|
30
|
+
export const runModes = ["terminal", "background"] as const;
|
|
31
|
+
export type RunMode = (typeof runModes)[number];
|
|
32
|
+
|
|
20
33
|
export const runSnapshotSchema = z.object({
|
|
21
34
|
runId: z.string(),
|
|
35
|
+
workspaceId: z.string(),
|
|
36
|
+
terminalId: z.string().nullable(),
|
|
22
37
|
key: z.string(),
|
|
38
|
+
mode: z.enum(runModes),
|
|
23
39
|
status: z.enum(["queued", "running", "done", "canceled", "failed"]),
|
|
24
40
|
exitCode: z.number().int().nullable(),
|
|
25
41
|
startedAt: z.number().nullable(),
|
|
@@ -39,8 +55,9 @@ export const startRun = defineRpc({
|
|
|
39
55
|
agentId: z.string(),
|
|
40
56
|
key: z.string(),
|
|
41
57
|
lang: z.string(),
|
|
42
|
-
code: z.string().min(1),
|
|
58
|
+
code: z.string().min(1).max(8_000),
|
|
43
59
|
send: z.boolean(),
|
|
60
|
+
mode: z.enum(runModes),
|
|
44
61
|
}),
|
|
45
62
|
output: runSnapshotSchema,
|
|
46
63
|
});
|
|
@@ -66,34 +83,7 @@ export const sendRunOutput = defineRpc({
|
|
|
66
83
|
export const runAdhoc = defineRpc({
|
|
67
84
|
name: "run.adhoc",
|
|
68
85
|
input: z.object({ agentId: z.string(), command: z.string().min(1) }),
|
|
69
|
-
output: z.object({ cardId: z.string()
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const terminalSize = { rows: z.number().int().min(2).max(500), cols: z.number().int().min(10).max(1000) };
|
|
73
|
-
|
|
74
|
-
export const openTerminalView = defineRpc({
|
|
75
|
-
name: "view.open",
|
|
76
|
-
input: z.object({ terminalId: z.string(), ...terminalSize }),
|
|
77
|
-
output: z.object({ viewId: z.string() }),
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
/** Long-polls for terminal output after `cursor`, which acknowledges everything before it. */
|
|
81
|
-
export const readTerminalView = defineRpc({
|
|
82
|
-
name: "view.read",
|
|
83
|
-
input: z.object({ viewId: z.string(), cursor: z.number().int().nonnegative() }),
|
|
84
|
-
output: z.object({ data: z.string(), cursor: z.number().int(), closed: z.string().nullable() }),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
export const resizeTerminalView = defineRpc({
|
|
88
|
-
name: "view.resize",
|
|
89
|
-
input: z.object({ viewId: z.string(), ...terminalSize }),
|
|
90
|
-
output: z.object({}),
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
export const closeTerminalView = defineRpc({
|
|
94
|
-
name: "view.close",
|
|
95
|
-
input: z.object({ viewId: z.string() }),
|
|
96
|
-
output: z.object({}),
|
|
86
|
+
output: z.object({ cardId: z.string() }),
|
|
97
87
|
});
|
|
98
88
|
|
|
99
89
|
export const scanBlocks = defineRpc({
|
|
@@ -102,6 +92,12 @@ export const scanBlocks = defineRpc({
|
|
|
102
92
|
output: z.object({ count: z.number().int() }),
|
|
103
93
|
});
|
|
104
94
|
|
|
95
|
+
export const enableLegacyCards = defineRpc({
|
|
96
|
+
name: "legacy.cards.enable",
|
|
97
|
+
input: z.object({}),
|
|
98
|
+
output: z.object({ enabled: z.boolean() }),
|
|
99
|
+
});
|
|
100
|
+
|
|
105
101
|
export const searchTerminalOutput = defineRpc({
|
|
106
102
|
name: "terminals.search",
|
|
107
103
|
input: z.object({ query: z.string() }),
|
package/shared/settings.ts
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { defineSettings } from "@getpaseo/plugin";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
export const TERMINAL_PANEL_ID = "terminal";
|
|
5
|
-
|
|
6
|
-
export const panelLocations = ["explorer", "workspace"] as const;
|
|
7
|
-
export type PanelLocation = (typeof panelLocations)[number];
|
|
8
|
-
|
|
9
4
|
export const preferences = defineSettings({
|
|
10
5
|
id: "preferences",
|
|
11
6
|
scope: "host",
|
|
12
7
|
version: 1,
|
|
13
8
|
schema: z.object({
|
|
14
|
-
/**
|
|
15
|
-
panelLocation: z.enum(panelLocations).default("explorer"),
|
|
16
|
-
/** Initial state of a run card's "Send to agent" checkbox. */
|
|
9
|
+
/** Initial state of a code block's "Send output to agent" checkbox. */
|
|
17
10
|
sendToAgent: z.boolean().default(true),
|
|
18
11
|
}),
|
|
19
12
|
});
|
package/shared/text.ts
CHANGED
|
@@ -6,7 +6,7 @@ export function fenced(content: string, lang = ""): string {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/** Matches the run markers the runner prints, concealed, around command output. */
|
|
9
|
-
|
|
9
|
+
const RUN_MARKER = /__PTI_(?:BEGIN_[0-9a-f]+|END_[0-9a-f]+_\d+)__/g;
|
|
10
10
|
|
|
11
11
|
/** Removes the run markers from captured lines. */
|
|
12
12
|
export function withoutRunMarkers(lines: readonly string[]): string[] {
|
package/client/panel-opener.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
-
import { TERMINAL_PANEL_ID, type PanelLocation } from "../shared/settings";
|
|
3
|
-
|
|
4
|
-
type OpenPanel = PluginClientContext["openPanel"];
|
|
5
|
-
|
|
6
|
-
// Timeline renderers receive no navigation, so the entry hands its openPanel capability here.
|
|
7
|
-
let openPanel: OpenPanel | null = null;
|
|
8
|
-
|
|
9
|
-
export function setPanelOpener(next: OpenPanel | null): void {
|
|
10
|
-
openPanel = next;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Shows the terminal panel. Compact layouts have no Explorer, so they use a workspace tab and
|
|
15
|
-
* only when asked explicitly: an automatic tab switch would hide the chat on a phone.
|
|
16
|
-
*/
|
|
17
|
-
export function openTerminalPanel(
|
|
18
|
-
workspaceId: string,
|
|
19
|
-
where: PanelLocation,
|
|
20
|
-
{ compact, explicit }: { compact: boolean; explicit: boolean },
|
|
21
|
-
): void {
|
|
22
|
-
if (!openPanel || (compact && !explicit)) return;
|
|
23
|
-
const location = compact ? "workspace" : where;
|
|
24
|
-
try {
|
|
25
|
-
openPanel(TERMINAL_PANEL_ID, { workspaceId, location });
|
|
26
|
-
} catch {
|
|
27
|
-
if (location === "explorer") openPanel(TERMINAL_PANEL_ID, { workspaceId, location: "workspace" });
|
|
28
|
-
}
|
|
29
|
-
}
|