mono-pilot 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 +87 -0
- package/dist/src/cli.js +85 -0
- package/dist/src/extensions/mono-pilot.js +19 -0
- package/dist/tools/README.md +31 -0
- package/dist/tools/apply-patch.js +337 -0
- package/dist/tools/apply-patch.md +93 -0
- package/dist/tools/delete.js +166 -0
- package/dist/tools/delete.md +5 -0
- package/dist/tools/glob.js +146 -0
- package/dist/tools/glob.md +18 -0
- package/dist/tools/read-file.js +156 -0
- package/dist/tools/read-file.md +23 -0
- package/dist/tools/rg.js +342 -0
- package/dist/tools/rg.md +35 -0
- package/dist/tools/shell.js +478 -0
- package/dist/tools/shell.md +152 -0
- package/package.json +50 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor-like Shell Extension
|
|
3
|
+
*
|
|
4
|
+
* Adds a `shell` tool that mirrors Cursor Shell semantics:
|
|
5
|
+
* - command + optional working_directory + block_until_ms + description
|
|
6
|
+
* - foreground execution when command completes before block timeout
|
|
7
|
+
* - automatic backgrounding when timeout elapses
|
|
8
|
+
* - per-command terminal files with live output
|
|
9
|
+
* - header fields including pid/running_for_seconds and completion footer
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* pi -e ./tools/shell.ts
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, getShellConfig, truncateTail, } from "@mariozechner/pi-coding-agent";
|
|
21
|
+
import { Type } from "@sinclair/typebox";
|
|
22
|
+
const SHELL_PROMPT_MARKER = "## Shell";
|
|
23
|
+
const SHELL_SPEC_PATH = fileURLToPath(new URL("./shell.md", import.meta.url));
|
|
24
|
+
const DEFAULT_BLOCK_UNTIL_MS = 30_000;
|
|
25
|
+
const UPDATE_RUNNING_SECONDS_EVERY_MS = 5_000;
|
|
26
|
+
const OUTPUT_CAPTURE_LIMIT_BYTES = DEFAULT_MAX_BYTES * 2;
|
|
27
|
+
const shellSchema = Type.Object({
|
|
28
|
+
command: Type.String({ description: "The command to execute" }),
|
|
29
|
+
working_directory: Type.Optional(Type.String({
|
|
30
|
+
description: "Optional working directory. Relative paths are resolved against the workspace root.",
|
|
31
|
+
})),
|
|
32
|
+
block_until_ms: Type.Optional(Type.Number({
|
|
33
|
+
description: "Maximum time (ms) to wait in foreground before backgrounding. 0 means immediate background execution.",
|
|
34
|
+
})),
|
|
35
|
+
description: Type.Optional(Type.String({
|
|
36
|
+
description: "Optional short description (5-10 words) describing the command intent.",
|
|
37
|
+
})),
|
|
38
|
+
});
|
|
39
|
+
function encodeWorkspacePath(workspace) {
|
|
40
|
+
return resolve(workspace)
|
|
41
|
+
.replace(/^[A-Za-z]:/, (match) => match[0])
|
|
42
|
+
.replace(/[\\/]/g, "-")
|
|
43
|
+
.replace(/^-+/, "");
|
|
44
|
+
}
|
|
45
|
+
function getTerminalsDir(workspaceCwd) {
|
|
46
|
+
const workspaceKey = encodeWorkspacePath(workspaceCwd);
|
|
47
|
+
const primary = join(resolve(workspaceCwd), ".pi", "terminals");
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(primary, { recursive: true });
|
|
50
|
+
return primary;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
const fallback = join(tmpdir(), "pi-shell", workspaceKey, "terminals");
|
|
54
|
+
mkdirSync(fallback, { recursive: true });
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function getNextTerminalId(terminalsDir) {
|
|
59
|
+
const entries = readdirSync(terminalsDir, { withFileTypes: true });
|
|
60
|
+
let maxId = 0;
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isFile())
|
|
63
|
+
continue;
|
|
64
|
+
const match = entry.name.match(/^(\d+)\.txt$/);
|
|
65
|
+
if (!match)
|
|
66
|
+
continue;
|
|
67
|
+
const id = Number.parseInt(match[1], 10);
|
|
68
|
+
if (Number.isInteger(id) && id > maxId)
|
|
69
|
+
maxId = id;
|
|
70
|
+
}
|
|
71
|
+
return maxId + 1;
|
|
72
|
+
}
|
|
73
|
+
function sanitizeOutputChunk(chunk) {
|
|
74
|
+
return chunk.toString("utf-8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
75
|
+
}
|
|
76
|
+
function parseEnvSnapshot(snapshot) {
|
|
77
|
+
const parsed = {};
|
|
78
|
+
const entries = snapshot.toString("utf-8").split("\0");
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.length === 0)
|
|
81
|
+
continue;
|
|
82
|
+
const separatorIndex = entry.indexOf("=");
|
|
83
|
+
if (separatorIndex <= 0)
|
|
84
|
+
continue;
|
|
85
|
+
const key = entry.slice(0, separatorIndex);
|
|
86
|
+
const value = entry.slice(separatorIndex + 1);
|
|
87
|
+
parsed[key] = value;
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
function updateRunningSecondsInHeader(session) {
|
|
92
|
+
const runningSeconds = Math.floor((Date.now() - session.startedAtMs) / 1000);
|
|
93
|
+
const runningLine = `running_for_seconds: ${String(runningSeconds).padStart(10, "0")}\n`;
|
|
94
|
+
try {
|
|
95
|
+
const fd = openSync(session.filePath, "r+");
|
|
96
|
+
try {
|
|
97
|
+
writeSync(fd, runningLine, session.runningSecondsOffset, "utf8");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Best effort; command execution should not fail due to metadata write failure.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function appendCompletionFooter(session, exitCode, elapsedMs, backgrounded) {
|
|
108
|
+
const finishedAt = new Date().toISOString();
|
|
109
|
+
const footer = [
|
|
110
|
+
"",
|
|
111
|
+
"---",
|
|
112
|
+
`exit_code: ${exitCode === null ? "null" : String(exitCode)}`,
|
|
113
|
+
`elapsed_ms: ${elapsedMs}`,
|
|
114
|
+
`backgrounded: ${String(backgrounded)}`,
|
|
115
|
+
`finished_at: ${finishedAt}`,
|
|
116
|
+
"---",
|
|
117
|
+
"",
|
|
118
|
+
].join("\n");
|
|
119
|
+
session.writeStream.write(footer);
|
|
120
|
+
}
|
|
121
|
+
function killProcessTree(pid) {
|
|
122
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
123
|
+
return;
|
|
124
|
+
if (process.platform === "win32") {
|
|
125
|
+
try {
|
|
126
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
127
|
+
stdio: "ignore",
|
|
128
|
+
detached: true,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Ignore best-effort kill errors.
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
process.kill(-pid, "SIGKILL");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(pid, "SIGKILL");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Process already gone.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function resolveWorkingDirectory(requested, workspaceCwd) {
|
|
149
|
+
if (!requested || requested.trim().length === 0) {
|
|
150
|
+
return workspaceCwd;
|
|
151
|
+
}
|
|
152
|
+
const resolved = isAbsolute(requested) ? requested : resolve(workspaceCwd, requested);
|
|
153
|
+
if (!existsSync(resolved)) {
|
|
154
|
+
throw new Error(`Working directory does not exist: ${resolved}`);
|
|
155
|
+
}
|
|
156
|
+
const stats = statSync(resolved);
|
|
157
|
+
if (!stats.isDirectory()) {
|
|
158
|
+
throw new Error(`Working directory is not a directory: ${resolved}`);
|
|
159
|
+
}
|
|
160
|
+
return resolved;
|
|
161
|
+
}
|
|
162
|
+
function normalizeBlockUntilMs(value) {
|
|
163
|
+
if (value === undefined)
|
|
164
|
+
return DEFAULT_BLOCK_UNTIL_MS;
|
|
165
|
+
if (!Number.isFinite(value) || Number.isNaN(value))
|
|
166
|
+
return DEFAULT_BLOCK_UNTIL_MS;
|
|
167
|
+
return Math.max(0, Math.floor(value));
|
|
168
|
+
}
|
|
169
|
+
function createTerminalSession(terminalsDir, command, cwd, runtimeState, activeSessions) {
|
|
170
|
+
const id = getNextTerminalId(terminalsDir);
|
|
171
|
+
const filePath = join(terminalsDir, `${id}.txt`);
|
|
172
|
+
const cwdSnapshotPath = join(terminalsDir, `${id}.state.cwd`);
|
|
173
|
+
const envSnapshotPath = join(terminalsDir, `${id}.state.env`);
|
|
174
|
+
const startedAtMs = Date.now();
|
|
175
|
+
const startedAtIso = new Date(startedAtMs).toISOString();
|
|
176
|
+
const { shell, args } = getShellConfig();
|
|
177
|
+
const wrappedCommand = 'cd "$1" || exit 1; eval "$2"; __pi_exit_code=$?; pwd > "$3"; env -0 > "$4"; exit $__pi_exit_code';
|
|
178
|
+
const child = spawn(shell, [...args, wrappedCommand, "--", cwd, command, cwdSnapshotPath, envSnapshotPath], {
|
|
179
|
+
cwd,
|
|
180
|
+
detached: true,
|
|
181
|
+
env: runtimeState.env,
|
|
182
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
183
|
+
});
|
|
184
|
+
if (!child.pid) {
|
|
185
|
+
throw new Error("Failed to start shell command: missing process pid.");
|
|
186
|
+
}
|
|
187
|
+
const pid = child.pid;
|
|
188
|
+
const headerPrefix = `---\npid: ${pid}\n`;
|
|
189
|
+
const runningLine = "running_for_seconds: 0000000000\n";
|
|
190
|
+
const headerSafeCommand = command.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
|
|
191
|
+
const headerSuffix = `cwd: ${cwd}\nlast_command: ${headerSafeCommand}\nstarted_at: ${startedAtIso}\n---\n`;
|
|
192
|
+
const header = `${headerPrefix}${runningLine}${headerSuffix}`;
|
|
193
|
+
const runningSecondsOffset = Buffer.byteLength(headerPrefix, "utf-8");
|
|
194
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
195
|
+
writeFileSync(filePath, header, "utf-8");
|
|
196
|
+
const writeStream = createWriteStream(filePath, { flags: "a" });
|
|
197
|
+
let resolveCompletion;
|
|
198
|
+
const completionPromise = new Promise((resolveCompletionFn) => {
|
|
199
|
+
resolveCompletion = resolveCompletionFn;
|
|
200
|
+
});
|
|
201
|
+
const session = {
|
|
202
|
+
id,
|
|
203
|
+
filePath,
|
|
204
|
+
cwd,
|
|
205
|
+
command,
|
|
206
|
+
pid,
|
|
207
|
+
startedAtMs,
|
|
208
|
+
runningSecondsOffset,
|
|
209
|
+
writeStream,
|
|
210
|
+
outputChunks: [],
|
|
211
|
+
outputChunkBytes: 0,
|
|
212
|
+
completionPromise,
|
|
213
|
+
resolveCompletion,
|
|
214
|
+
isFinished: false,
|
|
215
|
+
backgrounded: false,
|
|
216
|
+
};
|
|
217
|
+
const pushOutputChunk = (text) => {
|
|
218
|
+
if (text.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
session.writeStream.write(text);
|
|
221
|
+
session.outputChunks.push(text);
|
|
222
|
+
session.outputChunkBytes += Buffer.byteLength(text, "utf-8");
|
|
223
|
+
while (session.outputChunkBytes > OUTPUT_CAPTURE_LIMIT_BYTES && session.outputChunks.length > 1) {
|
|
224
|
+
const removed = session.outputChunks.shift() ?? "";
|
|
225
|
+
session.outputChunkBytes -= Buffer.byteLength(removed, "utf-8");
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const finalize = (exitCode) => {
|
|
229
|
+
if (session.isFinished)
|
|
230
|
+
return;
|
|
231
|
+
session.isFinished = true;
|
|
232
|
+
activeSessions.delete(session.filePath);
|
|
233
|
+
if (session.headerTimer)
|
|
234
|
+
clearInterval(session.headerTimer);
|
|
235
|
+
updateRunningSecondsInHeader(session);
|
|
236
|
+
try {
|
|
237
|
+
const nextCwd = readFileSync(cwdSnapshotPath, "utf-8").trim();
|
|
238
|
+
if (nextCwd.length > 0 && existsSync(nextCwd) && statSync(nextCwd).isDirectory()) {
|
|
239
|
+
runtimeState.cwd = nextCwd;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Keep previous cwd when no snapshot is available.
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const nextEnv = parseEnvSnapshot(readFileSync(envSnapshotPath));
|
|
247
|
+
if (Object.keys(nextEnv).length > 0) {
|
|
248
|
+
runtimeState.env = nextEnv;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Keep previous environment when no snapshot is available.
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
unlinkSync(cwdSnapshotPath);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Best effort cleanup.
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
unlinkSync(envSnapshotPath);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Best effort cleanup.
|
|
265
|
+
}
|
|
266
|
+
const elapsedMs = Date.now() - session.startedAtMs;
|
|
267
|
+
const output = session.outputChunks.join("");
|
|
268
|
+
const truncation = truncateTail(output, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
269
|
+
appendCompletionFooter(session, exitCode, elapsedMs, session.backgrounded);
|
|
270
|
+
session.writeStream.end(() => {
|
|
271
|
+
session.resolveCompletion({
|
|
272
|
+
exitCode,
|
|
273
|
+
elapsedMs,
|
|
274
|
+
output: truncation.content,
|
|
275
|
+
truncated: truncation.truncated,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
child.stdout?.on("data", (chunk) => {
|
|
280
|
+
pushOutputChunk(sanitizeOutputChunk(chunk));
|
|
281
|
+
});
|
|
282
|
+
child.stderr?.on("data", (chunk) => {
|
|
283
|
+
pushOutputChunk(sanitizeOutputChunk(chunk));
|
|
284
|
+
});
|
|
285
|
+
child.on("error", (error) => {
|
|
286
|
+
pushOutputChunk(`\n[spawn error] ${error.message}\n`);
|
|
287
|
+
finalize(null);
|
|
288
|
+
});
|
|
289
|
+
child.on("close", (code) => {
|
|
290
|
+
finalize(code);
|
|
291
|
+
});
|
|
292
|
+
updateRunningSecondsInHeader(session);
|
|
293
|
+
const timer = setInterval(() => updateRunningSecondsInHeader(session), UPDATE_RUNNING_SECONDS_EVERY_MS);
|
|
294
|
+
timer.unref?.();
|
|
295
|
+
session.headerTimer = timer;
|
|
296
|
+
activeSessions.set(session.filePath, session);
|
|
297
|
+
return session;
|
|
298
|
+
}
|
|
299
|
+
async function waitForeground(session, blockUntilMs, signal) {
|
|
300
|
+
if (blockUntilMs === 0) {
|
|
301
|
+
session.backgrounded = true;
|
|
302
|
+
return { completed: false, reason: "timeout" };
|
|
303
|
+
}
|
|
304
|
+
let timeoutId;
|
|
305
|
+
let abortHandler;
|
|
306
|
+
const timeoutPromise = new Promise((resolveTimeout) => {
|
|
307
|
+
timeoutId = setTimeout(() => resolveTimeout("timeout"), blockUntilMs);
|
|
308
|
+
});
|
|
309
|
+
const abortPromise = new Promise((resolveAbort) => {
|
|
310
|
+
if (!signal)
|
|
311
|
+
return;
|
|
312
|
+
abortHandler = () => resolveAbort("aborted");
|
|
313
|
+
if (signal.aborted) {
|
|
314
|
+
resolveAbort("aborted");
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
const result = await Promise.race([
|
|
321
|
+
session.completionPromise,
|
|
322
|
+
timeoutPromise,
|
|
323
|
+
abortPromise,
|
|
324
|
+
]);
|
|
325
|
+
if (timeoutId)
|
|
326
|
+
clearTimeout(timeoutId);
|
|
327
|
+
if (signal && abortHandler)
|
|
328
|
+
signal.removeEventListener("abort", abortHandler);
|
|
329
|
+
if (result === "timeout") {
|
|
330
|
+
session.backgrounded = true;
|
|
331
|
+
return { completed: false, reason: "timeout" };
|
|
332
|
+
}
|
|
333
|
+
if (result === "aborted") {
|
|
334
|
+
killProcessTree(session.pid);
|
|
335
|
+
const completion = await session.completionPromise;
|
|
336
|
+
return { completed: true, completion, reason: "aborted" };
|
|
337
|
+
}
|
|
338
|
+
return { completed: true, completion: result };
|
|
339
|
+
}
|
|
340
|
+
function formatForegroundOutput(completion, terminalFile) {
|
|
341
|
+
const lines = [];
|
|
342
|
+
const output = completion.output.trim();
|
|
343
|
+
if (output.length > 0) {
|
|
344
|
+
lines.push(output);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
lines.push("(no output)");
|
|
348
|
+
}
|
|
349
|
+
if (completion.truncated) {
|
|
350
|
+
lines.push("");
|
|
351
|
+
lines.push(`[Output truncated to ${formatSize(DEFAULT_MAX_BYTES)} / ${DEFAULT_MAX_LINES} lines. Full output: ${terminalFile}]`);
|
|
352
|
+
}
|
|
353
|
+
if (completion.exitCode !== 0 && completion.exitCode !== null) {
|
|
354
|
+
lines.push("");
|
|
355
|
+
lines.push(`Command exited with code ${completion.exitCode}`);
|
|
356
|
+
}
|
|
357
|
+
if (completion.exitCode === null) {
|
|
358
|
+
lines.push("");
|
|
359
|
+
lines.push("Command terminated before normal exit.");
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
text: lines.join("\n"),
|
|
363
|
+
details: {
|
|
364
|
+
backgrounded: false,
|
|
365
|
+
terminal_file: terminalFile,
|
|
366
|
+
exit_code: completion.exitCode,
|
|
367
|
+
elapsed_ms: completion.elapsedMs,
|
|
368
|
+
truncated: completion.truncated,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
export default function (pi) {
|
|
373
|
+
const activeSessions = new Map();
|
|
374
|
+
const runtimeStateByWorkspace = new Map();
|
|
375
|
+
const getRuntimeState = (workspaceCwd) => {
|
|
376
|
+
const workspaceKey = resolve(workspaceCwd);
|
|
377
|
+
const existing = runtimeStateByWorkspace.get(workspaceKey);
|
|
378
|
+
if (existing) {
|
|
379
|
+
try {
|
|
380
|
+
if (existsSync(existing.cwd) && statSync(existing.cwd).isDirectory()) {
|
|
381
|
+
return existing;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// Fall through to reset state.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const created = {
|
|
389
|
+
cwd: workspaceCwd,
|
|
390
|
+
env: { ...process.env },
|
|
391
|
+
};
|
|
392
|
+
runtimeStateByWorkspace.set(workspaceKey, created);
|
|
393
|
+
return created;
|
|
394
|
+
};
|
|
395
|
+
let shellSpecCache;
|
|
396
|
+
const getShellSpec = async () => {
|
|
397
|
+
if (shellSpecCache !== undefined)
|
|
398
|
+
return shellSpecCache ?? undefined;
|
|
399
|
+
try {
|
|
400
|
+
const content = await readFile(SHELL_SPEC_PATH, "utf-8");
|
|
401
|
+
shellSpecCache = content.trim();
|
|
402
|
+
return shellSpecCache;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
shellSpecCache = null;
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
pi.on("before_agent_start", async (event) => {
|
|
410
|
+
if (event.systemPrompt.includes(SHELL_PROMPT_MARKER))
|
|
411
|
+
return;
|
|
412
|
+
const shellSpec = await getShellSpec();
|
|
413
|
+
if (!shellSpec)
|
|
414
|
+
return;
|
|
415
|
+
return {
|
|
416
|
+
systemPrompt: `${event.systemPrompt}\n\n${SHELL_PROMPT_MARKER}\n\n${shellSpec}`,
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
pi.registerTool({
|
|
420
|
+
name: "shell",
|
|
421
|
+
label: "Shell",
|
|
422
|
+
description: "Execute a command in a shell session with optional working directory and foreground timeout. Long-running commands are moved to background and streamed into a terminal file.",
|
|
423
|
+
parameters: shellSchema,
|
|
424
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
425
|
+
const command = params.command?.trim();
|
|
426
|
+
if (!command) {
|
|
427
|
+
return {
|
|
428
|
+
content: [{ type: "text", text: "Command is required." }],
|
|
429
|
+
details: { error: "missing_command" },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const runtimeState = getRuntimeState(ctx.cwd);
|
|
433
|
+
const defaultWorkingDirectory = resolveWorkingDirectory(runtimeState.cwd, ctx.cwd);
|
|
434
|
+
const workingDirectory = resolveWorkingDirectory(params.working_directory ?? defaultWorkingDirectory, ctx.cwd);
|
|
435
|
+
const blockUntilMs = normalizeBlockUntilMs(params.block_until_ms);
|
|
436
|
+
const terminalsDir = getTerminalsDir(ctx.cwd);
|
|
437
|
+
const session = createTerminalSession(terminalsDir, command, workingDirectory, runtimeState, activeSessions);
|
|
438
|
+
const waitResult = await waitForeground(session, blockUntilMs, signal);
|
|
439
|
+
if (!waitResult.completed || !waitResult.completion) {
|
|
440
|
+
session.backgrounded = true;
|
|
441
|
+
return {
|
|
442
|
+
content: [
|
|
443
|
+
{
|
|
444
|
+
type: "text",
|
|
445
|
+
text: `Command moved to background.\n` +
|
|
446
|
+
`terminal_file: ${session.filePath}\n` +
|
|
447
|
+
`pid: ${session.pid}\n` +
|
|
448
|
+
`Use read on the terminal file to monitor progress.`,
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
details: {
|
|
452
|
+
backgrounded: true,
|
|
453
|
+
reason: waitResult.reason ?? "timeout",
|
|
454
|
+
terminal_file: session.filePath,
|
|
455
|
+
terminal_id: session.id,
|
|
456
|
+
pid: session.pid,
|
|
457
|
+
active_terminal_files: Array.from(activeSessions.keys()),
|
|
458
|
+
working_directory: workingDirectory,
|
|
459
|
+
block_until_ms: blockUntilMs,
|
|
460
|
+
description: params.description,
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const formatted = formatForegroundOutput(waitResult.completion, session.filePath);
|
|
465
|
+
return {
|
|
466
|
+
content: [{ type: "text", text: formatted.text }],
|
|
467
|
+
details: {
|
|
468
|
+
...formatted.details,
|
|
469
|
+
terminal_id: session.id,
|
|
470
|
+
pid: session.pid,
|
|
471
|
+
working_directory: workingDirectory,
|
|
472
|
+
block_until_ms: blockUntilMs,
|
|
473
|
+
description: params.description,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Executes a given command in a shell session with optional foreground timeout.
|
|
2
|
+
//
|
|
3
|
+
// IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
|
4
|
+
//
|
|
5
|
+
// Before executing the command, please follow these steps:
|
|
6
|
+
//
|
|
7
|
+
// 1. Check for Running Processes:
|
|
8
|
+
// - Before starting dev servers or long-running processes, list the terminals folder to check if they are already running in existing terminals.
|
|
9
|
+
// - You can use this information to determine which terminal, if any, matches the command you want to run, contains the output from the command you want to inspect, or has changed since you last read them.
|
|
10
|
+
// - Since these are text files, you can read any terminal's contents simply by reading the file, search using the grep tool, etc.
|
|
11
|
+
// 2. Directory Verification:
|
|
12
|
+
// - If the command will create new directories or files, first run ls to verify the parent directory exists and is the correct location
|
|
13
|
+
// - For example, before running "mkdir foo/bar", first run 'ls' to check that "foo" exists and is the intended parent directory
|
|
14
|
+
// 3. Command Execution:
|
|
15
|
+
// - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
|
16
|
+
// - Examples of proper quoting:
|
|
17
|
+
// - cd "/Users/name/My Documents" (correct)
|
|
18
|
+
// - cd /Users/name/My Documents (incorrect - will fail)
|
|
19
|
+
// - python "/path/with spaces/script.py" (correct)
|
|
20
|
+
// - python /path/with spaces/script.py (incorrect - will fail)
|
|
21
|
+
// - After ensuring proper quoting, execute the command.
|
|
22
|
+
// - Capture the output of the command.
|
|
23
|
+
//
|
|
24
|
+
// Usage notes:
|
|
25
|
+
//
|
|
26
|
+
// - The command argument is required.
|
|
27
|
+
// - The shell starts in the workspace root and is stateful across sequential calls. Current working directory and environment variables persist between calls. Use the `working_directory` parameter to run commands in different directories. Example: to run `npm install` in the `frontend` folder, set `working_directory: "frontend"` rather than using `cd frontend && npm install`.
|
|
28
|
+
// - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
|
29
|
+
// - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`.Instead use rg, Glob to search.You MUST avoid read tools like `cat`, `head`, and `tail`, and use ReadFile to read files.
|
|
30
|
+
// - If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` first, which all users have pre-installed.
|
|
31
|
+
// - When issuing multiple commands:
|
|
32
|
+
// - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
|
33
|
+
// - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, or git add before git commit), run these operations sequentially instead.
|
|
34
|
+
// - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
|
35
|
+
// - DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
|
36
|
+
//
|
|
37
|
+
// Dependencies:
|
|
38
|
+
//
|
|
39
|
+
// When adding new dependencies, prefer using the package manager (e.g. npm, pip) to add the latest version. Do not make up dependency versions.
|
|
40
|
+
//
|
|
41
|
+
// <managing-long-running-commands>
|
|
42
|
+
// - Commands that don't complete within `block_until_ms` (default 30s) are moved to background. The command keeps running and output streams to a terminal file. Set `block_until_ms: 0` to immediately background (use for dev servers, watchers, or any long-running process).
|
|
43
|
+
// - You do not need to use '&' at the end of commands.
|
|
44
|
+
// - Make sure to set `block_until_ms` to higher than the command's expected runtime. Add some buffer since block_until_ms includes shell startup time; increase buffer next time based on `elapsed_ms` if you chose too low. E.g. if you sleep for 40s, recommended `block_until_ms` is 45s.
|
|
45
|
+
// - Monitoring backgrounded commands:
|
|
46
|
+
// - When command moves to background, check status immediately by reading the terminal file.
|
|
47
|
+
// - Header has `pid` and `running_for_seconds` (updated every 5s)
|
|
48
|
+
// - When finished, footer with `exit_code` and `elapsed_ms` appears.
|
|
49
|
+
// - Poll repeatedly to monitor by sleeping between checks. If the file gets large, read from the end of the file to capture the latest content.
|
|
50
|
+
// - Pick your sleep intervals using best guess/judgment based on any knowledge you have about the command and its expected runtime, and any output from monitoring the command. When no new output, exponential backoff is a good strategy (e.g. sleep 2s, 4s, 8s, 16s...), using educated guess for min and max wait.
|
|
51
|
+
// - If it's longer than expected and the command seems like it is hung, kill the process if safe to do so using the pid that appears in the header. If possible, try to fix the hang and proceed.
|
|
52
|
+
// - Don't stop polling until: (a) `exit_code` footer appears (terminating command), (b) the command reaches a healthy steady state (only for non-terminating command, e.g. dev server/watcher), or (c) command is hung - follow guidance above.
|
|
53
|
+
// </managing-long-running-commands>
|
|
54
|
+
//
|
|
55
|
+
// <committing-changes-with-git>
|
|
56
|
+
// Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
|
|
57
|
+
//
|
|
58
|
+
// Git Safety Protocol:
|
|
59
|
+
//
|
|
60
|
+
// - NEVER update the git config
|
|
61
|
+
// - NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
|
|
62
|
+
// - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
|
63
|
+
// - NEVER run force push to main/master, warn the user if they request it
|
|
64
|
+
// - Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
|
|
65
|
+
// 1. User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including
|
|
66
|
+
// 2. HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')
|
|
67
|
+
// 3. Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead")
|
|
68
|
+
// - CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit
|
|
69
|
+
// - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)
|
|
70
|
+
// - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
|
71
|
+
//
|
|
72
|
+
// 1. You can call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following shell commands in parallel, each using the Shell tool:
|
|
73
|
+
// - Run a git status command to see all untracked files.
|
|
74
|
+
// - Run a git diff command to see both staged and unstaged changes that will be committed.
|
|
75
|
+
// - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
|
76
|
+
// 2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
|
|
77
|
+
// - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
|
|
78
|
+
// - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
|
|
79
|
+
// - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
|
80
|
+
// - Ensure it accurately reflects the changes and their purpose
|
|
81
|
+
// 3. Run the following commands sequentially:
|
|
82
|
+
// - Add relevant untracked files to the staging area.
|
|
83
|
+
// - Commit the changes with the message.
|
|
84
|
+
// - Run git status after the commit completes to verify success.
|
|
85
|
+
// 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)
|
|
86
|
+
//
|
|
87
|
+
// Important notes:
|
|
88
|
+
//
|
|
89
|
+
// - NEVER update the git config
|
|
90
|
+
// - NEVER run additional commands to read or explore code, besides git shell commands
|
|
91
|
+
// - DO NOT push to the remote repository unless the user explicitly asks you to do so
|
|
92
|
+
// - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
|
93
|
+
// - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
|
94
|
+
// - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
|
95
|
+
//
|
|
96
|
+
// <example>git commit -m "$(cat <<'EOF'
|
|
97
|
+
// Commit message here.
|
|
98
|
+
//
|
|
99
|
+
// EOF
|
|
100
|
+
// )"</example>
|
|
101
|
+
// </committing-changes-with-git>
|
|
102
|
+
//
|
|
103
|
+
// <creating-pull-requests>
|
|
104
|
+
// Use the gh command via the Shell tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
|
105
|
+
//
|
|
106
|
+
// IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
|
107
|
+
//
|
|
108
|
+
// 1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following shell commands in parallel using the Shell tool, in order to understand the current state of the branch since it diverged from the main branch:
|
|
109
|
+
// - Run a git status command to see all untracked files
|
|
110
|
+
// - Run a git diff command to see both staged and unstaged changes that will be committed
|
|
111
|
+
// - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
|
112
|
+
// - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)
|
|
113
|
+
// 2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary
|
|
114
|
+
// 3. Run the following commands sequentially:
|
|
115
|
+
// - Create new branch if needed
|
|
116
|
+
// - Push to remote with -u flag if needed
|
|
117
|
+
// - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
|
118
|
+
//
|
|
119
|
+
// <example># First, push the branch (with required_permissions: ["all"])
|
|
120
|
+
// git push -u origin HEAD
|
|
121
|
+
//
|
|
122
|
+
// # Then create the PR (with required_permissions: ["all"])
|
|
123
|
+
// gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
|
124
|
+
// ## Summary
|
|
125
|
+
// <1-3 bullet points>
|
|
126
|
+
//
|
|
127
|
+
// ## Test plan
|
|
128
|
+
// [Checklist of TODOs for testing the pull request...]
|
|
129
|
+
//
|
|
130
|
+
// EOF
|
|
131
|
+
// )"</example>
|
|
132
|
+
//
|
|
133
|
+
// Important:
|
|
134
|
+
//
|
|
135
|
+
// - NEVER update the git config
|
|
136
|
+
// - DO NOT use the TodoWrite or Task tools
|
|
137
|
+
// - Return the PR URL when you're done, so the user can see it
|
|
138
|
+
// </creating-pull-requests>
|
|
139
|
+
//
|
|
140
|
+
// <other-common-operations>
|
|
141
|
+
// - View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
|
|
142
|
+
// </other-common-operations>
|
|
143
|
+
type Shell = (_: {
|
|
144
|
+
// The command to execute
|
|
145
|
+
command: string,
|
|
146
|
+
// The absolute path to the working directory to execute the command in (defaults to current directory)
|
|
147
|
+
working_directory?: string,
|
|
148
|
+
// How long to block and wait for the command to complete before moving it to background (in milliseconds). Defaults to 30000ms (30 seconds). Set to 0 to immediately run the command in the background. The timer includes the shell startup time.
|
|
149
|
+
block_until_ms?: number,
|
|
150
|
+
// Clear, concise description of what this command does in 5-10 words
|
|
151
|
+
description?: string,
|
|
152
|
+
}) => any;
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mono-pilot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cursor-compatible coding agent powered by pi-mono",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mono-pilot": "dist/src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc && mkdir -p dist/tools && cp tools/*.md dist/tools/ && chmod +x dist/src/cli.js",
|
|
17
|
+
"check": "tsc --noEmit",
|
|
18
|
+
"start": "node dist/src/cli.js",
|
|
19
|
+
"prepack": "npm run check && npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mono-pilot",
|
|
23
|
+
"pi-mono",
|
|
24
|
+
"coding-agent",
|
|
25
|
+
"cursor-compatible"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/qianwan/mono-pilot.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/qianwan/mono-pilot/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/qianwan/mono-pilot#readme",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@mariozechner/pi-agent-core": "^0.53.0",
|
|
40
|
+
"@mariozechner/pi-ai": "^0.53.0",
|
|
41
|
+
"@mariozechner/pi-coding-agent": "^0.53.0",
|
|
42
|
+
"@mariozechner/pi-tui": "^0.53.0",
|
|
43
|
+
"@sinclair/typebox": "^0.34.13",
|
|
44
|
+
"glob": "^13.0.1"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"typescript": "^5.7.3"
|
|
49
|
+
}
|
|
50
|
+
}
|