paseo-terminal-integration 0.1.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/LICENSE +21 -0
- package/README.md +65 -0
- package/client/run-card.tsx +309 -0
- package/index.client.tsx +56 -0
- package/index.server.ts +46 -0
- package/package.json +42 -0
- package/paseo-plugin.json +7 -0
- package/server/attachments.ts +62 -0
- package/server/cards.ts +75 -0
- package/server/runner.ts +397 -0
- package/server/text.ts +21 -0
- package/shared/code-blocks.ts +86 -0
- package/shared/contracts.ts +102 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chechulin Serhii
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Terminal integration for Paseo
|
|
2
|
+
|
|
3
|
+
Two-way bridge between agent chats and Paseo terminals.
|
|
4
|
+
|
|
5
|
+
**Agent → terminal.** When a turn ends, runnable fenced code blocks in the agent's reply
|
|
6
|
+
(`bash`, `sh`, `shell`, `console`, `zsh`, `fish`, `python`, `js`/`node`) get a **Run in terminal**
|
|
7
|
+
card under the reply:
|
|
8
|
+
|
|
9
|
+
- **Run & send to agent** runs the block in the workspace terminal "Agent commands" and, when it
|
|
10
|
+
finishes, sends the command, exit code, and output to the agent as a new message. If the agent is
|
|
11
|
+
busy, delivery waits until its turn ends instead of interrupting it.
|
|
12
|
+
- **Run only** runs it without sending; **Send output** sends it later.
|
|
13
|
+
- **Stop** sends Ctrl-C; **Send output so far** reports a still-running command.
|
|
14
|
+
|
|
15
|
+
The terminal is a real PTY, so prompts (`sudo`, `y/n`) can be answered in the terminal tab. Runs in
|
|
16
|
+
one workspace are queued so a command never types into another one's stdin. Each block runs as a
|
|
17
|
+
script in the agent's working directory, so `cd` and `export` do not persist between runs.
|
|
18
|
+
|
|
19
|
+
**Terminal → agent.** In the composer's attachment menu, **Terminal output** attaches the latest
|
|
20
|
+
lines of any terminal on the daemon. Type part of a name or path to filter, and a number to choose
|
|
21
|
+
the line count (`build 500`; default 200).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Requires Paseo 0.10.1 or later and a macOS or Linux daemon host with `bash` (plus `python3` or
|
|
26
|
+
`node` for those blocks). Enable plugins under **Settings → Plugins**, then:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
paseo plugin install npm:paseo-terminal-integration
|
|
30
|
+
# or from GitHub
|
|
31
|
+
paseo plugin install github:keefeere/paseo-terminal-integration
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Plugins are trusted, unsandboxed code: this one types commands into terminals on the daemon host.
|
|
35
|
+
Nothing runs until you press a button or submit `/run`.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Effect |
|
|
40
|
+
| ----------------------------------------------- | ----------------------------------------------------------- |
|
|
41
|
+
| `/run <command>` | Run a shell command and send its output to the agent |
|
|
42
|
+
| `/blocks` | Add a run card for the latest reply (e.g. older chats) |
|
|
43
|
+
| ⌘K / Ctrl+K → "Terminal: add run buttons to …" | Same as `/blocks` |
|
|
44
|
+
|
|
45
|
+
## Limitations
|
|
46
|
+
|
|
47
|
+
- Output is read from the terminal screen, so very long lines arrive wrapped and only the last
|
|
48
|
+
3,000 rows are visible. The agent receives at most 300 lines or 24,000 characters.
|
|
49
|
+
- Run state lives in the plugin process; reloading the plugin forgets it (the cards stay).
|
|
50
|
+
- A command that has not started within 20 seconds fails, for example when something else is
|
|
51
|
+
running in "Agent commands".
|
|
52
|
+
|
|
53
|
+
## Develop
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install
|
|
57
|
+
npm run typecheck
|
|
58
|
+
paseo plugin install "$PWD" # first time
|
|
59
|
+
paseo plugin reload terminal-integration
|
|
60
|
+
paseo plugin logs terminal-integration
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { Platform, Pressable, Text, View, type TextStyle } from "react-native";
|
|
6
|
+
import {
|
|
7
|
+
cancelRun,
|
|
8
|
+
runKey,
|
|
9
|
+
runStatus,
|
|
10
|
+
sendRunOutput,
|
|
11
|
+
startRun,
|
|
12
|
+
TERMINAL_NAME,
|
|
13
|
+
type RunCardData,
|
|
14
|
+
type RunSnapshot,
|
|
15
|
+
} from "../shared/contracts";
|
|
16
|
+
|
|
17
|
+
type Theme = PluginTimelineItemProps["theme"];
|
|
18
|
+
type Styles = ReturnType<typeof createStyles>;
|
|
19
|
+
|
|
20
|
+
const POLL_MS = 700;
|
|
21
|
+
const MONOSPACE = Platform.select({
|
|
22
|
+
ios: "Menlo",
|
|
23
|
+
android: "monospace",
|
|
24
|
+
default: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function isActive(run: RunSnapshot): boolean {
|
|
28
|
+
if (run.status === "queued" || run.status === "running" || run.sending) return true;
|
|
29
|
+
return run.status === "done" && run.sendOnFinish && !run.sent && !run.error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProps<RunCardData>) {
|
|
33
|
+
const { cardId, blocks } = item.data;
|
|
34
|
+
const keys = useMemo(() => blocks.map((_, index) => runKey(cardId, index)), [cardId, blocks]);
|
|
35
|
+
const styles = useMemo(() => createStyles(theme, layout.compact), [theme, layout.compact]);
|
|
36
|
+
const toast = useToast();
|
|
37
|
+
const queryClient = useQueryClient();
|
|
38
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
const fetchStatus = useRpc(runStatus);
|
|
41
|
+
const start = useRpc(startRun);
|
|
42
|
+
const cancel = useRpc(cancelRun);
|
|
43
|
+
const send = useRpc(sendRunOutput);
|
|
44
|
+
|
|
45
|
+
const queryKey = useMemo(() => ["terminal-run-status", cardId], [cardId]);
|
|
46
|
+
const status = useQuery({
|
|
47
|
+
queryKey,
|
|
48
|
+
queryFn: () => fetchStatus({ keys }),
|
|
49
|
+
refetchInterval: (query) => (query.state.data?.runs.some(isActive) ? POLL_MS : false),
|
|
50
|
+
});
|
|
51
|
+
const runs = useMemo(
|
|
52
|
+
() => new Map((status.data?.runs ?? []).map((run) => [run.key, run])),
|
|
53
|
+
[status.data],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
async function act(key: string, action: () => Promise<unknown>) {
|
|
57
|
+
setPending(key);
|
|
58
|
+
try {
|
|
59
|
+
await action();
|
|
60
|
+
await queryClient.invalidateQueries({ queryKey });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
toast.error(error instanceof Error ? error.message : String(error));
|
|
63
|
+
} finally {
|
|
64
|
+
setPending(null);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View style={styles.card}>
|
|
70
|
+
<View style={styles.header}>
|
|
71
|
+
<Icon name="SquareTerminal" size={16} color={theme.colors.foregroundMuted} />
|
|
72
|
+
<Text style={styles.title}>Run in terminal</Text>
|
|
73
|
+
<Text style={styles.muted} numberOfLines={1}>
|
|
74
|
+
· {TERMINAL_NAME}
|
|
75
|
+
</Text>
|
|
76
|
+
</View>
|
|
77
|
+
{blocks.map((block, index) => {
|
|
78
|
+
const key = keys[index];
|
|
79
|
+
const run = runs.get(key);
|
|
80
|
+
const active = run ? isActive(run) : false;
|
|
81
|
+
const busy = pending === key;
|
|
82
|
+
return (
|
|
83
|
+
<View key={key} style={index > 0 ? [styles.block, styles.separated] : styles.block}>
|
|
84
|
+
<View style={styles.codeRow}>
|
|
85
|
+
<Text style={styles.lang}>{block.lang}</Text>
|
|
86
|
+
<Text style={styles.code} numberOfLines={3}>
|
|
87
|
+
{block.code}
|
|
88
|
+
</Text>
|
|
89
|
+
</View>
|
|
90
|
+
<View style={styles.actions}>
|
|
91
|
+
{run && (run.status === "queued" || run.status === "running") ? (
|
|
92
|
+
<>
|
|
93
|
+
<ActionButton
|
|
94
|
+
styles={styles}
|
|
95
|
+
theme={theme}
|
|
96
|
+
icon="Square"
|
|
97
|
+
label="Stop"
|
|
98
|
+
tone="danger"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onPress={() => act(key, () => cancel({ key }))}
|
|
101
|
+
/>
|
|
102
|
+
{run.status === "running" && !run.sendOnFinish ? (
|
|
103
|
+
<ActionButton
|
|
104
|
+
styles={styles}
|
|
105
|
+
theme={theme}
|
|
106
|
+
icon="Send"
|
|
107
|
+
label="Send output so far"
|
|
108
|
+
disabled={busy || run.sending}
|
|
109
|
+
onPress={() => act(key, () => send({ key }))}
|
|
110
|
+
/>
|
|
111
|
+
) : null}
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<ActionButton
|
|
116
|
+
styles={styles}
|
|
117
|
+
theme={theme}
|
|
118
|
+
icon="Play"
|
|
119
|
+
label={run ? "Run again & send" : "Run & send to agent"}
|
|
120
|
+
tone="primary"
|
|
121
|
+
disabled={busy || active}
|
|
122
|
+
onPress={() =>
|
|
123
|
+
act(key, () => start({ agentId, key, lang: block.lang, code: block.code, send: true }))
|
|
124
|
+
}
|
|
125
|
+
/>
|
|
126
|
+
<ActionButton
|
|
127
|
+
styles={styles}
|
|
128
|
+
theme={theme}
|
|
129
|
+
icon="SquareTerminal"
|
|
130
|
+
label="Run only"
|
|
131
|
+
disabled={busy || active}
|
|
132
|
+
onPress={() =>
|
|
133
|
+
act(key, () => start({ agentId, key, lang: block.lang, code: block.code, send: false }))
|
|
134
|
+
}
|
|
135
|
+
/>
|
|
136
|
+
{run && !run.sent && !active ? (
|
|
137
|
+
<ActionButton
|
|
138
|
+
styles={styles}
|
|
139
|
+
theme={theme}
|
|
140
|
+
icon="Send"
|
|
141
|
+
label="Send output"
|
|
142
|
+
disabled={busy}
|
|
143
|
+
onPress={() => act(key, () => send({ key }))}
|
|
144
|
+
/>
|
|
145
|
+
) : null}
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
<ActionButton
|
|
149
|
+
styles={styles}
|
|
150
|
+
theme={theme}
|
|
151
|
+
icon="Copy"
|
|
152
|
+
label="Copy"
|
|
153
|
+
onPress={() =>
|
|
154
|
+
act(key, async () => {
|
|
155
|
+
await copyText(block.code);
|
|
156
|
+
toast.show("Command copied", { variant: "success" });
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
/>
|
|
160
|
+
</View>
|
|
161
|
+
{run ? <RunStatus run={run} styles={styles} /> : null}
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</View>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function RunStatus({ run, styles }: { run: RunSnapshot; styles: Styles }) {
|
|
170
|
+
const parts: string[] = [];
|
|
171
|
+
let tone: TextStyle = styles.muted;
|
|
172
|
+
if (run.status === "queued") parts.push("Queued, waiting for the terminal");
|
|
173
|
+
if (run.status === "running") parts.push(`Running · ${elapsed(run)}`);
|
|
174
|
+
if (run.status === "done") {
|
|
175
|
+
parts.push(`${run.exitCode === 0 ? "✓" : "✗"} exit ${run.exitCode}`);
|
|
176
|
+
tone = run.exitCode === 0 ? styles.success : styles.danger;
|
|
177
|
+
}
|
|
178
|
+
if (run.status === "canceled") parts.push("Stopped");
|
|
179
|
+
if (run.status === "failed") tone = styles.danger;
|
|
180
|
+
if (run.status !== "queued" && run.status !== "failed") {
|
|
181
|
+
parts.push(`${run.lineCount} ${run.lineCount === 1 ? "line" : "lines"}`);
|
|
182
|
+
}
|
|
183
|
+
if (run.sent) parts.push("sent to agent");
|
|
184
|
+
else if (run.sending) parts.push("sending when the agent is idle…");
|
|
185
|
+
else if (run.sendOnFinish && (run.status === "queued" || run.status === "running")) {
|
|
186
|
+
parts.push("output goes to the agent");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<View style={styles.status}>
|
|
191
|
+
{parts.length > 0 ? <Text style={tone}>{parts.join(" · ")}</Text> : null}
|
|
192
|
+
{run.error ? <Text style={styles.danger}>{run.error}</Text> : null}
|
|
193
|
+
{run.tail.length > 0 ? (
|
|
194
|
+
<View style={styles.tail}>
|
|
195
|
+
{run.tail.map((line, index) => (
|
|
196
|
+
<Text key={index} style={styles.tailLine} numberOfLines={1}>
|
|
197
|
+
{line || " "}
|
|
198
|
+
</Text>
|
|
199
|
+
))}
|
|
200
|
+
</View>
|
|
201
|
+
) : null}
|
|
202
|
+
</View>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ActionButton({
|
|
207
|
+
styles,
|
|
208
|
+
theme,
|
|
209
|
+
icon,
|
|
210
|
+
label,
|
|
211
|
+
tone = "default",
|
|
212
|
+
disabled = false,
|
|
213
|
+
onPress,
|
|
214
|
+
}: {
|
|
215
|
+
styles: Styles;
|
|
216
|
+
theme: Theme;
|
|
217
|
+
icon: string;
|
|
218
|
+
label: string;
|
|
219
|
+
tone?: "default" | "primary" | "danger";
|
|
220
|
+
disabled?: boolean;
|
|
221
|
+
onPress(): void;
|
|
222
|
+
}) {
|
|
223
|
+
const color =
|
|
224
|
+
tone === "primary"
|
|
225
|
+
? theme.colors.accentForeground
|
|
226
|
+
: tone === "danger"
|
|
227
|
+
? theme.colors.statusDanger
|
|
228
|
+
: theme.colors.foreground;
|
|
229
|
+
return (
|
|
230
|
+
<Pressable
|
|
231
|
+
accessibilityRole="button"
|
|
232
|
+
accessibilityLabel={label}
|
|
233
|
+
accessibilityState={{ disabled }}
|
|
234
|
+
disabled={disabled}
|
|
235
|
+
onPress={onPress}
|
|
236
|
+
style={({ pressed }) => [
|
|
237
|
+
styles.button,
|
|
238
|
+
tone === "primary" ? styles.primaryButton : null,
|
|
239
|
+
{ opacity: disabled ? 0.5 : pressed ? 0.75 : 1 },
|
|
240
|
+
]}
|
|
241
|
+
>
|
|
242
|
+
<Icon name={icon} size={14} color={color} />
|
|
243
|
+
<Text style={[styles.buttonText, { color }]}>{label}</Text>
|
|
244
|
+
</Pressable>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function elapsed(run: RunSnapshot): string {
|
|
249
|
+
const seconds = Math.max(0, Math.round((Date.now() - (run.startedAt ?? Date.now())) / 1000));
|
|
250
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createStyles(theme: Theme, compact: boolean) {
|
|
254
|
+
return {
|
|
255
|
+
card: {
|
|
256
|
+
gap: compact ? 8 : 10,
|
|
257
|
+
borderWidth: 1,
|
|
258
|
+
borderColor: theme.colors.border,
|
|
259
|
+
borderRadius: 10,
|
|
260
|
+
padding: compact ? 10 : 12,
|
|
261
|
+
backgroundColor: theme.colors.surface1,
|
|
262
|
+
},
|
|
263
|
+
header: { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 },
|
|
264
|
+
title: { color: theme.colors.foreground, fontWeight: "600" as const },
|
|
265
|
+
muted: { color: theme.colors.foregroundMuted, flexShrink: 1 },
|
|
266
|
+
success: { color: theme.colors.statusSuccess },
|
|
267
|
+
danger: { color: theme.colors.statusDanger },
|
|
268
|
+
block: { gap: 8 },
|
|
269
|
+
separated: {
|
|
270
|
+
borderTopWidth: 1,
|
|
271
|
+
borderTopColor: theme.colors.border,
|
|
272
|
+
paddingTop: compact ? 8 : 10,
|
|
273
|
+
},
|
|
274
|
+
codeRow: { flexDirection: "row" as const, gap: 8, alignItems: "flex-start" as const },
|
|
275
|
+
lang: {
|
|
276
|
+
color: theme.colors.foregroundMuted,
|
|
277
|
+
fontSize: 12,
|
|
278
|
+
paddingHorizontal: 6,
|
|
279
|
+
paddingVertical: 2,
|
|
280
|
+
borderRadius: 4,
|
|
281
|
+
backgroundColor: theme.colors.surface2,
|
|
282
|
+
overflow: "hidden" as const,
|
|
283
|
+
},
|
|
284
|
+
code: { color: theme.colors.foreground, fontFamily: MONOSPACE, fontSize: 13, flex: 1 },
|
|
285
|
+
actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 8 },
|
|
286
|
+
button: {
|
|
287
|
+
flexDirection: "row" as const,
|
|
288
|
+
alignItems: "center" as const,
|
|
289
|
+
gap: 6,
|
|
290
|
+
paddingHorizontal: 10,
|
|
291
|
+
paddingVertical: compact ? 7 : 5,
|
|
292
|
+
borderRadius: 6,
|
|
293
|
+
borderWidth: 1,
|
|
294
|
+
borderColor: theme.colors.border,
|
|
295
|
+
backgroundColor: theme.colors.surface2,
|
|
296
|
+
},
|
|
297
|
+
primaryButton: { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent },
|
|
298
|
+
buttonText: { fontSize: 13 },
|
|
299
|
+
status: { gap: 6 },
|
|
300
|
+
tail: {
|
|
301
|
+
padding: 8,
|
|
302
|
+
borderRadius: 6,
|
|
303
|
+
backgroundColor: theme.colors.surface0,
|
|
304
|
+
borderWidth: 1,
|
|
305
|
+
borderColor: theme.colors.border,
|
|
306
|
+
},
|
|
307
|
+
tailLine: { color: theme.colors.foregroundMuted, fontFamily: MONOSPACE, fontSize: 12 },
|
|
308
|
+
};
|
|
309
|
+
}
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { RunCard } from "./client/run-card";
|
|
3
|
+
import {
|
|
4
|
+
RUN_CARD_KIND,
|
|
5
|
+
RUN_CARD_VERSION,
|
|
6
|
+
runAdhoc,
|
|
7
|
+
runCardSchema,
|
|
8
|
+
scanBlocks,
|
|
9
|
+
terminalOutputSource,
|
|
10
|
+
} from "./shared/contracts";
|
|
11
|
+
|
|
12
|
+
export default function contribute(client: PluginClientContext) {
|
|
13
|
+
client.addTimelineRenderer({
|
|
14
|
+
kind: RUN_CARD_KIND,
|
|
15
|
+
version: RUN_CARD_VERSION,
|
|
16
|
+
schema: runCardSchema,
|
|
17
|
+
Component: RunCard,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
client.addAttachmentSource(terminalOutputSource);
|
|
21
|
+
|
|
22
|
+
client.addSlashCommand({
|
|
23
|
+
name: "run",
|
|
24
|
+
description: "Run a shell command in the workspace terminal and send its output to the agent",
|
|
25
|
+
argumentHint: "<command>",
|
|
26
|
+
context: "agent",
|
|
27
|
+
async onSubmit({ args, agent, rpc }) {
|
|
28
|
+
if (!args) throw new Error("Usage: /run <command>");
|
|
29
|
+
await rpc(runAdhoc, { agentId: agent.id, command: args });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function addRunButtons(agentId: string, rpc: PluginClientContext["rpc"]) {
|
|
34
|
+
const { count } = await rpc(scanBlocks, { agentId });
|
|
35
|
+
if (count === 0) throw new Error("The latest reply has no runnable code blocks");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
client.addSlashCommand({
|
|
39
|
+
name: "blocks",
|
|
40
|
+
description: "Add terminal run buttons for code blocks in the agent's latest reply",
|
|
41
|
+
argumentHint: "",
|
|
42
|
+
context: "agent",
|
|
43
|
+
onSubmit: ({ agent, rpc }) => addRunButtons(agent.id, rpc),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
client.addCommandCenterItem({
|
|
47
|
+
id: "scan-latest-reply",
|
|
48
|
+
title: "Terminal: add run buttons to the latest reply",
|
|
49
|
+
icon: "SquareTerminal",
|
|
50
|
+
keywords: ["run", "code block", "terminal", "execute"],
|
|
51
|
+
context: "agent",
|
|
52
|
+
onSelect: ({ agent, rpc }) => addRunButtons(agent.id, rpc),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return () => {};
|
|
56
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { searchTerminals } from "./server/attachments";
|
|
3
|
+
import { appendCardForTurn, appendRunCard, scanLatestReply } from "./server/cards";
|
|
4
|
+
import { cleanupScripts, TerminalRunner } from "./server/runner";
|
|
5
|
+
import {
|
|
6
|
+
cancelRun,
|
|
7
|
+
runAdhoc,
|
|
8
|
+
runKey,
|
|
9
|
+
runStatus,
|
|
10
|
+
scanBlocks,
|
|
11
|
+
searchTerminalOutput,
|
|
12
|
+
sendRunOutput,
|
|
13
|
+
startRun,
|
|
14
|
+
} from "./shared/contracts";
|
|
15
|
+
|
|
16
|
+
export default function contribute(server: PluginServerContext) {
|
|
17
|
+
let runner: TerminalRunner | null = null;
|
|
18
|
+
const runnerFor = (paseo: ConstructorParameters<typeof TerminalRunner>[0]) =>
|
|
19
|
+
(runner ??= new TerminalRunner(paseo));
|
|
20
|
+
|
|
21
|
+
server.on("agent.turn_ended", (event, { paseo }) => appendCardForTurn(paseo, event));
|
|
22
|
+
|
|
23
|
+
server.handle(startRun, (input, { paseo }) => runnerFor(paseo).start(input));
|
|
24
|
+
server.handle(runStatus, ({ keys }, { paseo }) => ({ runs: runnerFor(paseo).status(keys) }));
|
|
25
|
+
server.handle(cancelRun, ({ key }, { paseo }) => runnerFor(paseo).cancel(key));
|
|
26
|
+
server.handle(sendRunOutput, ({ key }, { paseo }) => runnerFor(paseo).sendOutput(key));
|
|
27
|
+
|
|
28
|
+
server.handle(runAdhoc, async ({ agentId, command }, { paseo }) => {
|
|
29
|
+
const block = { lang: "bash", code: command };
|
|
30
|
+
const cardId = await appendRunCard(paseo, agentId, [block]);
|
|
31
|
+
if (!cardId) throw new Error("Command is too long");
|
|
32
|
+
await runnerFor(paseo).start({ agentId, key: runKey(cardId, 0), ...block, send: true });
|
|
33
|
+
return { cardId };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
server.handle(scanBlocks, async ({ agentId }, { paseo }) => ({
|
|
37
|
+
count: await scanLatestReply(paseo, agentId),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
server.handle(searchTerminalOutput, searchTerminals);
|
|
41
|
+
|
|
42
|
+
return async () => {
|
|
43
|
+
runner?.stop();
|
|
44
|
+
await cleanupScripts();
|
|
45
|
+
};
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "paseo-terminal-integration",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"files": [
|
|
5
|
+
"paseo-plugin.json",
|
|
6
|
+
"index.client.tsx",
|
|
7
|
+
"index.server.ts",
|
|
8
|
+
"client/",
|
|
9
|
+
"server/",
|
|
10
|
+
"shared/"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@getpaseo/plugin": "0.10.1",
|
|
17
|
+
"@tanstack/react-query": "^5.90.11",
|
|
18
|
+
"@types/node": "^26.6.2",
|
|
19
|
+
"@types/react": "~19.2.0",
|
|
20
|
+
"react": "19.1.0",
|
|
21
|
+
"react-native": "0.81.5",
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"zod": "^4.4.3"
|
|
24
|
+
},
|
|
25
|
+
"description": "Run agent code blocks in a Paseo terminal and send the output back to the agent; attach terminal output to prompts",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Chechulin Serhii",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/keefeere/paseo-terminal-integration.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/keefeere/paseo-terminal-integration#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/keefeere/paseo-terminal-integration/issues"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"paseo",
|
|
38
|
+
"paseo-plugin",
|
|
39
|
+
"terminal",
|
|
40
|
+
"agents"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
3
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
4
|
+
import type { searchTerminalOutput } from "../shared/contracts";
|
|
5
|
+
import { fenced, trimBlankEdges, withoutRunMarkers } from "./text";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LINES = 200;
|
|
8
|
+
const MAX_LINES = 2_000;
|
|
9
|
+
const MAX_RESULTS = 20;
|
|
10
|
+
|
|
11
|
+
/** Lists every terminal on the daemon with a snapshot of its latest lines. */
|
|
12
|
+
export async function searchTerminals(
|
|
13
|
+
{ query }: RpcInput<typeof searchTerminalOutput>,
|
|
14
|
+
{ paseo }: PluginHandlerContext,
|
|
15
|
+
): Promise<RpcOutput<typeof searchTerminalOutput>> {
|
|
16
|
+
const { lineCount, filter } = parseQuery(query);
|
|
17
|
+
const [{ entries }, workspaces] = await Promise.all([
|
|
18
|
+
paseo.terminals.list(),
|
|
19
|
+
paseo.workspaces.list().catch(() => null),
|
|
20
|
+
]);
|
|
21
|
+
const workspaceNames = new Map(
|
|
22
|
+
(workspaces?.entries ?? []).map((workspace) => [workspace.id, workspace.name]),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const matches = entries
|
|
26
|
+
.filter((entry) => {
|
|
27
|
+
if (!filter) return true;
|
|
28
|
+
const haystack = `${entry.name} ${entry.cwd} ${workspaceNames.get(entry.workspaceId) ?? ""}`;
|
|
29
|
+
return haystack.toLowerCase().includes(filter);
|
|
30
|
+
})
|
|
31
|
+
.slice(0, MAX_RESULTS);
|
|
32
|
+
|
|
33
|
+
const items = await Promise.all(
|
|
34
|
+
matches.map(async (entry) => {
|
|
35
|
+
const capture = await paseo.terminals.ref(entry).capture({ start: -lineCount, stripAnsi: true });
|
|
36
|
+
const lines = trimBlankEdges(withoutRunMarkers(capture.lines));
|
|
37
|
+
const name = entry.name || "Terminal";
|
|
38
|
+
const workspace = workspaceNames.get(entry.workspaceId);
|
|
39
|
+
return {
|
|
40
|
+
id: `${entry.id}:${lineCount}`,
|
|
41
|
+
identifier: name,
|
|
42
|
+
title: `${name} · last ${lines.length} lines`,
|
|
43
|
+
subtitle: workspace ? `${workspace} · ${entry.cwd}` : entry.cwd,
|
|
44
|
+
url: pathToFileURL(entry.cwd).href,
|
|
45
|
+
resourceType: "terminal-output",
|
|
46
|
+
text: [
|
|
47
|
+
`Output of the Paseo terminal "${name}" (cwd \`${entry.cwd}\`), last ${lines.length} lines:`,
|
|
48
|
+
"",
|
|
49
|
+
fenced(lines.join("\n"), "text"),
|
|
50
|
+
].join("\n"),
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
return { items };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseQuery(query: string): { lineCount: number; filter: string } {
|
|
58
|
+
const number = /(?:^|\s)(\d{1,5})(?:\s|$)/.exec(query);
|
|
59
|
+
const lineCount = number ? Math.min(MAX_LINES, Math.max(1, Number(number[1]))) : DEFAULT_LINES;
|
|
60
|
+
const filter = (number ? query.replace(number[0], " ") : query).trim().toLowerCase();
|
|
61
|
+
return { lineCount, filter };
|
|
62
|
+
}
|
package/server/cards.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { PluginHandlerContext, PluginLifecycleEvents } from "@getpaseo/plugin/server";
|
|
3
|
+
import { runnableBlocks, type CodeBlock } from "../shared/code-blocks";
|
|
4
|
+
import { RUN_CARD_KIND, RUN_CARD_VERSION, type RunCardData } from "../shared/contracts";
|
|
5
|
+
|
|
6
|
+
type Paseo = PluginHandlerContext["paseo"];
|
|
7
|
+
type TimelineItem = PluginLifecycleEvents["agent.turn_ended"]["timeline"][number];
|
|
8
|
+
|
|
9
|
+
const MAX_CARD_BYTES = 60 * 1024;
|
|
10
|
+
const SCAN_LIMIT = 400;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Assistant text after the latest user message. Consecutive assistant items are stream chunks of
|
|
14
|
+
* one message; any other item between them separates messages.
|
|
15
|
+
*/
|
|
16
|
+
export function latestAssistantText(timeline: readonly TimelineItem[]): string {
|
|
17
|
+
let text = "";
|
|
18
|
+
let previous: TimelineItem["type"] | null = null;
|
|
19
|
+
for (const item of timeline) {
|
|
20
|
+
if (item.type === "user_message") {
|
|
21
|
+
text = "";
|
|
22
|
+
} else if (item.type === "assistant_message") {
|
|
23
|
+
if (previous !== "assistant_message" && text) text += "\n\n";
|
|
24
|
+
text += item.text;
|
|
25
|
+
}
|
|
26
|
+
previous = item.type;
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function appendRunCard(
|
|
32
|
+
paseo: Paseo,
|
|
33
|
+
agentId: string,
|
|
34
|
+
blocks: CodeBlock[],
|
|
35
|
+
): Promise<string | null> {
|
|
36
|
+
const fitting: CodeBlock[] = [];
|
|
37
|
+
for (const block of blocks) {
|
|
38
|
+
const data: RunCardData = { cardId: "", blocks: [...fitting, block] };
|
|
39
|
+
if (JSON.stringify(data).length > MAX_CARD_BYTES) break;
|
|
40
|
+
fitting.push(block);
|
|
41
|
+
}
|
|
42
|
+
if (fitting.length === 0) return null;
|
|
43
|
+
|
|
44
|
+
const cardId = randomUUID();
|
|
45
|
+
const data: RunCardData = { cardId, blocks: fitting };
|
|
46
|
+
await paseo.agents.ref(agentId).timeline.append({
|
|
47
|
+
type: "plugin",
|
|
48
|
+
id: cardId,
|
|
49
|
+
kind: RUN_CARD_KIND,
|
|
50
|
+
version: RUN_CARD_VERSION,
|
|
51
|
+
data,
|
|
52
|
+
});
|
|
53
|
+
return cardId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function appendCardForTurn(
|
|
57
|
+
paseo: Paseo,
|
|
58
|
+
event: PluginLifecycleEvents["agent.turn_ended"],
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
if (event.outcome.kind !== "completed") return;
|
|
61
|
+
const blocks = runnableBlocks(latestAssistantText(event.timeline));
|
|
62
|
+
if (blocks.length > 0) await appendRunCard(paseo, event.agent.id, blocks);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Re-scans the latest reply, for conversations that predate the plugin. */
|
|
66
|
+
export async function scanLatestReply(paseo: Paseo, agentId: string): Promise<number> {
|
|
67
|
+
const page = await paseo.agents
|
|
68
|
+
.ref(agentId)
|
|
69
|
+
.timeline.refetch({ direction: "tail", limit: SCAN_LIMIT, projection: "projected" });
|
|
70
|
+
if (page.error) throw new Error(page.error);
|
|
71
|
+
const blocks = runnableBlocks(latestAssistantText(page.entries.map((entry) => entry.item)));
|
|
72
|
+
if (blocks.length === 0) return 0;
|
|
73
|
+
await appendRunCard(paseo, agentId, blocks);
|
|
74
|
+
return blocks.length;
|
|
75
|
+
}
|
package/server/runner.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
6
|
+
import { interpreterFor, type Interpreter } from "../shared/code-blocks";
|
|
7
|
+
import { TERMINAL_NAME, type RunSnapshot } from "../shared/contracts";
|
|
8
|
+
import { fenced, trimBlankEdges } from "./text";
|
|
9
|
+
|
|
10
|
+
type Paseo = PluginHandlerContext["paseo"];
|
|
11
|
+
type TerminalHandle = ReturnType<Paseo["terminals"]["ref"]>;
|
|
12
|
+
|
|
13
|
+
const SCRIPT_DIR = path.join(tmpdir(), "paseo-terminal-integration");
|
|
14
|
+
const POLL_MS = 500;
|
|
15
|
+
const CAPTURE_WINDOW = 3_000;
|
|
16
|
+
const PROMPT_WAIT_MS = 10_000;
|
|
17
|
+
const PROMPT_SETTLE_POLLS = 4;
|
|
18
|
+
const START_TIMEOUT_MS = 20_000;
|
|
19
|
+
const CANCEL_GRACE_MS = 1_500;
|
|
20
|
+
const MAX_RUN_MS = 6 * 60 * 60 * 1000;
|
|
21
|
+
const AGENT_IDLE_WAIT_MS = 30 * 60 * 1000;
|
|
22
|
+
const TAIL_LINES = 6;
|
|
23
|
+
const REPORT_MAX_LINES = 300;
|
|
24
|
+
const REPORT_MAX_CHARS = 24_000;
|
|
25
|
+
const FINISHED_RUN_LIMIT = 200;
|
|
26
|
+
|
|
27
|
+
// sendKeys() expands only a few key names, so control characters are written as raw bytes.
|
|
28
|
+
const CTRL_C = "\x03";
|
|
29
|
+
const CTRL_U = "\x15";
|
|
30
|
+
|
|
31
|
+
const EXTENSIONS: Record<Interpreter, string> = {
|
|
32
|
+
bash: "sh",
|
|
33
|
+
zsh: "zsh",
|
|
34
|
+
fish: "fish",
|
|
35
|
+
python3: "py",
|
|
36
|
+
node: "mjs",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface Run {
|
|
40
|
+
id: string;
|
|
41
|
+
key: string;
|
|
42
|
+
agentId: string;
|
|
43
|
+
workspaceId: string;
|
|
44
|
+
cwd: string;
|
|
45
|
+
lang: string;
|
|
46
|
+
code: string;
|
|
47
|
+
interpreter: Interpreter;
|
|
48
|
+
status: RunSnapshot["status"];
|
|
49
|
+
exitCode: number | null;
|
|
50
|
+
startedAt: number | null;
|
|
51
|
+
finishedAt: number | null;
|
|
52
|
+
output: string[];
|
|
53
|
+
truncated: boolean;
|
|
54
|
+
sendOnFinish: boolean;
|
|
55
|
+
sending: boolean;
|
|
56
|
+
sent: boolean;
|
|
57
|
+
error: string | null;
|
|
58
|
+
cancelRequested: boolean;
|
|
59
|
+
terminal: TerminalHandle | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface StartRunInput {
|
|
63
|
+
agentId: string;
|
|
64
|
+
key: string;
|
|
65
|
+
lang: string;
|
|
66
|
+
code: string;
|
|
67
|
+
send: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Runs code blocks in a per-workspace Paseo terminal and reports output back to the agent. */
|
|
71
|
+
export class TerminalRunner {
|
|
72
|
+
private readonly runs = new Map<string, Run>();
|
|
73
|
+
private readonly lanes = new Map<string, Promise<void>>();
|
|
74
|
+
private stopped = false;
|
|
75
|
+
|
|
76
|
+
constructor(private readonly paseo: Paseo) {}
|
|
77
|
+
|
|
78
|
+
async start(input: StartRunInput): Promise<RunSnapshot> {
|
|
79
|
+
const active = this.runs.get(input.key);
|
|
80
|
+
if (active && (active.status === "queued" || active.status === "running")) {
|
|
81
|
+
return snapshot(active);
|
|
82
|
+
}
|
|
83
|
+
const interpreter = interpreterFor(input.lang);
|
|
84
|
+
if (!interpreter) throw new Error(`Cannot run ${input.lang || "unlabeled"} code blocks`);
|
|
85
|
+
|
|
86
|
+
const agent = this.paseo.agents.ref(input.agentId);
|
|
87
|
+
await agent.refresh();
|
|
88
|
+
if (!agent.workspaceId || !agent.cwd) throw new Error("This agent has no workspace to run in");
|
|
89
|
+
|
|
90
|
+
const run: Run = {
|
|
91
|
+
id: randomUUID().slice(0, 8),
|
|
92
|
+
key: input.key,
|
|
93
|
+
agentId: input.agentId,
|
|
94
|
+
workspaceId: agent.workspaceId,
|
|
95
|
+
cwd: agent.cwd,
|
|
96
|
+
lang: input.lang,
|
|
97
|
+
code: input.code,
|
|
98
|
+
interpreter,
|
|
99
|
+
status: "queued",
|
|
100
|
+
exitCode: null,
|
|
101
|
+
startedAt: null,
|
|
102
|
+
finishedAt: null,
|
|
103
|
+
output: [],
|
|
104
|
+
truncated: false,
|
|
105
|
+
sendOnFinish: input.send,
|
|
106
|
+
sending: false,
|
|
107
|
+
sent: false,
|
|
108
|
+
error: null,
|
|
109
|
+
cancelRequested: false,
|
|
110
|
+
terminal: null,
|
|
111
|
+
};
|
|
112
|
+
this.runs.delete(input.key);
|
|
113
|
+
this.runs.set(input.key, run);
|
|
114
|
+
this.pruneFinished();
|
|
115
|
+
|
|
116
|
+
const lane = (this.lanes.get(run.workspaceId) ?? Promise.resolve()).then(() => this.execute(run));
|
|
117
|
+
this.lanes.set(run.workspaceId, lane);
|
|
118
|
+
return snapshot(run);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
status(keys: readonly string[]): RunSnapshot[] {
|
|
122
|
+
return keys.flatMap((key) => {
|
|
123
|
+
const run = this.runs.get(key);
|
|
124
|
+
return run ? [snapshot(run)] : [];
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
cancel(key: string): RunSnapshot | null {
|
|
129
|
+
const run = this.runs.get(key);
|
|
130
|
+
if (!run) return null;
|
|
131
|
+
run.sendOnFinish = false;
|
|
132
|
+
if (run.status === "queued") {
|
|
133
|
+
run.cancelRequested = true;
|
|
134
|
+
this.finish(run, "canceled");
|
|
135
|
+
} else if (run.status === "running" && !run.cancelRequested) {
|
|
136
|
+
run.cancelRequested = true;
|
|
137
|
+
run.terminal?.write(CTRL_C);
|
|
138
|
+
}
|
|
139
|
+
return snapshot(run);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
sendOutput(key: string): RunSnapshot | null {
|
|
143
|
+
const run = this.runs.get(key);
|
|
144
|
+
if (!run) return null;
|
|
145
|
+
if (run.status === "queued") {
|
|
146
|
+
run.sendOnFinish = true;
|
|
147
|
+
} else {
|
|
148
|
+
void this.deliver(run);
|
|
149
|
+
}
|
|
150
|
+
return snapshot(run);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stop(): void {
|
|
154
|
+
this.stopped = true;
|
|
155
|
+
for (const run of this.runs.values()) {
|
|
156
|
+
if (run.status === "queued" || run.status === "running") {
|
|
157
|
+
run.error = "Plugin stopped before the command finished";
|
|
158
|
+
this.finish(run, "failed");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async execute(run: Run): Promise<void> {
|
|
164
|
+
if (run.status !== "queued" || this.stopped) return;
|
|
165
|
+
run.status = "running";
|
|
166
|
+
run.startedAt = Date.now();
|
|
167
|
+
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);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
run.error = error instanceof Error ? error.message : String(error);
|
|
175
|
+
this.finish(run, "failed");
|
|
176
|
+
}
|
|
177
|
+
// Delivery waits for the agent to go idle; keep it off the lane so queued runs proceed.
|
|
178
|
+
if (finishedCleanly(run) && run.sendOnFinish) void this.deliver(run);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async watch(run: Run, terminal: TerminalHandle): Promise<void> {
|
|
182
|
+
const begin = `__PTI_BEGIN_${run.id}__`;
|
|
183
|
+
const end = new RegExp(`__PTI_END_${run.id}_(\\d+)__`);
|
|
184
|
+
let sawBegin = false;
|
|
185
|
+
let canceledAt: number | null = null;
|
|
186
|
+
let polls = 0;
|
|
187
|
+
|
|
188
|
+
while (!this.stopped && run.startedAt !== null && Date.now() - run.startedAt < MAX_RUN_MS) {
|
|
189
|
+
await delay(POLL_MS);
|
|
190
|
+
polls++;
|
|
191
|
+
const { lines } = await terminal.capture({ start: -CAPTURE_WINDOW, stripAnsi: true });
|
|
192
|
+
if (lines.length === 0 && polls % 10 === 0 && !(await terminal.refresh())) {
|
|
193
|
+
run.error = `Terminal "${TERMINAL_NAME}" was closed`;
|
|
194
|
+
this.finish(run, "failed");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const beginIndex = lastIndexWhere(lines, (line) => line.includes(begin));
|
|
199
|
+
if (beginIndex >= 0) sawBegin = true;
|
|
200
|
+
if (!sawBegin && !run.cancelRequested && Date.now() - run.startedAt > START_TIMEOUT_MS) {
|
|
201
|
+
run.error = `The command did not start. Is something else running in the "${TERMINAL_NAME}" terminal?`;
|
|
202
|
+
this.finish(run, "failed");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const from = beginIndex >= 0 ? beginIndex + 1 : sawBegin ? 0 : lines.length;
|
|
206
|
+
run.truncated = sawBegin && beginIndex < 0;
|
|
207
|
+
const endIndex = lines.findIndex((line, index) => index >= from && end.test(line));
|
|
208
|
+
const output = lines.slice(from, endIndex >= 0 ? endIndex : lines.length);
|
|
209
|
+
run.output = withoutInterruptEcho(trimBlankEdges(output));
|
|
210
|
+
|
|
211
|
+
if (endIndex >= 0) {
|
|
212
|
+
run.exitCode = Number(end.exec(lines[endIndex])?.[1] ?? NaN);
|
|
213
|
+
this.finish(run, run.cancelRequested ? "canceled" : "done");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (run.cancelRequested) {
|
|
217
|
+
canceledAt ??= Date.now();
|
|
218
|
+
if (Date.now() - canceledAt > CANCEL_GRACE_MS) {
|
|
219
|
+
this.finish(run, "canceled");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (run.status === "running") {
|
|
225
|
+
run.error = "Stopped watching: the command ran longer than 6 hours";
|
|
226
|
+
this.finish(run, "failed");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async ensureTerminal(workspaceId: string, cwd: string): Promise<TerminalHandle> {
|
|
231
|
+
const { entries } = await this.paseo.terminals.list({ workspaceId });
|
|
232
|
+
const existing = entries.find((entry) => entry.name === TERMINAL_NAME);
|
|
233
|
+
if (existing) return this.paseo.terminals.ref(existing);
|
|
234
|
+
|
|
235
|
+
const terminal = await this.paseo.terminals.create({ workspaceId, cwd, name: TERMINAL_NAME });
|
|
236
|
+
// Input typed while the shell's rc files still run can be dropped; wait for output to settle.
|
|
237
|
+
const deadline = Date.now() + PROMPT_WAIT_MS;
|
|
238
|
+
let previous = "";
|
|
239
|
+
let stablePolls = 0;
|
|
240
|
+
while (Date.now() < deadline && stablePolls < PROMPT_SETTLE_POLLS) {
|
|
241
|
+
await delay(250);
|
|
242
|
+
const { lines } = await terminal.capture({ stripAnsi: true });
|
|
243
|
+
const current = lines.join("\n");
|
|
244
|
+
stablePolls = current.trim() && current === previous ? stablePolls + 1 : 0;
|
|
245
|
+
previous = current;
|
|
246
|
+
}
|
|
247
|
+
return terminal;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async deliver(run: Run): Promise<void> {
|
|
251
|
+
if (run.sending) return;
|
|
252
|
+
run.sending = true;
|
|
253
|
+
run.error = null;
|
|
254
|
+
try {
|
|
255
|
+
const agent = this.paseo.agents.ref(run.agentId);
|
|
256
|
+
await agent.refresh();
|
|
257
|
+
if (agent.status === "running" || agent.status === "initializing") {
|
|
258
|
+
await agent.waitForFinish(AGENT_IDLE_WAIT_MS);
|
|
259
|
+
}
|
|
260
|
+
await agent.send(formatReport(run));
|
|
261
|
+
run.sent = true;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
run.error = `Could not send output: ${error instanceof Error ? error.message : String(error)}`;
|
|
264
|
+
} finally {
|
|
265
|
+
run.sending = false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private finish(run: Run, status: RunSnapshot["status"]): void {
|
|
270
|
+
run.status = status;
|
|
271
|
+
run.finishedAt = Date.now();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private pruneFinished(): void {
|
|
275
|
+
const finished = [...this.runs.values()].filter(
|
|
276
|
+
(run) => run.status !== "queued" && run.status !== "running",
|
|
277
|
+
);
|
|
278
|
+
for (const run of finished.slice(0, Math.max(0, finished.length - FINISHED_RUN_LIMIT))) {
|
|
279
|
+
this.runs.delete(run.key);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Read through a call: TypeScript keeps `run.status` narrowed across the awaits in execute(). */
|
|
285
|
+
function finishedCleanly(run: Run): boolean {
|
|
286
|
+
return run.status === "done";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function snapshot(run: Run): RunSnapshot {
|
|
290
|
+
return {
|
|
291
|
+
runId: run.id,
|
|
292
|
+
key: run.key,
|
|
293
|
+
status: run.status,
|
|
294
|
+
exitCode: run.exitCode,
|
|
295
|
+
startedAt: run.startedAt,
|
|
296
|
+
finishedAt: run.finishedAt,
|
|
297
|
+
lineCount: run.output.length,
|
|
298
|
+
tail: run.output.slice(-TAIL_LINES),
|
|
299
|
+
sendOnFinish: run.sendOnFinish,
|
|
300
|
+
sending: run.sending,
|
|
301
|
+
sent: run.sent,
|
|
302
|
+
error: run.error,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The typed command only names a wrapper script, so the markers never appear in the shell's echo
|
|
308
|
+
* of the input line. The wrapper prints concealed begin/end markers around the command's output.
|
|
309
|
+
*/
|
|
310
|
+
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]}`);
|
|
313
|
+
const wrapper = path.join(SCRIPT_DIR, `${run.id}.run.sh`);
|
|
314
|
+
const shell = run.interpreter === "bash" || run.interpreter === "zsh" || run.interpreter === "fish";
|
|
315
|
+
const echo = shell
|
|
316
|
+
? `sed 's/^/$ /' -- ${shellQuote(codeFile)}`
|
|
317
|
+
: `printf '# %s\\n' ${shellQuote(run.interpreter)}; cat -- ${shellQuote(codeFile)}`;
|
|
318
|
+
|
|
319
|
+
await writeFile(codeFile, `${run.code}\n`, "utf8");
|
|
320
|
+
await writeFile(
|
|
321
|
+
wrapper,
|
|
322
|
+
[
|
|
323
|
+
// A trap handler (unlike an ignored signal) is reset in the child, so Ctrl-C still stops the
|
|
324
|
+
// command while this script survives to print the end marker.
|
|
325
|
+
`trap ':' INT`,
|
|
326
|
+
`cd -- ${shellQuote(run.cwd)} 2>/dev/null`,
|
|
327
|
+
`printf '\\033[2m'; ${echo}; printf '\\033[0m'`,
|
|
328
|
+
`printf '\\033[8m%s\\033[0m\\n' '__PTI_BEGIN_${run.id}__'`,
|
|
329
|
+
`${run.interpreter} ${shellQuote(codeFile)}`,
|
|
330
|
+
`__pti_code=$?`,
|
|
331
|
+
`printf '\\n\\033[8m%s\\033[0m\\033[2m[exit %s]\\033[0m\\n' "__PTI_END_${run.id}_\${__pti_code}__" "$__pti_code"`,
|
|
332
|
+
`rm -f -- ${shellQuote(codeFile)} "$0"`,
|
|
333
|
+
`exit $__pti_code`,
|
|
334
|
+
"",
|
|
335
|
+
].join("\n"),
|
|
336
|
+
"utf8",
|
|
337
|
+
);
|
|
338
|
+
return wrapper;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function cleanupScripts(): Promise<void> {
|
|
342
|
+
await rm(SCRIPT_DIR, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function formatReport(run: Run): string {
|
|
346
|
+
const outcome =
|
|
347
|
+
run.status === "done"
|
|
348
|
+
? `exit code ${run.exitCode}`
|
|
349
|
+
: run.status === "canceled"
|
|
350
|
+
? "stopped by the user before it finished"
|
|
351
|
+
: run.status === "failed"
|
|
352
|
+
? `failed: ${run.error ?? "unknown error"}`
|
|
353
|
+
: "still running; partial output";
|
|
354
|
+
|
|
355
|
+
let lines = run.output;
|
|
356
|
+
let note = run.truncated ? " (earlier lines scrolled out of the terminal)" : "";
|
|
357
|
+
if (lines.length > REPORT_MAX_LINES) {
|
|
358
|
+
note = ` (last ${REPORT_MAX_LINES} of ${lines.length} lines)`;
|
|
359
|
+
lines = lines.slice(-REPORT_MAX_LINES);
|
|
360
|
+
}
|
|
361
|
+
let output = lines.join("\n");
|
|
362
|
+
if (output.length > REPORT_MAX_CHARS) {
|
|
363
|
+
output = output.slice(-REPORT_MAX_CHARS);
|
|
364
|
+
note = ` (last ${REPORT_MAX_CHARS} characters)`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return [
|
|
368
|
+
`I ran this in the Paseo terminal "${TERMINAL_NAME}" (cwd \`${run.cwd}\`), ${outcome}:`,
|
|
369
|
+
"",
|
|
370
|
+
fenced(run.code, run.lang),
|
|
371
|
+
"",
|
|
372
|
+
output ? `Output${note}:` : "It printed no output.",
|
|
373
|
+
...(output ? ["", fenced(output, "text")] : []),
|
|
374
|
+
].join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function shellQuote(value: string): string {
|
|
378
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Drops the `^C` the terminal echoes when the command is interrupted. */
|
|
382
|
+
function withoutInterruptEcho(lines: string[]): string[] {
|
|
383
|
+
let end = lines.length;
|
|
384
|
+
while (end > 0 && /^\s*\^C\s*$/.test(lines[end - 1])) end--;
|
|
385
|
+
return trimBlankEdges(lines.slice(0, end));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function lastIndexWhere<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
|
389
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
390
|
+
if (predicate(items[index])) return index;
|
|
391
|
+
}
|
|
392
|
+
return -1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function delay(ms: number): Promise<void> {
|
|
396
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
397
|
+
}
|
package/server/text.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A Markdown fence long enough that no backtick run inside `content` can close it. */
|
|
2
|
+
export function fenced(content: string, lang = ""): string {
|
|
3
|
+
const longest = Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length));
|
|
4
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
5
|
+
return `${fence}${lang}\n${content}\n${fence}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Removes the run markers the runner prints around command output. */
|
|
9
|
+
export function withoutRunMarkers(lines: readonly string[]): string[] {
|
|
10
|
+
return lines
|
|
11
|
+
.filter((line) => !/^\s*__PTI_BEGIN_[0-9a-f]+__\s*$/.test(line))
|
|
12
|
+
.map((line) => line.replace(/__PTI_(?:BEGIN_[0-9a-f]+|END_[0-9a-f]+_\d+)__/g, ""));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function trimBlankEdges(lines: readonly string[]): string[] {
|
|
16
|
+
let start = 0;
|
|
17
|
+
let end = lines.length;
|
|
18
|
+
while (start < end && !lines[start].trim()) start++;
|
|
19
|
+
while (end > start && !lines[end - 1].trim()) end--;
|
|
20
|
+
return lines.slice(start, end).map((line) => line.replace(/\s+$/, ""));
|
|
21
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface CodeBlock {
|
|
2
|
+
lang: string;
|
|
3
|
+
code: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type Interpreter = "bash" | "zsh" | "fish" | "python3" | "node";
|
|
7
|
+
|
|
8
|
+
const INTERPRETERS: Record<string, Interpreter> = {
|
|
9
|
+
bash: "bash",
|
|
10
|
+
sh: "bash",
|
|
11
|
+
shell: "bash",
|
|
12
|
+
shellscript: "bash",
|
|
13
|
+
console: "bash",
|
|
14
|
+
"shell-session": "bash",
|
|
15
|
+
zsh: "zsh",
|
|
16
|
+
fish: "fish",
|
|
17
|
+
python: "python3",
|
|
18
|
+
python3: "python3",
|
|
19
|
+
py: "python3",
|
|
20
|
+
javascript: "node",
|
|
21
|
+
js: "node",
|
|
22
|
+
mjs: "node",
|
|
23
|
+
node: "node",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const PROMPT_LANGUAGES = new Set(["console", "shell-session"]);
|
|
27
|
+
const MAX_BLOCK_CHARS = 8_000;
|
|
28
|
+
const MAX_BLOCKS = 20;
|
|
29
|
+
|
|
30
|
+
export function interpreterFor(lang: string): Interpreter | null {
|
|
31
|
+
return INTERPRETERS[lang.toLowerCase()] ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fenced code blocks in Markdown, in order. An unclosed trailing fence is ignored. */
|
|
35
|
+
export function extractCodeBlocks(markdown: string): CodeBlock[] {
|
|
36
|
+
const blocks: CodeBlock[] = [];
|
|
37
|
+
let open: { indent: number; fence: string; lang: string; body: string[] } | null = null;
|
|
38
|
+
|
|
39
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
40
|
+
if (!open) {
|
|
41
|
+
const match = /^(\s*)(`{3,}|~{3,})(.*)$/.exec(line);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const [, indent, fence, info] = match;
|
|
44
|
+
if (fence.startsWith("`") && info.includes("`")) continue;
|
|
45
|
+
const lang = info.trim().split(/[\s{},]+/)[0] ?? "";
|
|
46
|
+
open = { indent: indent.length, fence, lang: lang.toLowerCase(), body: [] };
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(line);
|
|
50
|
+
if (close && close[1][0] === open.fence[0] && close[1].length >= open.fence.length) {
|
|
51
|
+
blocks.push({ lang: open.lang, code: open.body.join("\n") });
|
|
52
|
+
open = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
open.body.push(stripIndent(line, open.indent));
|
|
56
|
+
}
|
|
57
|
+
return blocks;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Code blocks a terminal can run, with shell-session prompts reduced to their commands. */
|
|
61
|
+
export function runnableBlocks(markdown: string): CodeBlock[] {
|
|
62
|
+
const blocks: CodeBlock[] = [];
|
|
63
|
+
for (const block of extractCodeBlocks(markdown)) {
|
|
64
|
+
if (!interpreterFor(block.lang)) continue;
|
|
65
|
+
const code = normalizeCommand(block).trim();
|
|
66
|
+
if (!code || code.length > MAX_BLOCK_CHARS) continue;
|
|
67
|
+
blocks.push({ lang: block.lang, code });
|
|
68
|
+
if (blocks.length === MAX_BLOCKS) break;
|
|
69
|
+
}
|
|
70
|
+
return blocks;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeCommand(block: CodeBlock): string {
|
|
74
|
+
const lines = block.code.split("\n");
|
|
75
|
+
const prompted = lines.filter((line) => /^\s*\$ /.test(line));
|
|
76
|
+
const allPrompted = prompted.length > 0 && prompted.length === lines.filter((l) => l.trim()).length;
|
|
77
|
+
if (!PROMPT_LANGUAGES.has(block.lang) && !allPrompted) return block.code;
|
|
78
|
+
if (prompted.length === 0) return block.code;
|
|
79
|
+
return prompted.map((line) => line.replace(/^\s*\$ /, "")).join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stripIndent(line: string, indent: number): string {
|
|
83
|
+
let index = 0;
|
|
84
|
+
while (index < indent && line[index] === " ") index++;
|
|
85
|
+
return line.slice(index);
|
|
86
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const TERMINAL_NAME = "Agent commands";
|
|
5
|
+
|
|
6
|
+
export const RUN_CARD_KIND = "terminal-run-card";
|
|
7
|
+
export const RUN_CARD_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export const runCardSchema = z.object({
|
|
10
|
+
cardId: z.string(),
|
|
11
|
+
blocks: z.array(z.object({ lang: z.string(), code: z.string() })),
|
|
12
|
+
});
|
|
13
|
+
export type RunCardData = z.output<typeof runCardSchema>;
|
|
14
|
+
|
|
15
|
+
export function runKey(cardId: string, blockIndex: number): string {
|
|
16
|
+
return `${cardId}:${blockIndex}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const runSnapshotSchema = z.object({
|
|
20
|
+
runId: z.string(),
|
|
21
|
+
key: z.string(),
|
|
22
|
+
status: z.enum(["queued", "running", "done", "canceled", "failed"]),
|
|
23
|
+
exitCode: z.number().int().nullable(),
|
|
24
|
+
startedAt: z.number().nullable(),
|
|
25
|
+
finishedAt: z.number().nullable(),
|
|
26
|
+
lineCount: z.number().int(),
|
|
27
|
+
tail: z.array(z.string()),
|
|
28
|
+
sendOnFinish: z.boolean(),
|
|
29
|
+
sending: z.boolean(),
|
|
30
|
+
sent: z.boolean(),
|
|
31
|
+
error: z.string().nullable(),
|
|
32
|
+
});
|
|
33
|
+
export type RunSnapshot = z.output<typeof runSnapshotSchema>;
|
|
34
|
+
|
|
35
|
+
export const startRun = defineRpc({
|
|
36
|
+
name: "run.start",
|
|
37
|
+
input: z.object({
|
|
38
|
+
agentId: z.string(),
|
|
39
|
+
key: z.string(),
|
|
40
|
+
lang: z.string(),
|
|
41
|
+
code: z.string().min(1),
|
|
42
|
+
send: z.boolean(),
|
|
43
|
+
}),
|
|
44
|
+
output: runSnapshotSchema,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const runStatus = defineRpc({
|
|
48
|
+
name: "run.status",
|
|
49
|
+
input: z.object({ keys: z.array(z.string()) }),
|
|
50
|
+
output: z.object({ runs: z.array(runSnapshotSchema) }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const cancelRun = defineRpc({
|
|
54
|
+
name: "run.cancel",
|
|
55
|
+
input: z.object({ key: z.string() }),
|
|
56
|
+
output: runSnapshotSchema.nullable(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const sendRunOutput = defineRpc({
|
|
60
|
+
name: "run.send",
|
|
61
|
+
input: z.object({ key: z.string() }),
|
|
62
|
+
output: runSnapshotSchema.nullable(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const runAdhoc = defineRpc({
|
|
66
|
+
name: "run.adhoc",
|
|
67
|
+
input: z.object({ agentId: z.string(), command: z.string().min(1) }),
|
|
68
|
+
output: z.object({ cardId: z.string() }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const scanBlocks = defineRpc({
|
|
72
|
+
name: "blocks.scan",
|
|
73
|
+
input: z.object({ agentId: z.string() }),
|
|
74
|
+
output: z.object({ count: z.number().int() }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const searchTerminalOutput = defineRpc({
|
|
78
|
+
name: "terminals.search",
|
|
79
|
+
input: z.object({ query: z.string() }),
|
|
80
|
+
output: z.object({
|
|
81
|
+
items: z.array(
|
|
82
|
+
z.object({
|
|
83
|
+
id: z.string(),
|
|
84
|
+
identifier: z.string(),
|
|
85
|
+
title: z.string(),
|
|
86
|
+
subtitle: z.string().optional(),
|
|
87
|
+
url: z.string().url(),
|
|
88
|
+
text: z.string(),
|
|
89
|
+
resourceType: z.string(),
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const terminalOutputSource = defineAttachmentSource({
|
|
96
|
+
id: "terminal-output",
|
|
97
|
+
title: "Terminal output",
|
|
98
|
+
icon: "SquareTerminal",
|
|
99
|
+
pickerTitle: "Attach terminal output",
|
|
100
|
+
searchPlaceholder: "Filter by name or path; add a number for line count (e.g. build 500)",
|
|
101
|
+
search: searchTerminalOutput,
|
|
102
|
+
});
|