paseo-terminal-integration 0.2.0 → 0.3.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/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
- /** Runs code blocks in a per-workspace Paseo terminal and reports output back to the agent. */
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,19 @@ 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
- const lane = (this.lanes.get(run.workspaceId) ?? Promise.resolve()).then(() => this.execute(run));
117
- this.lanes.set(run.workspaceId, lane);
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(() => this.execute(run));
134
+ this.lanes.set(run.workspaceId, lane);
135
+ }
118
136
  return snapshot(run);
119
137
  }
120
138
 
@@ -134,7 +152,13 @@ export class TerminalRunner {
134
152
  this.finish(run, "canceled");
135
153
  } else if (run.status === "running" && !run.cancelRequested) {
136
154
  run.cancelRequested = true;
137
- run.terminal?.write(CTRL_C);
155
+ const child = run.process;
156
+ if (child) {
157
+ signalGroup(child, "SIGINT");
158
+ setTimeout(() => signalGroup(child, "SIGKILL"), CANCEL_GRACE_MS).unref();
159
+ } else {
160
+ run.terminal?.write(CTRL_C);
161
+ }
138
162
  }
139
163
  return snapshot(run);
140
164
  }
@@ -153,6 +177,7 @@ export class TerminalRunner {
153
177
  stop(): void {
154
178
  this.stopped = true;
155
179
  for (const run of this.runs.values()) {
180
+ if (run.process) signalGroup(run.process, "SIGTERM");
156
181
  if (run.status === "queued" || run.status === "running") {
157
182
  run.error = "Plugin stopped before the command finished";
158
183
  this.finish(run, "failed");
@@ -165,11 +190,15 @@ export class TerminalRunner {
165
190
  run.status = "running";
166
191
  run.startedAt = Date.now();
167
192
  try {
168
- const terminal = await this.ensureTerminal(run.workspaceId, run.cwd);
169
- run.terminal = terminal;
170
- const wrapper = await writeScripts(run);
171
- terminal.write(`${CTRL_U} bash ${shellQuote(wrapper)}\r`);
172
- await this.watch(run, terminal);
193
+ if (run.mode === "background") {
194
+ await this.runInBackground(run);
195
+ } else {
196
+ const terminal = await this.ensureTerminal(run.workspaceId, run.cwd);
197
+ run.terminal = terminal;
198
+ const wrapper = await writeScripts(run);
199
+ terminal.write(`${CTRL_U} bash ${shellQuote(wrapper)}\r`);
200
+ await this.watch(run, terminal);
201
+ }
173
202
  } catch (error) {
174
203
  run.error = error instanceof Error ? error.message : String(error);
175
204
  this.finish(run, "failed");
@@ -227,6 +256,68 @@ export class TerminalRunner {
227
256
  }
228
257
  }
229
258
 
259
+ /** Runs the block as a child process with no terminal, stderr merged into stdout. */
260
+ private async runInBackground(run: Run): Promise<void> {
261
+ const codeFile = await writeCode(run);
262
+ const output = new OutputLines();
263
+ const sync = setInterval(() => (run.output = output.lines()), POLL_MS);
264
+ let timedOut = false;
265
+ try {
266
+ const exitCode = await new Promise<number>((resolve, reject) => {
267
+ // One pipe for both streams keeps their order, as a terminal shows it. The command gets
268
+ // its own process group, so Stop reaches its children too, and no controlling terminal or
269
+ // stdin, so a password prompt fails instead of waiting for input.
270
+ const child = spawn("/bin/sh", ["-c", 'exec "$@" 2>&1', "sh", run.interpreter, codeFile], {
271
+ cwd: run.cwd,
272
+ env: backgroundEnv(),
273
+ stdio: ["ignore", "pipe", "pipe"],
274
+ detached: true,
275
+ });
276
+ run.process = child;
277
+ const limit = setTimeout(() => {
278
+ timedOut = true;
279
+ signalGroup(child, "SIGKILL");
280
+ }, MAX_RUN_MS);
281
+ for (const stream of [child.stdout, child.stderr]) {
282
+ const decoder = new StringDecoder("utf8");
283
+ stream.on("data", (chunk: Buffer) => output.write(decoder.write(chunk)));
284
+ }
285
+ child.once("error", (error) => {
286
+ clearTimeout(limit);
287
+ reject(new Error(`Could not start the command in ${run.cwd}: ${error.message}`));
288
+ });
289
+ child.once("exit", (code, signal) => {
290
+ clearTimeout(limit);
291
+ const status = code ?? 128 + (signal ? osConstants.signals[signal] : 0);
292
+ // Output still in the pipes arrives before "close", but a leftover background job that
293
+ // holds them open must not keep the run going.
294
+ const flushed = setTimeout(() => {
295
+ child.stdout.destroy();
296
+ child.stderr.destroy();
297
+ resolve(status);
298
+ }, BACKGROUND_FLUSH_MS);
299
+ child.once("close", () => {
300
+ clearTimeout(flushed);
301
+ resolve(status);
302
+ });
303
+ });
304
+ });
305
+ run.output = trimBlankEdges(output.lines());
306
+ run.truncated = output.dropped > 0;
307
+ if (timedOut) {
308
+ run.error = "Stopped: the command ran longer than 6 hours";
309
+ this.finish(run, "failed");
310
+ } else {
311
+ run.exitCode = exitCode;
312
+ this.finish(run, run.cancelRequested ? "canceled" : "done");
313
+ }
314
+ } finally {
315
+ clearInterval(sync);
316
+ run.process = null;
317
+ await rm(codeFile, { force: true });
318
+ }
319
+ }
320
+
230
321
  private async ensureTerminal(workspaceId: string, cwd: string): Promise<TerminalHandle> {
231
322
  const { entries } = await this.paseo.terminals.list({ workspaceId });
232
323
  const existing = entries.find((entry) => entry.name === TERMINAL_NAME);
@@ -290,6 +381,7 @@ function snapshot(run: Run): RunSnapshot {
290
381
  return {
291
382
  runId: run.id,
292
383
  key: run.key,
384
+ mode: run.mode,
293
385
  status: run.status,
294
386
  exitCode: run.exitCode,
295
387
  startedAt: run.startedAt,
@@ -308,15 +400,13 @@ function snapshot(run: Run): RunSnapshot {
308
400
  * of the input line. The wrapper prints concealed begin/end markers around the command's output.
309
401
  */
310
402
  async function writeScripts(run: Run): Promise<string> {
311
- await mkdir(SCRIPT_DIR, { recursive: true });
312
- const codeFile = path.join(SCRIPT_DIR, `${run.id}.${EXTENSIONS[run.interpreter]}`);
403
+ const codeFile = await writeCode(run);
313
404
  const wrapper = path.join(SCRIPT_DIR, `${run.id}.run.sh`);
314
405
  const shell = run.interpreter === "bash" || run.interpreter === "zsh" || run.interpreter === "fish";
315
406
  const echo = shell
316
407
  ? `sed 's/^/$ /' -- ${shellQuote(codeFile)}`
317
408
  : `printf '# %s\\n' ${shellQuote(run.interpreter)}; cat -- ${shellQuote(codeFile)}`;
318
409
 
319
- await writeFile(codeFile, `${run.code}\n`, "utf8");
320
410
  await writeFile(
321
411
  wrapper,
322
412
  [
@@ -338,6 +428,13 @@ async function writeScripts(run: Run): Promise<string> {
338
428
  return wrapper;
339
429
  }
340
430
 
431
+ async function writeCode(run: Run): Promise<string> {
432
+ await mkdir(SCRIPT_DIR, { recursive: true });
433
+ const codeFile = path.join(SCRIPT_DIR, `${run.id}.${EXTENSIONS[run.interpreter]}`);
434
+ await writeFile(codeFile, `${run.code}\n`, "utf8");
435
+ return codeFile;
436
+ }
437
+
341
438
  export async function cleanupScripts(): Promise<void> {
342
439
  await rm(SCRIPT_DIR, { recursive: true, force: true });
343
440
  }
@@ -353,7 +450,11 @@ function formatReport(run: Run): string {
353
450
  : "still running; partial output";
354
451
 
355
452
  let lines = run.output;
356
- let note = run.truncated ? " (earlier lines scrolled out of the terminal)" : "";
453
+ let note = !run.truncated
454
+ ? ""
455
+ : run.mode === "background"
456
+ ? " (earlier lines were dropped)"
457
+ : " (earlier lines scrolled out of the terminal)";
357
458
  if (lines.length > REPORT_MAX_LINES) {
358
459
  note = ` (last ${REPORT_MAX_LINES} of ${lines.length} lines)`;
359
460
  lines = lines.slice(-REPORT_MAX_LINES);
@@ -364,8 +465,12 @@ function formatReport(run: Run): string {
364
465
  note = ` (last ${REPORT_MAX_CHARS} characters)`;
365
466
  }
366
467
 
468
+ const where =
469
+ run.mode === "background"
470
+ ? "in the background, with no terminal"
471
+ : `in the Paseo terminal "${TERMINAL_NAME}"`;
367
472
  return [
368
- `I ran this in the Paseo terminal "${TERMINAL_NAME}" (cwd \`${run.cwd}\`), ${outcome}:`,
473
+ `I ran this ${where} (cwd \`${run.cwd}\`), ${outcome}:`,
369
474
  "",
370
475
  fenced(run.code, run.lang),
371
476
  "",
@@ -385,6 +490,59 @@ function withoutInterruptEcho(lines: string[]): string[] {
385
490
  return trimBlankEdges(lines.slice(0, end));
386
491
  }
387
492
 
493
+ /** Terminals get the plugin's environment without the flag that runs Electron apps as Node. */
494
+ function backgroundEnv(): NodeJS.ProcessEnv {
495
+ const { ELECTRON_RUN_AS_NODE: _electronAsNode, ...env } = process.env;
496
+ // Python block-buffers a pipe; unbuffered output keeps the card's tail live.
497
+ return { ...env, TERM: "dumb", PYTHONUNBUFFERED: "1" };
498
+ }
499
+
500
+ function signalGroup(child: ChildProcess, signal: NodeJS.Signals): void {
501
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
502
+ try {
503
+ process.kill(-child.pid, signal);
504
+ } catch {
505
+ // The group is already gone.
506
+ }
507
+ }
508
+
509
+ const ANSI_ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])/g;
510
+ const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
511
+
512
+ /** Program output as a terminal would show it, without colors: `\r` rewrites the current line. */
513
+ class OutputLines {
514
+ private readonly finished: string[] = [];
515
+ private current = "";
516
+ dropped = 0;
517
+
518
+ write(text: string): void {
519
+ const [first, ...rest] = text.replace(ANSI_ESCAPE, "").replace(CONTROL_CHARS, "").split("\n");
520
+ this.current += first;
521
+ for (const part of rest) {
522
+ this.finished.push(overwrite(this.current));
523
+ this.current = part;
524
+ }
525
+ if (this.current.length > BACKGROUND_MAX_LINE_CHARS) {
526
+ this.current = overwrite(this.current).slice(-BACKGROUND_MAX_LINE_CHARS);
527
+ }
528
+ const excess = this.finished.length - BACKGROUND_MAX_LINES;
529
+ if (excess > 0) {
530
+ this.finished.splice(0, excess);
531
+ this.dropped += excess;
532
+ }
533
+ }
534
+
535
+ lines(): string[] {
536
+ return this.current ? [...this.finished, overwrite(this.current)] : [...this.finished];
537
+ }
538
+ }
539
+
540
+ /** Each segment after a carriage return overwrites the line from its start. */
541
+ function overwrite(line: string): string {
542
+ if (!line.includes("\r")) return line;
543
+ return line.split("\r").reduce((shown, segment) => segment + shown.slice(segment.length));
544
+ }
545
+
388
546
  function lastIndexWhere<T>(items: readonly T[], predicate: (item: T) => boolean): number {
389
547
  for (let index = items.length - 1; index >= 0; index--) {
390
548
  if (predicate(items[index])) return index;
@@ -1,6 +1,5 @@
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
 
@@ -17,9 +16,14 @@ export function runKey(cardId: string, blockIndex: number): string {
17
16
  return `${cardId}:${blockIndex}`;
18
17
  }
19
18
 
19
+ /** `terminal` types the block into "Agent commands"; `background` runs it with no terminal and no stdin. */
20
+ export const runModes = ["terminal", "background"] as const;
21
+ export type RunMode = (typeof runModes)[number];
22
+
20
23
  export const runSnapshotSchema = z.object({
21
24
  runId: z.string(),
22
25
  key: z.string(),
26
+ mode: z.enum(runModes),
23
27
  status: z.enum(["queued", "running", "done", "canceled", "failed"]),
24
28
  exitCode: z.number().int().nullable(),
25
29
  startedAt: z.number().nullable(),
@@ -41,6 +45,7 @@ export const startRun = defineRpc({
41
45
  lang: z.string(),
42
46
  code: z.string().min(1),
43
47
  send: z.boolean(),
48
+ mode: z.enum(runModes),
44
49
  }),
45
50
  output: runSnapshotSchema,
46
51
  });
@@ -66,34 +71,7 @@ export const sendRunOutput = defineRpc({
66
71
  export const runAdhoc = defineRpc({
67
72
  name: "run.adhoc",
68
73
  input: z.object({ agentId: z.string(), command: z.string().min(1) }),
69
- output: z.object({ cardId: z.string(), panelLocation: z.enum(panelLocations) }),
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({}),
74
+ output: z.object({ cardId: z.string() }),
97
75
  });
98
76
 
99
77
  export const scanBlocks = defineRpc({
@@ -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
- /** Where "Run in side terminal", the Terminal button, and /run show the terminal panel. */
15
- panelLocation: z.enum(panelLocations).default("explorer"),
16
- /** Initial state of a run card's "Send to agent" checkbox. */
9
+ /** Initial state of a run card'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
- export const RUN_MARKER = /__PTI_(?:BEGIN_[0-9a-f]+|END_[0-9a-f]+_\d+)__/g;
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[] {
@@ -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
- }