paseo-terminal-integration 0.1.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/README.md +28 -8
- package/client/run-card.tsx +75 -12
- package/client/settings-screen.tsx +40 -0
- package/index.client.tsx +11 -1
- package/index.server.ts +9 -1
- package/package.json +1 -1
- package/server/attachments.ts +1 -1
- package/server/runner.ts +175 -17
- package/shared/contracts.ts +6 -0
- package/shared/settings.ts +12 -0
- package/{server → shared}/text.ts +5 -2
package/README.md
CHANGED
|
@@ -6,15 +6,26 @@ Two-way bridge between agent chats and Paseo terminals.
|
|
|
6
6
|
(`bash`, `sh`, `shell`, `console`, `zsh`, `fish`, `python`, `js`/`node`) get a **Run in terminal**
|
|
7
7
|
card under the reply:
|
|
8
8
|
|
|
9
|
-
- **Run
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
- **Run** runs the block in the workspace terminal "Agent commands", a regular Paseo terminal tab.
|
|
10
|
+
- **Run in background** runs it as a hidden process in the agent's working directory: no terminal
|
|
11
|
+
tab, and the card keeps the last 5,000 lines instead of what fits on a terminal screen. Nothing
|
|
12
|
+
can be typed into it: stdin is empty, so password and confirmation prompts fail instead of
|
|
13
|
+
waiting. Background runs do not wait for the terminal.
|
|
14
|
+
- With **Send output to agent** checked (the default), the command, exit code, and output go to
|
|
15
|
+
the agent as a new message when the block finishes. If the agent is busy, delivery waits until
|
|
16
|
+
its turn ends instead of interrupting it. Unchecked, **Send output** sends it later.
|
|
13
17
|
- **Stop** sends Ctrl-C; **Send output so far** reports a still-running command.
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
**Terminal beside the chat.** "Agent commands" opens as a background tab. Drag the tab onto the
|
|
20
|
+
right or bottom edge of the chat to split the workspace; it stays there while the terminal is open,
|
|
21
|
+
so you can watch runs and type answers to `sudo` passwords or `y/n` questions next to the
|
|
22
|
+
conversation. From the keyboard, split the pane and move the tab into it with the shortcuts in
|
|
23
|
+
**Settings → Shortcuts → Tabs & panes**. Closing the tab ends the terminal; the next run starts a
|
|
24
|
+
new one in a new tab.
|
|
25
|
+
|
|
26
|
+
Terminal runs in one workspace are queued so a command never types into another one's stdin.
|
|
27
|
+
Each block runs as a script in the agent's working directory, so `cd` and `export` do not persist
|
|
28
|
+
between runs. **Settings → Plugins → Terminal integration** sets the checkbox's default.
|
|
18
29
|
|
|
19
30
|
**Terminal → agent.** In the composer's attachment menu, **Terminal output** attaches the latest
|
|
20
31
|
lines of any terminal on the daemon. Type part of a name or path to filter, and a number to choose
|
|
@@ -44,9 +55,10 @@ Nothing runs until you press a button or submit `/run`.
|
|
|
44
55
|
|
|
45
56
|
## Limitations
|
|
46
57
|
|
|
47
|
-
-
|
|
58
|
+
- Terminal output is read from the screen, so very long lines arrive wrapped and only the last
|
|
48
59
|
3,000 rows are visible. The agent receives at most 300 lines or 24,000 characters.
|
|
49
60
|
- Run state lives in the plugin process; reloading the plugin forgets it (the cards stay).
|
|
61
|
+
- Plugins cannot split panes or move tabs, so placing the terminal beside the chat is up to you.
|
|
50
62
|
- A command that has not started within 20 seconds fails, for example when something else is
|
|
51
63
|
running in "Agent commands".
|
|
52
64
|
|
|
@@ -60,6 +72,14 @@ paseo plugin reload terminal-integration
|
|
|
60
72
|
paseo plugin logs terminal-integration
|
|
61
73
|
```
|
|
62
74
|
|
|
75
|
+
To release, bump the version and push the tag; GitHub Actions publishes it to npm through trusted
|
|
76
|
+
publishing, and Paseo Cafe picks up the new version from there:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm version patch # or minor; commits and tags vX.Y.Z
|
|
80
|
+
git push --follow-tags
|
|
81
|
+
```
|
|
82
|
+
|
|
63
83
|
## License
|
|
64
84
|
|
|
65
85
|
MIT
|
package/client/run-card.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
1
|
+
import { useRpc, useSettings, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { useMemo, useState } from "react";
|
|
@@ -11,8 +11,10 @@ import {
|
|
|
11
11
|
startRun,
|
|
12
12
|
TERMINAL_NAME,
|
|
13
13
|
type RunCardData,
|
|
14
|
+
type RunMode,
|
|
14
15
|
type RunSnapshot,
|
|
15
16
|
} from "../shared/contracts";
|
|
17
|
+
import { preferences } from "../shared/settings";
|
|
16
18
|
|
|
17
19
|
type Theme = PluginTimelineItemProps["theme"];
|
|
18
20
|
type Styles = ReturnType<typeof createStyles>;
|
|
@@ -42,6 +44,17 @@ export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProp
|
|
|
42
44
|
const cancel = useRpc(cancelRun);
|
|
43
45
|
const send = useRpc(sendRunOutput);
|
|
44
46
|
|
|
47
|
+
const settings = useSettings(preferences);
|
|
48
|
+
const [sendChoice, setSendChoice] = useState<boolean | null>(null);
|
|
49
|
+
const sendToAgent =
|
|
50
|
+
sendChoice ?? (settings.status === "ready" ? settings.values.sendToAgent : true);
|
|
51
|
+
|
|
52
|
+
function runBlock(key: string, block: RunCardData["blocks"][number], mode: RunMode) {
|
|
53
|
+
return act(key, () =>
|
|
54
|
+
start({ agentId, key, lang: block.lang, code: block.code, send: sendToAgent, mode }),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
const queryKey = useMemo(() => ["terminal-run-status", cardId], [cardId]);
|
|
46
59
|
const status = useQuery({
|
|
47
60
|
queryKey,
|
|
@@ -73,6 +86,14 @@ export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProp
|
|
|
73
86
|
<Text style={styles.muted} numberOfLines={1}>
|
|
74
87
|
· {TERMINAL_NAME}
|
|
75
88
|
</Text>
|
|
89
|
+
<View style={styles.spacer} />
|
|
90
|
+
<Checkbox
|
|
91
|
+
styles={styles}
|
|
92
|
+
theme={theme}
|
|
93
|
+
label="Send output to agent"
|
|
94
|
+
checked={sendToAgent}
|
|
95
|
+
onChange={setSendChoice}
|
|
96
|
+
/>
|
|
76
97
|
</View>
|
|
77
98
|
{blocks.map((block, index) => {
|
|
78
99
|
const key = keys[index];
|
|
@@ -116,22 +137,18 @@ export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProp
|
|
|
116
137
|
styles={styles}
|
|
117
138
|
theme={theme}
|
|
118
139
|
icon="Play"
|
|
119
|
-
label=
|
|
140
|
+
label="Run"
|
|
120
141
|
tone="primary"
|
|
121
142
|
disabled={busy || active}
|
|
122
|
-
onPress={() =>
|
|
123
|
-
act(key, () => start({ agentId, key, lang: block.lang, code: block.code, send: true }))
|
|
124
|
-
}
|
|
143
|
+
onPress={() => runBlock(key, block, "terminal")}
|
|
125
144
|
/>
|
|
126
145
|
<ActionButton
|
|
127
146
|
styles={styles}
|
|
128
147
|
theme={theme}
|
|
129
|
-
icon="
|
|
130
|
-
label="Run
|
|
148
|
+
icon="EyeOff"
|
|
149
|
+
label="Run in background"
|
|
131
150
|
disabled={busy || active}
|
|
132
|
-
onPress={() =>
|
|
133
|
-
act(key, () => start({ agentId, key, lang: block.lang, code: block.code, send: false }))
|
|
134
|
-
}
|
|
151
|
+
onPress={() => runBlock(key, block, "background")}
|
|
135
152
|
/>
|
|
136
153
|
{run && !run.sent && !active ? (
|
|
137
154
|
<ActionButton
|
|
@@ -170,7 +187,9 @@ function RunStatus({ run, styles }: { run: RunSnapshot; styles: Styles }) {
|
|
|
170
187
|
const parts: string[] = [];
|
|
171
188
|
let tone: TextStyle = styles.muted;
|
|
172
189
|
if (run.status === "queued") parts.push("Queued, waiting for the terminal");
|
|
173
|
-
if (run.status === "running")
|
|
190
|
+
if (run.status === "running") {
|
|
191
|
+
parts.push(`${run.mode === "background" ? "Running in background" : "Running"} · ${elapsed(run)}`);
|
|
192
|
+
}
|
|
174
193
|
if (run.status === "done") {
|
|
175
194
|
parts.push(`${run.exitCode === 0 ? "✓" : "✗"} exit ${run.exitCode}`);
|
|
176
195
|
tone = run.exitCode === 0 ? styles.success : styles.danger;
|
|
@@ -203,6 +222,37 @@ function RunStatus({ run, styles }: { run: RunSnapshot; styles: Styles }) {
|
|
|
203
222
|
);
|
|
204
223
|
}
|
|
205
224
|
|
|
225
|
+
function Checkbox({
|
|
226
|
+
styles,
|
|
227
|
+
theme,
|
|
228
|
+
label,
|
|
229
|
+
checked,
|
|
230
|
+
onChange,
|
|
231
|
+
}: {
|
|
232
|
+
styles: Styles;
|
|
233
|
+
theme: Theme;
|
|
234
|
+
label: string;
|
|
235
|
+
checked: boolean;
|
|
236
|
+
onChange(checked: boolean): void;
|
|
237
|
+
}) {
|
|
238
|
+
return (
|
|
239
|
+
<Pressable
|
|
240
|
+
accessibilityRole="checkbox"
|
|
241
|
+
accessibilityLabel={label}
|
|
242
|
+
accessibilityState={{ checked }}
|
|
243
|
+
onPress={() => onChange(!checked)}
|
|
244
|
+
style={({ pressed }) => [styles.checkbox, { opacity: pressed ? 0.75 : 1 }]}
|
|
245
|
+
>
|
|
246
|
+
<Icon
|
|
247
|
+
name={checked ? "SquareCheck" : "Square"}
|
|
248
|
+
size={16}
|
|
249
|
+
color={checked ? theme.colors.accent : theme.colors.foregroundMuted}
|
|
250
|
+
/>
|
|
251
|
+
<Text style={[styles.buttonText, { color: theme.colors.foreground }]}>{label}</Text>
|
|
252
|
+
</Pressable>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
206
256
|
function ActionButton({
|
|
207
257
|
styles,
|
|
208
258
|
theme,
|
|
@@ -260,9 +310,22 @@ function createStyles(theme: Theme, compact: boolean) {
|
|
|
260
310
|
padding: compact ? 10 : 12,
|
|
261
311
|
backgroundColor: theme.colors.surface1,
|
|
262
312
|
},
|
|
263
|
-
header: {
|
|
313
|
+
header: {
|
|
314
|
+
flexDirection: "row" as const,
|
|
315
|
+
flexWrap: "wrap" as const,
|
|
316
|
+
alignItems: "center" as const,
|
|
317
|
+
gap: 6,
|
|
318
|
+
},
|
|
319
|
+
checkbox: {
|
|
320
|
+
flexDirection: "row" as const,
|
|
321
|
+
alignItems: "center" as const,
|
|
322
|
+
gap: 6,
|
|
323
|
+
paddingHorizontal: 4,
|
|
324
|
+
paddingVertical: compact ? 7 : 5,
|
|
325
|
+
},
|
|
264
326
|
title: { color: theme.colors.foreground, fontWeight: "600" as const },
|
|
265
327
|
muted: { color: theme.colors.foregroundMuted, flexShrink: 1 },
|
|
328
|
+
spacer: { flex: 1 },
|
|
266
329
|
success: { color: theme.colors.statusSuccess },
|
|
267
330
|
danger: { color: theme.colors.statusDanger },
|
|
268
331
|
block: { gap: 8 },
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
SettingsCard,
|
|
4
|
+
SettingsRow,
|
|
5
|
+
SettingsSection,
|
|
6
|
+
SettingsSwitch,
|
|
7
|
+
} from "@getpaseo/plugin/client/ui";
|
|
8
|
+
import { Text } from "react-native";
|
|
9
|
+
import { TERMINAL_NAME } from "../shared/contracts";
|
|
10
|
+
import { preferences } from "../shared/settings";
|
|
11
|
+
|
|
12
|
+
export function SettingsScreen({ theme }: PluginSurfaceProps) {
|
|
13
|
+
const settings = useSettings(preferences);
|
|
14
|
+
|
|
15
|
+
if (settings.status !== "ready") {
|
|
16
|
+
const message =
|
|
17
|
+
settings.status === "loading" ? "Loading…" : `Settings are unavailable: ${settings.error}`;
|
|
18
|
+
return <Text style={{ color: theme.colors.foregroundMuted }}>{message}</Text>;
|
|
19
|
+
}
|
|
20
|
+
const { values, revision } = settings;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<SettingsSection title="Run cards">
|
|
24
|
+
<SettingsCard>
|
|
25
|
+
<SettingsSwitch
|
|
26
|
+
label="Send output to the agent by default"
|
|
27
|
+
hint={`Initial state of the "Send output to agent" checkbox on each run card.`}
|
|
28
|
+
value={values.sendToAgent}
|
|
29
|
+
disabled={settings.saving}
|
|
30
|
+
error={settings.saveError}
|
|
31
|
+
onValueChange={(sendToAgent) => void settings.save({ ...values, sendToAgent }, revision)}
|
|
32
|
+
/>
|
|
33
|
+
<SettingsRow
|
|
34
|
+
label="Where the terminal appears"
|
|
35
|
+
hint={`Commands run in the "${TERMINAL_NAME}" terminal tab. To keep it beside the chat, drag the tab onto the right or bottom edge of the chat; it stays there while the terminal is open.`}
|
|
36
|
+
/>
|
|
37
|
+
</SettingsCard>
|
|
38
|
+
</SettingsSection>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { PluginCleanup } from "@getpaseo/plugin";
|
|
1
2
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
3
|
import { RunCard } from "./client/run-card";
|
|
4
|
+
import { SettingsScreen } from "./client/settings-screen";
|
|
3
5
|
import {
|
|
4
6
|
RUN_CARD_KIND,
|
|
5
7
|
RUN_CARD_VERSION,
|
|
@@ -9,7 +11,8 @@ import {
|
|
|
9
11
|
terminalOutputSource,
|
|
10
12
|
} from "./shared/contracts";
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
// A function declaration: the host reads the default export before the module body has run.
|
|
15
|
+
export default function contribute(client: PluginClientContext): PluginCleanup {
|
|
13
16
|
client.addTimelineRenderer({
|
|
14
17
|
kind: RUN_CARD_KIND,
|
|
15
18
|
version: RUN_CARD_VERSION,
|
|
@@ -17,6 +20,13 @@ export default function contribute(client: PluginClientContext) {
|
|
|
17
20
|
Component: RunCard,
|
|
18
21
|
});
|
|
19
22
|
|
|
23
|
+
client.addSettingsScreen({
|
|
24
|
+
id: "preferences",
|
|
25
|
+
title: "Terminal integration",
|
|
26
|
+
icon: "SquareTerminal",
|
|
27
|
+
Component: SettingsScreen,
|
|
28
|
+
});
|
|
29
|
+
|
|
20
30
|
client.addAttachmentSource(terminalOutputSource);
|
|
21
31
|
|
|
22
32
|
client.addSlashCommand({
|
package/index.server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
|
2
2
|
import { searchTerminals } from "./server/attachments";
|
|
3
3
|
import { appendCardForTurn, appendRunCard, scanLatestReply } from "./server/cards";
|
|
4
4
|
import { cleanupScripts, TerminalRunner } from "./server/runner";
|
|
5
|
+
import { preferences } from "./shared/settings";
|
|
5
6
|
import {
|
|
6
7
|
cancelRun,
|
|
7
8
|
runAdhoc,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from "./shared/contracts";
|
|
15
16
|
|
|
16
17
|
export default function contribute(server: PluginServerContext) {
|
|
18
|
+
server.registerSettings(preferences);
|
|
17
19
|
let runner: TerminalRunner | null = null;
|
|
18
20
|
const runnerFor = (paseo: ConstructorParameters<typeof TerminalRunner>[0]) =>
|
|
19
21
|
(runner ??= new TerminalRunner(paseo));
|
|
@@ -29,7 +31,13 @@ export default function contribute(server: PluginServerContext) {
|
|
|
29
31
|
const block = { lang: "bash", code: command };
|
|
30
32
|
const cardId = await appendRunCard(paseo, agentId, [block]);
|
|
31
33
|
if (!cardId) throw new Error("Command is too long");
|
|
32
|
-
await runnerFor(paseo).start({
|
|
34
|
+
await runnerFor(paseo).start({
|
|
35
|
+
agentId,
|
|
36
|
+
key: runKey(cardId, 0),
|
|
37
|
+
...block,
|
|
38
|
+
send: true,
|
|
39
|
+
mode: "terminal",
|
|
40
|
+
});
|
|
33
41
|
return { cardId };
|
|
34
42
|
});
|
|
35
43
|
|
package/package.json
CHANGED
package/server/attachments.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { pathToFileURL } from "node:url";
|
|
|
2
2
|
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
3
3
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
4
4
|
import type { searchTerminalOutput } from "../shared/contracts";
|
|
5
|
-
import { fenced, trimBlankEdges, withoutRunMarkers } from "
|
|
5
|
+
import { fenced, trimBlankEdges, withoutRunMarkers } from "../shared/text";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_LINES = 200;
|
|
8
8
|
const MAX_LINES = 2_000;
|
package/server/runner.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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";
|
|
8
|
-
import { fenced, trimBlankEdges } from "
|
|
9
|
+
import { TERMINAL_NAME, type RunMode, type RunSnapshot } from "../shared/contracts";
|
|
10
|
+
import { fenced, trimBlankEdges } from "../shared/text";
|
|
9
11
|
|
|
10
12
|
type Paseo = PluginHandlerContext["paseo"];
|
|
11
13
|
type TerminalHandle = ReturnType<Paseo["terminals"]["ref"]>;
|
|
@@ -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,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
|
-
|
|
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(() => 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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
package/shared/contracts.ts
CHANGED
|
@@ -16,9 +16,14 @@ export function runKey(cardId: string, blockIndex: number): string {
|
|
|
16
16
|
return `${cardId}:${blockIndex}`;
|
|
17
17
|
}
|
|
18
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
|
+
|
|
19
23
|
export const runSnapshotSchema = z.object({
|
|
20
24
|
runId: z.string(),
|
|
21
25
|
key: z.string(),
|
|
26
|
+
mode: z.enum(runModes),
|
|
22
27
|
status: z.enum(["queued", "running", "done", "canceled", "failed"]),
|
|
23
28
|
exitCode: z.number().int().nullable(),
|
|
24
29
|
startedAt: z.number().nullable(),
|
|
@@ -40,6 +45,7 @@ export const startRun = defineRpc({
|
|
|
40
45
|
lang: z.string(),
|
|
41
46
|
code: z.string().min(1),
|
|
42
47
|
send: z.boolean(),
|
|
48
|
+
mode: z.enum(runModes),
|
|
43
49
|
}),
|
|
44
50
|
output: runSnapshotSchema,
|
|
45
51
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineSettings } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const preferences = defineSettings({
|
|
5
|
+
id: "preferences",
|
|
6
|
+
scope: "host",
|
|
7
|
+
version: 1,
|
|
8
|
+
schema: z.object({
|
|
9
|
+
/** Initial state of a run card's "Send output to agent" checkbox. */
|
|
10
|
+
sendToAgent: z.boolean().default(true),
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
@@ -5,11 +5,14 @@ export function fenced(content: string, lang = ""): string {
|
|
|
5
5
|
return `${fence}${lang}\n${content}\n${fence}`;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Matches the run markers the runner prints, concealed, around command output. */
|
|
9
|
+
const RUN_MARKER = /__PTI_(?:BEGIN_[0-9a-f]+|END_[0-9a-f]+_\d+)__/g;
|
|
10
|
+
|
|
11
|
+
/** Removes the run markers from captured lines. */
|
|
9
12
|
export function withoutRunMarkers(lines: readonly string[]): string[] {
|
|
10
13
|
return lines
|
|
11
14
|
.filter((line) => !/^\s*__PTI_BEGIN_[0-9a-f]+__\s*$/.test(line))
|
|
12
|
-
.map((line) => line.replace(
|
|
15
|
+
.map((line) => line.replace(RUN_MARKER, ""));
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function trimBlankEdges(lines: readonly string[]): string[] {
|