specpi 0.31.0 → 0.33.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/CHANGELOG.md +23 -0
- package/NPM_RELEASE.md +1 -1
- package/README.md +5 -4
- package/SECURITY_MODEL.md +14 -2
- package/THIRD_PARTY.md +1 -1
- package/extensions/tool-wishlist/authoring-tools.mjs +39 -9
- package/extensions/tool-wishlist/index.ts +25 -7
- package/extensions/workflow-controls/background.mjs +495 -0
- package/extensions/workflow-controls/capabilities.mjs +17 -0
- package/extensions/workflow-controls/index.ts +220 -4
- package/package.json +2 -1
- package/scripts/permission-shell-tools.mjs +146 -0
- package/scripts/specpi.mjs +31 -0
- package/skills/specpi-improve/SKILL.md +1 -0
- package/templates/AGENTS.md +3 -6
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// Background jobs: start a long command, keep talking, hear back when it ends.
|
|
2
|
+
//
|
|
3
|
+
// A long eval or build run through `bash` holds the whole conversation until it returns; the person
|
|
4
|
+
// at the keyboard cannot say anything to the agent in the meantime. A background job returns at once
|
|
5
|
+
// and reports its exit code and last lines of output as a follow-up message when it finishes.
|
|
6
|
+
//
|
|
7
|
+
// It is deliberately small, and it never widens what may run:
|
|
8
|
+
//
|
|
9
|
+
// - One tool, and it only starts jobs. Its `command` is registered with the permission system as a
|
|
10
|
+
// shell argument (`shellTools`), so every `bash` rule applies to it at full parity. A job may not
|
|
11
|
+
// start unless that mapping is in effect.
|
|
12
|
+
// - The Jev command guard gates `bash` alone. While it is on, background jobs are off, so a guard
|
|
13
|
+
// user never loses coverage. Only its saved state is visible to another extension; see
|
|
14
|
+
// guardEnabled.
|
|
15
|
+
// - Interactive sessions only. A headless run has nobody to report back to.
|
|
16
|
+
// - Jobs belong to the session: they are stopped, and their logs deleted, when it ends.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
export const BACKGROUND_TOOL = "background";
|
|
24
|
+
export const BACKGROUND_MESSAGE = "specpi-background";
|
|
25
|
+
export const BACKGROUND_STATUS = "specpi-background";
|
|
26
|
+
|
|
27
|
+
/** The `shellTools` entry that makes the permission system gate `command` exactly like `bash`. */
|
|
28
|
+
export const SHELL_TOOL_MAPPING = Object.freeze({ commandArgument: "command" });
|
|
29
|
+
|
|
30
|
+
export const MAX_RUNNING_JOBS = 4;
|
|
31
|
+
export const MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
32
|
+
const TAIL_BUFFER_BYTES = 64 * 1024;
|
|
33
|
+
const TAIL_LINES = 40;
|
|
34
|
+
const TAIL_CHARS = 4000;
|
|
35
|
+
const COMMAND_PREVIEW_CHARS = 200;
|
|
36
|
+
const STALE_LOG_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
export function agentDirectory() {
|
|
39
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
40
|
+
|
|
41
|
+
return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function logRoot(agentDir = agentDirectory()) {
|
|
45
|
+
return path.join(agentDir, "specpi", "background");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A fresh log directory for one session's jobs. */
|
|
49
|
+
export function sessionLogDir(agentDir = agentDirectory()) {
|
|
50
|
+
return path.join(logRoot(agentDir), `${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Remove log directories a crashed session left behind. A session deletes its own on exit, so
|
|
55
|
+
* anything older than a day belongs to a process that never got the chance.
|
|
56
|
+
*/
|
|
57
|
+
export function pruneStaleLogs({ root = logRoot(), maxAgeMs = STALE_LOG_MS, now = Date.now() } = {}) {
|
|
58
|
+
let removed = 0;
|
|
59
|
+
let entries = [];
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return removed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (!entry.isDirectory() || !/^\d+-[0-9a-f]{8}$/u.test(entry.name)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dir = path.join(root, entry.name);
|
|
72
|
+
try {
|
|
73
|
+
if (now - fs.statSync(dir).mtimeMs > maxAgeMs) {
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
removed += 1;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Held open or already gone; the next session tries again.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return removed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------------------------
|
|
86
|
+
// Admission
|
|
87
|
+
// ---------------------------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse JSON that may carry `//` and `/* *\/` comments, as the permission system's config does.
|
|
91
|
+
* Comments inside strings are left alone. Returns undefined for a missing file and throws for an
|
|
92
|
+
* unreadable one, so a caller can tell "nothing configured" from "cannot verify".
|
|
93
|
+
*/
|
|
94
|
+
export function readJsonc(file) {
|
|
95
|
+
if (!fs.existsSync(file)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const text = fs.readFileSync(file, "utf8");
|
|
100
|
+
let out = "";
|
|
101
|
+
let inString = false;
|
|
102
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
103
|
+
const char = text[index];
|
|
104
|
+
const next = text[index + 1];
|
|
105
|
+
if (inString) {
|
|
106
|
+
out += char;
|
|
107
|
+
if (char === "\\") {
|
|
108
|
+
out += next ?? "";
|
|
109
|
+
index += 1;
|
|
110
|
+
} else if (char === '"') {
|
|
111
|
+
inString = false;
|
|
112
|
+
}
|
|
113
|
+
} else if (char === '"') {
|
|
114
|
+
inString = true;
|
|
115
|
+
out += char;
|
|
116
|
+
} else if (char === "/" && next === "/") {
|
|
117
|
+
while (index < text.length && text[index] !== "\n") {
|
|
118
|
+
index += 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out += "\n";
|
|
122
|
+
} else if (char === "/" && next === "*") {
|
|
123
|
+
const end = text.indexOf("*/", index + 2);
|
|
124
|
+
index = end === -1 ? text.length : end + 1;
|
|
125
|
+
} else {
|
|
126
|
+
out += char;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const value = JSON.parse(out);
|
|
131
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
132
|
+
throw new Error(`${file} is not a JSON object`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The guard's saved state, resolved the way the guard resolves it: its own default is on, the
|
|
140
|
+
* global file overrides that, and a trusted project's file overrides the global one.
|
|
141
|
+
*
|
|
142
|
+
* The guard also keeps a session-only switch in memory, and no other extension can read it. So a
|
|
143
|
+
* guard switched on with `/jev-guard on` (without `--global`) is invisible here; that is disclosed
|
|
144
|
+
* rather than guessed at. Saved state is what `/jev-guard on --global`, `setup` and SpecPi's own
|
|
145
|
+
* installer write.
|
|
146
|
+
*/
|
|
147
|
+
export function guardEnabled({ home = os.homedir(), cwd, trusted = false } = {}) {
|
|
148
|
+
let enabled = true;
|
|
149
|
+
let source = "default";
|
|
150
|
+
const files = [["global", path.join(home, ".pi", "jev-guard.json")]];
|
|
151
|
+
if (trusted && cwd) {
|
|
152
|
+
files.push(["project", path.join(cwd, ".pi", "jev-guard.json")]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [scope, file] of files) {
|
|
156
|
+
let value;
|
|
157
|
+
try {
|
|
158
|
+
// Parsed exactly as the guard parses it: a byte-order mark stripped, then strict JSON. A
|
|
159
|
+
// file it cannot parse is absent to it, so its default -- on -- applies; reading one with
|
|
160
|
+
// comments more leniently here would see "off" where the guard sees "on".
|
|
161
|
+
value = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/u, "")) : undefined;
|
|
162
|
+
} catch {
|
|
163
|
+
value = undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof value?.enabled === "boolean") {
|
|
167
|
+
enabled = value.enabled;
|
|
168
|
+
source = scope;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { enabled, source };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function permissionConfigPaths({ agentDir, cwd }) {
|
|
176
|
+
return {
|
|
177
|
+
global: path.join(agentDir, "extensions", "pi-permission-system", "config.json"),
|
|
178
|
+
project: cwd ? path.join(cwd, ".pi", "extensions", "pi-permission-system", "config.json") : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The `shellTools` mapping the permission system will actually apply to this tool: global, then a
|
|
184
|
+
* trusted project's entry, which replaces it wholesale on a key collision. A trusted repository could
|
|
185
|
+
* point the mapping at another argument, which would leave `command` ungated, so the effective entry
|
|
186
|
+
* is what gets checked, not the one SpecPi wrote.
|
|
187
|
+
*/
|
|
188
|
+
export function effectiveShellMapping({ agentDir, cwd, trusted = false }) {
|
|
189
|
+
const paths = permissionConfigPaths({ agentDir, cwd });
|
|
190
|
+
let mapping;
|
|
191
|
+
for (const file of trusted ? [paths.global, paths.project] : [paths.global]) {
|
|
192
|
+
const config = readJsonc(file);
|
|
193
|
+
const entry = config?.shellTools?.[BACKGROUND_TOOL];
|
|
194
|
+
if (entry !== undefined) {
|
|
195
|
+
mapping = entry;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return mapping;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function mappingGatesCommand(mapping) {
|
|
203
|
+
return (
|
|
204
|
+
Boolean(mapping) &&
|
|
205
|
+
typeof mapping === "object" &&
|
|
206
|
+
mapping.commandArgument === SHELL_TOOL_MAPPING.commandArgument &&
|
|
207
|
+
(mapping.workdirArgument === undefined || typeof mapping.workdirArgument === "string")
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Whether a job may start, and if not, a sentence the model and the human can act on. Every
|
|
213
|
+
* condition fails closed: an unreadable config or an unknown guard state refuses.
|
|
214
|
+
*/
|
|
215
|
+
export function admission({
|
|
216
|
+
interactive,
|
|
217
|
+
commandsKnown = true,
|
|
218
|
+
guardInstalled,
|
|
219
|
+
guard,
|
|
220
|
+
permissionInstalled,
|
|
221
|
+
mapping,
|
|
222
|
+
mappingError,
|
|
223
|
+
running,
|
|
224
|
+
}) {
|
|
225
|
+
if (interactive !== true) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: "Background jobs need an interactive session to report back to. Run the command with bash.",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (commandsKnown !== true) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: "Background jobs are off because this Pi cannot list its commands, so SpecPi cannot tell whether the command guard or the permission system is installed. Run the command with bash.",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (guardInstalled && guard?.enabled !== false) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
reason: "The Jev command guard is on, and it only checks bash, so background jobs are off while it is. Run the command with bash, or switch the guard off with /jev-guard off --global.",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (permissionInstalled) {
|
|
247
|
+
if (mappingError) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
reason: `Background jobs are off because the permission system's config could not be read to confirm bash rules apply: ${mappingError}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!mappingGatesCommand(mapping)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
reason: 'Background jobs are off because the permission system does not yet apply bash rules to them. Run `specpi update`, or add "shellTools": { "background": { "commandArgument": "command" } } to its global config.',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (Number.isInteger(running) && running >= MAX_RUNNING_JOBS) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
reason: `${MAX_RUNNING_JOBS} background jobs are already running. Wait for one to finish, or ask the user to stop one with /jobs stop <id>.`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------------------------
|
|
273
|
+
// Jobs
|
|
274
|
+
// ---------------------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function sanitize(text) {
|
|
277
|
+
// Keep tab, newline and carriage return; drop other control characters and bidi overrides.
|
|
278
|
+
return String(text).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/gu, "");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function tailOf(text, { lines = TAIL_LINES, chars = TAIL_CHARS } = {}) {
|
|
282
|
+
const kept = sanitize(text).replace(/\r\n?/gu, "\n").replace(/\n+$/u, "").split("\n").slice(-lines).join("\n");
|
|
283
|
+
|
|
284
|
+
return kept.length > chars ? kept.slice(-chars) : kept;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function formatDuration(ms) {
|
|
288
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
289
|
+
const hours = Math.floor(seconds / 3600);
|
|
290
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
291
|
+
const rest = seconds % 60;
|
|
292
|
+
if (hours > 0) {
|
|
293
|
+
return `${hours}h ${minutes}m`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function preview(command) {
|
|
300
|
+
const single = sanitize(command).replace(/\s+/gu, " ").trim();
|
|
301
|
+
|
|
302
|
+
return single.length > COMMAND_PREVIEW_CHARS ? `${single.slice(0, COMMAND_PREVIEW_CHARS - 1)}…` : single;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Run and track a session's jobs.
|
|
307
|
+
*
|
|
308
|
+
* `run(command, cwd, { onData, signal })` resolves `{ exitCode }` when the process ends and rejects
|
|
309
|
+
* when it is aborted; it is Pi's own local bash runner in production, so shell selection, process
|
|
310
|
+
* trees and cleanup on Pi's exit match the `bash` tool. `report(job)` is called once per job with its
|
|
311
|
+
* final state.
|
|
312
|
+
*/
|
|
313
|
+
export function createJobManager({ run, logDir, report, now = () => Date.now() }) {
|
|
314
|
+
const jobs = new Map();
|
|
315
|
+
let nextId = 1;
|
|
316
|
+
let closed = false;
|
|
317
|
+
|
|
318
|
+
const running = () => [...jobs.values()].filter((job) => job.state === "running");
|
|
319
|
+
|
|
320
|
+
function start({ command, cwd, label }) {
|
|
321
|
+
if (closed) {
|
|
322
|
+
throw new Error("This session has ended");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
326
|
+
const id = String(nextId);
|
|
327
|
+
nextId += 1;
|
|
328
|
+
const logPath = path.join(logDir, `job-${id}.log`);
|
|
329
|
+
const log = fs.openSync(logPath, "w", 0o600);
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const job = {
|
|
332
|
+
id,
|
|
333
|
+
label: typeof label === "string" && label.trim() ? preview(label).slice(0, 80) : undefined,
|
|
334
|
+
command: preview(command),
|
|
335
|
+
cwd,
|
|
336
|
+
logPath,
|
|
337
|
+
startedAt: now(),
|
|
338
|
+
state: "running",
|
|
339
|
+
exitCode: undefined,
|
|
340
|
+
endedAt: undefined,
|
|
341
|
+
bytes: 0,
|
|
342
|
+
truncated: false,
|
|
343
|
+
tail: "",
|
|
344
|
+
controller,
|
|
345
|
+
stoppedBy: undefined,
|
|
346
|
+
};
|
|
347
|
+
jobs.set(id, job);
|
|
348
|
+
|
|
349
|
+
const onData = (chunk) => {
|
|
350
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
351
|
+
const text = buffer.toString("utf8");
|
|
352
|
+
job.tail = (job.tail + text).slice(-TAIL_BUFFER_BYTES);
|
|
353
|
+
if (job.bytes < MAX_LOG_BYTES) {
|
|
354
|
+
const room = MAX_LOG_BYTES - job.bytes;
|
|
355
|
+
const slice = buffer.length > room ? buffer.subarray(0, room) : buffer;
|
|
356
|
+
try {
|
|
357
|
+
fs.writeSync(log, slice);
|
|
358
|
+
} catch {
|
|
359
|
+
// A full disk must not take the job down with it; the tail is still kept.
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
job.bytes += slice.length;
|
|
363
|
+
if (buffer.length > room && !job.truncated) {
|
|
364
|
+
job.truncated = true;
|
|
365
|
+
try {
|
|
366
|
+
fs.writeSync(log, `\n[SpecPi: log capped at ${MAX_LOG_BYTES} bytes; the job keeps running]\n`);
|
|
367
|
+
} catch {
|
|
368
|
+
// As above.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const finish = (state, exitCode) => {
|
|
375
|
+
if (job.state !== "running") {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
job.state = state;
|
|
380
|
+
job.exitCode = exitCode;
|
|
381
|
+
job.endedAt = now();
|
|
382
|
+
try {
|
|
383
|
+
fs.closeSync(log);
|
|
384
|
+
} catch {
|
|
385
|
+
// Already closed.
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!closed) {
|
|
389
|
+
report(job);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
Promise.resolve()
|
|
394
|
+
.then(() => run(command, cwd, { onData, signal: controller.signal }))
|
|
395
|
+
.then(
|
|
396
|
+
(result) => finish("exited", Number.isInteger(result?.exitCode) ? result.exitCode : null),
|
|
397
|
+
(error) => {
|
|
398
|
+
job.error = error?.message;
|
|
399
|
+
finish(controller.signal.aborted ? "stopped" : "failed", undefined);
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
return job;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function stop(id, by = "user") {
|
|
407
|
+
const job = jobs.get(String(id));
|
|
408
|
+
if (!job || job.state !== "running") {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
job.stoppedBy = by;
|
|
413
|
+
job.controller.abort();
|
|
414
|
+
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** End every job and delete this session's logs. Nothing is reported after this. */
|
|
419
|
+
function close() {
|
|
420
|
+
closed = true;
|
|
421
|
+
for (const job of running()) {
|
|
422
|
+
job.stoppedBy = "session-end";
|
|
423
|
+
job.controller.abort();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
fs.rmSync(logDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
428
|
+
} catch {
|
|
429
|
+
// A log still held open by a dying process is removed with the OS temp cleanup instead.
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
start,
|
|
435
|
+
stop,
|
|
436
|
+
close,
|
|
437
|
+
get: (id) => jobs.get(String(id)),
|
|
438
|
+
list: () => [...jobs.values()],
|
|
439
|
+
running: () => running().length,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The follow-up the agent and the human both see when a job ends. */
|
|
444
|
+
export function completionText(job, now = Date.now()) {
|
|
445
|
+
const name = job.label ? `${job.id} (${job.label})` : job.id;
|
|
446
|
+
const took = formatDuration((job.endedAt ?? now) - job.startedAt);
|
|
447
|
+
const outcome =
|
|
448
|
+
job.state === "exited"
|
|
449
|
+
? `finished with exit code ${job.exitCode ?? "unknown"}`
|
|
450
|
+
: job.state === "stopped"
|
|
451
|
+
? `was stopped${job.stoppedBy === "user" ? " by the user" : ""}`
|
|
452
|
+
: `could not run${job.error ? `: ${sanitize(job.error).slice(0, 200)}` : ""}`;
|
|
453
|
+
const tail = tailOf(job.tail);
|
|
454
|
+
|
|
455
|
+
return [
|
|
456
|
+
`Background job ${name} ${outcome} after ${took}.`,
|
|
457
|
+
`Command: ${job.command}`,
|
|
458
|
+
tail ? `Last lines of output:\n\`\`\`\n${tail.replace(/```/gu, "``\u200b`")}\n\`\`\`` : "It printed nothing.",
|
|
459
|
+
`Full log (deleted when this session ends): ${job.logPath}${job.truncated ? " (capped)" : ""}`,
|
|
460
|
+
].join("\n");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function startedText(job) {
|
|
464
|
+
return [
|
|
465
|
+
`Started background job ${job.id}${job.label ? ` (${job.label})` : ""}. It keeps running while you and the user carry on.`,
|
|
466
|
+
"When it ends you will receive its exit code and last lines of output as a message, so do not wait for it or poll it.",
|
|
467
|
+
`Its output is written to ${job.logPath} if you need to look before then. The user can list or stop jobs with /jobs.`,
|
|
468
|
+
].join(" ");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function statusText(jobs) {
|
|
472
|
+
const count = jobs.filter((job) => job.state === "running").length;
|
|
473
|
+
|
|
474
|
+
return count > 0 ? `${count} background job${count === 1 ? "" : "s"} running` : undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function listText(jobs, now = Date.now()) {
|
|
478
|
+
if (jobs.length === 0) {
|
|
479
|
+
return "No background jobs in this session.";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return jobs
|
|
483
|
+
.map((job) => {
|
|
484
|
+
const took = formatDuration((job.endedAt ?? now) - job.startedAt);
|
|
485
|
+
const state =
|
|
486
|
+
job.state === "running"
|
|
487
|
+
? `running ${took}`
|
|
488
|
+
: job.state === "exited"
|
|
489
|
+
? `exit ${job.exitCode ?? "?"} after ${took}`
|
|
490
|
+
: `${job.state} after ${took}`;
|
|
491
|
+
|
|
492
|
+
return `${job.id} ${state} ${job.label ?? job.command}`;
|
|
493
|
+
})
|
|
494
|
+
.join("\n");
|
|
495
|
+
}
|
|
@@ -124,6 +124,23 @@ export function capabilityInstalled(allToolNames, capability) {
|
|
|
124
124
|
return capability.tools.some((name) => known.has(name));
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** The tool the model uses to ask for a withdrawn group. */
|
|
128
|
+
export const CAPABILITY_REQUEST_TOOL = "request_capability";
|
|
129
|
+
|
|
130
|
+
/** Whether any requestable group is installed, i.e. whether asking for one could ever succeed. */
|
|
131
|
+
export function anyCapabilityInstalled(allToolNames) {
|
|
132
|
+
return CAPABILITY_NAMES.some((id) => capabilityInstalled(allToolNames, CAPABILITIES[id]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether to offer the request tool at all. It can only ever ask a human, so a session without one
|
|
137
|
+
* would carry its schema for a tool that always refuses, and a session with no requestable group
|
|
138
|
+
* installed has nothing to ask for.
|
|
139
|
+
*/
|
|
140
|
+
export function capabilityRequestOffered({ interactive, allToolNames }) {
|
|
141
|
+
return interactive === true && anyCapabilityInstalled(allToolNames);
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
/** One catalogue line per capability, for the tool description and its error results. */
|
|
128
145
|
export function describeCapabilities() {
|
|
129
146
|
return CAPABILITY_NAMES.map((id) => `${id}: ${CAPABILITIES[id].summary}`).join("; ");
|