@scira/cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -10
- package/dist/agent/background-tasks.js +173 -0
- package/dist/agent/research-agent.js +95 -38
- package/dist/agent/todos.js +140 -0
- package/dist/agent/tools.js +146 -143
- package/dist/agent/tools.test.js +33 -0
- package/dist/agent/workspace.js +85 -0
- package/dist/cli/commands/init.js +53 -39
- package/dist/cli/index.js +30 -14
- package/dist/config/env-guide.js +151 -0
- package/dist/config/env-guide.test.js +18 -0
- package/dist/config/env-store.js +53 -0
- package/dist/config/env-store.test.js +60 -0
- package/dist/tools/agent-tools.js +621 -0
- package/dist/tools/background-tasks.js +261 -0
- package/dist/tools/bash-policy.test.js +38 -0
- package/dist/tools/file-tools.js +6 -1
- package/dist/tools/search-web.js +24 -6
- package/dist/tools/search-web.test.js +24 -0
- package/dist/tools/todos.js +140 -0
- package/dist/tools/workspace.js +91 -0
- package/dist/tools/workspace.test.js +75 -0
- package/dist/tools/x-search.js +142 -0
- package/dist/types/index.js +1 -0
- package/dist/types/schema.test.js +1 -0
- package/dist/ui/ink/SciraApp.js +74 -21
- package/dist/ui/ink/components/overlays.js +15 -9
- package/dist/ui/ink/constants.js +13 -4
- package/dist/ui/ink/hooks/use-agent-turn.js +26 -7
- package/dist/ui/ink/hooks/use-feed-lines.js +33 -6
- package/dist/ui/ink/hooks/use-keyboard.js +16 -1
- package/dist/ui/ink/hooks/use-session.js +15 -14
- package/dist/ui/ink/hooks/use-settings.js +30 -8
- package/dist/ui/ink/hooks/use-submit.js +14 -3
- package/dist/ui/ink/hooks/use-theme.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +73 -5
- package/dist/ui/ink/lib/tool-result.test.js +3 -3
- package/dist/ui/ink/lib/utils.js +104 -5
- package/dist/ui/ink/lib/utils.test.js +18 -1
- package/dist/ui/ink/theme-context.js +29 -26
- package/dist/ui/ink/theme.js +36 -9
- package/dist/ui/ink/theme.test.js +32 -5
- package/package.json +6 -2
package/dist/agent/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
-
import { dirname,
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { tool } from "ai";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { multiSearchWeb } from "../tools/search-web.js";
|
|
@@ -11,6 +11,9 @@ import { logEvent } from "../storage/run-store.js";
|
|
|
11
11
|
import { diffLines } from "diff";
|
|
12
12
|
import { SKILL_NAMES, getSkill } from "./skills.js";
|
|
13
13
|
import { createFileTools } from "../tools/file-tools.js";
|
|
14
|
+
import { createTodoTool } from "./todos.js";
|
|
15
|
+
import { resolveToolPath, resolveInsideWorkspace } from "./workspace.js";
|
|
16
|
+
export { resolveInsideRun } from "./workspace.js";
|
|
14
17
|
const execAsync = promisify(exec);
|
|
15
18
|
const MAX_OUTPUT = 8000;
|
|
16
19
|
function truncate(text, max = MAX_OUTPUT) {
|
|
@@ -22,18 +25,7 @@ function truncate(text, max = MAX_OUTPUT) {
|
|
|
22
25
|
function snapshotSlug(url) {
|
|
23
26
|
return url.replace(/^https?:\/\//u, "").replace(/[^a-zA-Z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "page";
|
|
24
27
|
}
|
|
25
|
-
|
|
26
|
-
* Resolve a model-provided path against the run directory and refuse to escape it.
|
|
27
|
-
*/
|
|
28
|
-
export function resolveInsideRun(runPath, candidate) {
|
|
29
|
-
const abs = isAbsolute(candidate) ? candidate : resolve(runPath, candidate);
|
|
30
|
-
const rel = relative(runPath, abs);
|
|
31
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
32
|
-
throw new Error(`Path "${candidate}" is outside the run directory and is not allowed.`);
|
|
33
|
-
}
|
|
34
|
-
return abs;
|
|
35
|
-
}
|
|
36
|
-
export function createResearchTools(runPath, config, onApprovalRequired) {
|
|
28
|
+
export function createResearchTools(runPath, config, onApprovalRequired, workspacePath) {
|
|
37
29
|
/** Gate a tool behind user approval unless approvalMode is "auto". */
|
|
38
30
|
async function gate(toolName, description) {
|
|
39
31
|
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
@@ -41,55 +33,60 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
|
|
|
41
33
|
return onApprovalRequired(toolName, description);
|
|
42
34
|
}
|
|
43
35
|
const claimsPath = join(runPath, "claims.jsonl");
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (!await gate("bash", command))
|
|
53
|
-
return "Command rejected by user.";
|
|
54
|
-
// Block commands that attempt to leave the run directory.
|
|
55
|
-
const escapesRunDir = /(?:^|[;&|`\n])\s*cd\s+[^.]/u.test(command)
|
|
56
|
-
|| /\.\.\/\.\.\//u.test(command)
|
|
57
|
-
|| /~[/\\]/u.test(command)
|
|
58
|
-
|| /\$HOME/u.test(command);
|
|
59
|
-
if (escapesRunDir) {
|
|
60
|
-
return "Command rejected: navigating outside the run directory is not allowed.";
|
|
61
|
-
}
|
|
62
|
-
await logEvent(runPath, "tool.bash", { command });
|
|
63
|
-
try {
|
|
64
|
-
const { stdout, stderr } = await execAsync(command, {
|
|
65
|
-
cwd: runPath,
|
|
66
|
-
timeout: timeoutMs ?? 60000,
|
|
67
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
68
|
-
shell: "/bin/bash"
|
|
69
|
-
});
|
|
70
|
-
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
71
|
-
return truncate(out || "(no output)");
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
const err = error;
|
|
75
|
-
const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
|
|
76
|
-
return truncate(`Command failed:\n${out}`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
36
|
+
const filePathHint = workspacePath
|
|
37
|
+
? "Project source paths (src/foo.ts, package.json, …) are relative to the project root. Harness files (plan.md, notes.md, report.md, sources.jsonl) live in the run directory — use those bare names."
|
|
38
|
+
: "Paths are relative to the run directory.";
|
|
39
|
+
const runBash = tool({
|
|
40
|
+
description: "Run a shell command inside the run directory. Use for listing files, grepping notes, running scripts, git, etc. Returns combined stdout/stderr.",
|
|
41
|
+
inputSchema: z.object({
|
|
42
|
+
command: z.string().describe("The shell command to execute."),
|
|
43
|
+
timeoutMs: z.number().int().positive().max(120000).optional().describe("Optional timeout in ms (default 60000).")
|
|
79
44
|
}),
|
|
45
|
+
execute: async ({ command, timeoutMs }) => {
|
|
46
|
+
if (!await gate("bash", command))
|
|
47
|
+
return "Command rejected by user.";
|
|
48
|
+
const escapesRunDir = /(?:^|[;&|`\n])\s*cd\s+[^.]/u.test(command)
|
|
49
|
+
|| /\.\.\/\.\.\//u.test(command)
|
|
50
|
+
|| /~[/\\]/u.test(command)
|
|
51
|
+
|| /\$HOME/u.test(command);
|
|
52
|
+
if (escapesRunDir) {
|
|
53
|
+
return "Command rejected: navigating outside the run directory is not allowed.";
|
|
54
|
+
}
|
|
55
|
+
await logEvent(runPath, "tool.bash", { command });
|
|
56
|
+
try {
|
|
57
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
58
|
+
cwd: runPath,
|
|
59
|
+
timeout: timeoutMs ?? 60000,
|
|
60
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
61
|
+
shell: "/bin/bash"
|
|
62
|
+
});
|
|
63
|
+
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
64
|
+
return truncate(out || "(no output)");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const err = error;
|
|
68
|
+
const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
|
|
69
|
+
return truncate(`Command failed:\n${out}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
...(workspacePath ? {} : { bash: runBash }),
|
|
80
75
|
writeFile: tool({
|
|
81
|
-
description:
|
|
76
|
+
description: `Create or overwrite a file. ${filePathHint}`,
|
|
82
77
|
inputSchema: z.object({
|
|
83
|
-
path: z.string().describe("File path
|
|
78
|
+
path: z.string().describe("File path."),
|
|
84
79
|
content: z.string().describe("Full file content to write.")
|
|
85
80
|
}),
|
|
86
81
|
execute: async ({ path, content }) => {
|
|
87
|
-
const
|
|
88
|
-
|
|
82
|
+
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
83
|
+
const needsApproval = resolved.scope === "workspace"
|
|
84
|
+
|| path === "plan.md"
|
|
85
|
+
|| path === "report.md";
|
|
86
|
+
if (needsApproval) {
|
|
89
87
|
let description;
|
|
90
|
-
if (path === "report.md") {
|
|
91
|
-
const
|
|
92
|
-
const existing = await readFile(abs, "utf8").catch(() => "");
|
|
88
|
+
if (path === "report.md" && resolved.scope === "run") {
|
|
89
|
+
const existing = await readFile(resolved.abs, "utf8").catch(() => "");
|
|
93
90
|
const parts = diffLines(existing, content);
|
|
94
91
|
const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
|
|
95
92
|
const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
|
|
@@ -100,40 +97,55 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
|
|
|
100
97
|
.join("\n");
|
|
101
98
|
description = `report.md (+${added} / -${removed} lines)\n\n${diffPreview}`;
|
|
102
99
|
}
|
|
100
|
+
else if (resolved.scope === "workspace") {
|
|
101
|
+
const preview = content.length > 800 ? `${content.slice(0, 800)}\n…[${content.length} total chars]` : content;
|
|
102
|
+
description = `Write to ${resolved.displayPath}:\n\n${preview}`;
|
|
103
|
+
}
|
|
103
104
|
else {
|
|
104
105
|
description = `${path}\n\n${content.length > 600 ? `${content.slice(0, 600)}\n…` : content}`;
|
|
105
106
|
}
|
|
106
107
|
if (!await gate("writeFile", description))
|
|
107
|
-
return `Write to ${
|
|
108
|
+
return `Write to ${resolved.displayPath} rejected by user.`;
|
|
108
109
|
}
|
|
109
|
-
|
|
110
|
-
await
|
|
111
|
-
await writeFile(abs, content);
|
|
110
|
+
await mkdir(dirname(resolved.abs), { recursive: true });
|
|
111
|
+
await writeFile(resolved.abs, content);
|
|
112
112
|
const eventType = path === "report.md" ? "report.updated" : path === "plan.md" ? "plan.updated" : "file.written";
|
|
113
|
-
await logEvent(runPath, eventType, { path, chars: content.length });
|
|
114
|
-
|
|
113
|
+
await logEvent(runPath, eventType, { path: resolved.displayPath, scope: resolved.scope, chars: content.length });
|
|
114
|
+
const where = resolved.scope === "workspace" ? "workspace" : "run";
|
|
115
|
+
return `Wrote ${content.length} chars to ${resolved.displayPath} (${where})`;
|
|
115
116
|
}
|
|
116
117
|
}),
|
|
117
118
|
editFile: tool({
|
|
118
|
-
description:
|
|
119
|
+
description: `Replace an exact string in an existing file. ${filePathHint} The oldString must match exactly and be unique.`,
|
|
119
120
|
inputSchema: z.object({
|
|
120
|
-
path: z.string().describe("File path
|
|
121
|
+
path: z.string().describe("File path."),
|
|
121
122
|
oldString: z.string().describe("Exact text to replace."),
|
|
122
123
|
newString: z.string().describe("Replacement text.")
|
|
123
124
|
}),
|
|
124
125
|
execute: async ({ path, oldString, newString }) => {
|
|
125
|
-
const
|
|
126
|
-
const current = await readFile(abs, "utf8");
|
|
126
|
+
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
127
|
+
const current = await readFile(resolved.abs, "utf8");
|
|
127
128
|
const occurrences = current.split(oldString).length - 1;
|
|
128
129
|
if (occurrences === 0) {
|
|
129
|
-
return `No match for the given oldString in ${
|
|
130
|
+
return `No match for the given oldString in ${resolved.displayPath}. No changes made.`;
|
|
130
131
|
}
|
|
131
132
|
if (occurrences > 1) {
|
|
132
|
-
return `oldString matched ${occurrences} times in ${
|
|
133
|
+
return `oldString matched ${occurrences} times in ${resolved.displayPath}; provide more context to make it unique. No changes made.`;
|
|
134
|
+
}
|
|
135
|
+
if (resolved.scope === "workspace") {
|
|
136
|
+
const diff = diffLines(current, current.replace(oldString, newString));
|
|
137
|
+
const preview = diff
|
|
138
|
+
.filter((p) => p.added || p.removed)
|
|
139
|
+
.flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
|
|
140
|
+
.slice(0, 15)
|
|
141
|
+
.join("\n");
|
|
142
|
+
if (!await gate("editFile", `Edit ${resolved.displayPath}:\n\n${preview}`)) {
|
|
143
|
+
return `Edit to ${resolved.displayPath} rejected by user.`;
|
|
144
|
+
}
|
|
133
145
|
}
|
|
134
|
-
await writeFile(abs, current.replace(oldString, newString));
|
|
135
|
-
await logEvent(runPath, "file.edited", { path });
|
|
136
|
-
return `Edited ${
|
|
146
|
+
await writeFile(resolved.abs, current.replace(oldString, newString));
|
|
147
|
+
await logEvent(runPath, "file.edited", { path: resolved.displayPath, scope: resolved.scope });
|
|
148
|
+
return `Edited ${resolved.displayPath}`;
|
|
137
149
|
}
|
|
138
150
|
}),
|
|
139
151
|
createClaim: tool({
|
|
@@ -173,13 +185,13 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
|
|
|
173
185
|
}
|
|
174
186
|
}),
|
|
175
187
|
readFile: tool({
|
|
176
|
-
description:
|
|
188
|
+
description: `Read a file. ${filePathHint}`,
|
|
177
189
|
inputSchema: z.object({
|
|
178
|
-
path: z.string().describe("File path
|
|
190
|
+
path: z.string().describe("File path.")
|
|
179
191
|
}),
|
|
180
192
|
execute: async ({ path }) => {
|
|
181
|
-
const
|
|
182
|
-
return truncate(await readFile(abs, "utf8"));
|
|
193
|
+
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
194
|
+
return truncate(await readFile(resolved.abs, "utf8"));
|
|
183
195
|
}
|
|
184
196
|
}),
|
|
185
197
|
webSearch: tool({
|
|
@@ -246,6 +258,7 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
|
|
|
246
258
|
return skill.content;
|
|
247
259
|
}
|
|
248
260
|
}),
|
|
261
|
+
todo: createTodoTool(runPath),
|
|
249
262
|
...(config.files ? createFileTools(runPath, config, onApprovalRequired) : {})
|
|
250
263
|
};
|
|
251
264
|
}
|
|
@@ -254,8 +267,9 @@ export function createResearchTools(runPath, config, onApprovalRequired) {
|
|
|
254
267
|
* plus a single escalation tool the model can call (with user approval) to
|
|
255
268
|
* switch into the full research harness.
|
|
256
269
|
*/
|
|
257
|
-
export function createOneShotTools(runPath, config, onApprovalRequired, onEscalate) {
|
|
258
|
-
const all = createResearchTools(runPath, config, onApprovalRequired);
|
|
270
|
+
export function createOneShotTools(runPath, config, onApprovalRequired, onEscalate, workspacePath, backgroundTasks) {
|
|
271
|
+
const all = createResearchTools(runPath, config, onApprovalRequired, workspacePath);
|
|
272
|
+
const coding = workspacePath ? createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath) : {};
|
|
259
273
|
async function gate(toolName, description) {
|
|
260
274
|
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
261
275
|
return true;
|
|
@@ -264,6 +278,8 @@ export function createOneShotTools(runPath, config, onApprovalRequired, onEscala
|
|
|
264
278
|
return {
|
|
265
279
|
webSearch: all.webSearch,
|
|
266
280
|
readUrl: all.readUrl,
|
|
281
|
+
todo: all.todo,
|
|
282
|
+
...coding,
|
|
267
283
|
requestFullResearch: tool({
|
|
268
284
|
description: "Escalate from quick one-shot mode to the FULL research harness (skills, plan.md, claims, verification, sources.jsonl, report.md). " +
|
|
269
285
|
"You MUST call this if the user's goal involves research, deep dives, analysis, comparisons, history, or any topic that would benefit from structured multi-source research. " +
|
|
@@ -286,76 +302,18 @@ export function createOneShotTools(runPath, config, onApprovalRequired, onEscala
|
|
|
286
302
|
* Workspace-aware tools for coding agent capabilities.
|
|
287
303
|
* Unlike research tools, these operate on the full workspace, not just the run directory.
|
|
288
304
|
*/
|
|
289
|
-
export function createCodingTools(workspacePath, config, onApprovalRequired) {
|
|
305
|
+
export function createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath) {
|
|
290
306
|
async function gate(toolName, description) {
|
|
291
307
|
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
292
308
|
return true;
|
|
293
309
|
return onApprovalRequired(toolName, description);
|
|
294
310
|
}
|
|
295
311
|
function resolveWorkspacePath(candidate) {
|
|
296
|
-
return
|
|
312
|
+
return resolveInsideWorkspace(workspacePath, candidate);
|
|
297
313
|
}
|
|
298
314
|
return {
|
|
299
|
-
readWorkspaceFile: tool({
|
|
300
|
-
description: "Read a file from the workspace. Use for examining code, configs, or any workspace file.",
|
|
301
|
-
inputSchema: z.object({
|
|
302
|
-
path: z.string().describe("File path (absolute or relative to workspace root).")
|
|
303
|
-
}),
|
|
304
|
-
execute: async ({ path }) => {
|
|
305
|
-
const abs = resolveWorkspacePath(path);
|
|
306
|
-
const content = await readFile(abs, "utf8");
|
|
307
|
-
return truncate(content);
|
|
308
|
-
}
|
|
309
|
-
}),
|
|
310
|
-
writeWorkspaceFile: tool({
|
|
311
|
-
description: "Create or overwrite a file in the workspace. Requires approval for safety.",
|
|
312
|
-
inputSchema: z.object({
|
|
313
|
-
path: z.string().describe("File path (absolute or relative to workspace root)."),
|
|
314
|
-
content: z.string().describe("Full file content to write.")
|
|
315
|
-
}),
|
|
316
|
-
execute: async ({ path, content }) => {
|
|
317
|
-
const abs = resolveWorkspacePath(path);
|
|
318
|
-
const preview = content.length > 800 ? `${content.slice(0, 800)}\n…[${content.length} total chars]` : content;
|
|
319
|
-
if (!await gate("writeWorkspaceFile", `Write to ${path}:\n\n${preview}`)) {
|
|
320
|
-
return `Write to ${path} rejected by user.`;
|
|
321
|
-
}
|
|
322
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
323
|
-
await writeFile(abs, content);
|
|
324
|
-
return `Wrote ${content.length} chars to ${path}`;
|
|
325
|
-
}
|
|
326
|
-
}),
|
|
327
|
-
editWorkspaceFile: tool({
|
|
328
|
-
description: "Replace an exact string in a workspace file. The oldString must match exactly and be unique.",
|
|
329
|
-
inputSchema: z.object({
|
|
330
|
-
path: z.string().describe("File path (absolute or relative to workspace root)."),
|
|
331
|
-
oldString: z.string().describe("Exact text to replace."),
|
|
332
|
-
newString: z.string().describe("Replacement text.")
|
|
333
|
-
}),
|
|
334
|
-
execute: async ({ path, oldString, newString }) => {
|
|
335
|
-
const abs = resolveWorkspacePath(path);
|
|
336
|
-
const current = await readFile(abs, "utf8");
|
|
337
|
-
const occurrences = current.split(oldString).length - 1;
|
|
338
|
-
if (occurrences === 0) {
|
|
339
|
-
return `No match for the given oldString in ${path}. No changes made.`;
|
|
340
|
-
}
|
|
341
|
-
if (occurrences > 1) {
|
|
342
|
-
return `oldString matched ${occurrences} times in ${path}; provide more context to make it unique. No changes made.`;
|
|
343
|
-
}
|
|
344
|
-
const diff = diffLines(current, current.replace(oldString, newString));
|
|
345
|
-
const preview = diff
|
|
346
|
-
.filter((p) => p.added || p.removed)
|
|
347
|
-
.flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
|
|
348
|
-
.slice(0, 15)
|
|
349
|
-
.join("\n");
|
|
350
|
-
if (!await gate("editWorkspaceFile", `Edit ${path}:\n\n${preview}`)) {
|
|
351
|
-
return `Edit to ${path} rejected by user.`;
|
|
352
|
-
}
|
|
353
|
-
await writeFile(abs, current.replace(oldString, newString));
|
|
354
|
-
return `Edited ${path}`;
|
|
355
|
-
}
|
|
356
|
-
}),
|
|
357
315
|
listWorkspaceDir: tool({
|
|
358
|
-
description: "List files and directories in
|
|
316
|
+
description: "List files and directories in the project workspace.",
|
|
359
317
|
inputSchema: z.object({
|
|
360
318
|
path: z.string().describe("Directory path (absolute or relative to workspace root)."),
|
|
361
319
|
recursive: z.boolean().optional().describe("Recursively list subdirectories (default false).")
|
|
@@ -396,18 +354,63 @@ export function createCodingTools(workspacePath, config, onApprovalRequired) {
|
|
|
396
354
|
}
|
|
397
355
|
}
|
|
398
356
|
}),
|
|
399
|
-
|
|
400
|
-
description: "
|
|
357
|
+
bash: tool({
|
|
358
|
+
description: "Run shell commands in the workspace. " +
|
|
359
|
+
"Actions: run (foreground, default), background (start a long-running process like a dev server), list (show background tasks), output (read task logs), kill (stop a background task). " +
|
|
360
|
+
"Use background for servers and watchers; the task id and output remain available across turns.",
|
|
401
361
|
inputSchema: z.object({
|
|
402
|
-
|
|
362
|
+
action: z
|
|
363
|
+
.enum(["run", "background", "list", "output", "kill"])
|
|
364
|
+
.optional()
|
|
365
|
+
.describe("run=foreground (default), background=spawn detached, list/output/kill manage background tasks."),
|
|
366
|
+
command: z.string().optional().describe("Shell command for run or background."),
|
|
367
|
+
taskId: z.string().optional().describe("Task id for output or kill."),
|
|
403
368
|
cwd: z.string().optional().describe("Working directory (default: workspace root)."),
|
|
404
|
-
timeoutMs: z.number().int().positive().max(300000).optional().describe("Timeout
|
|
369
|
+
timeoutMs: z.number().int().positive().max(300000).optional().describe("Timeout for foreground run (default 60000)."),
|
|
370
|
+
tailLines: z.number().int().positive().max(200).optional().describe("Lines of output for action=output (default 50).")
|
|
405
371
|
}),
|
|
406
|
-
execute: async ({ command, cwd, timeoutMs }) => {
|
|
372
|
+
execute: async ({ action, command, taskId, cwd, timeoutMs, tailLines }) => {
|
|
373
|
+
const act = action ?? "run";
|
|
407
374
|
const workDir = cwd ? resolveWorkspacePath(cwd) : workspacePath;
|
|
408
|
-
if (
|
|
375
|
+
if (act === "list") {
|
|
376
|
+
if (!backgroundTasks)
|
|
377
|
+
return "No background task manager available.";
|
|
378
|
+
const tasks = await backgroundTasks.list();
|
|
379
|
+
if (tasks.length === 0)
|
|
380
|
+
return "No background tasks.";
|
|
381
|
+
return tasks
|
|
382
|
+
.map((t) => `${t.id} [${t.status}] pid=${t.pid} ${t.command}`)
|
|
383
|
+
.join("\n");
|
|
384
|
+
}
|
|
385
|
+
if (act === "output") {
|
|
386
|
+
if (!backgroundTasks)
|
|
387
|
+
return "No background task manager available.";
|
|
388
|
+
if (!taskId)
|
|
389
|
+
return "output requires taskId.";
|
|
390
|
+
return truncate(await backgroundTasks.getOutput(taskId, tailLines ?? 50));
|
|
391
|
+
}
|
|
392
|
+
if (act === "kill") {
|
|
393
|
+
if (!backgroundTasks)
|
|
394
|
+
return "No background task manager available.";
|
|
395
|
+
if (!taskId)
|
|
396
|
+
return "kill requires taskId.";
|
|
397
|
+
return await backgroundTasks.kill(taskId);
|
|
398
|
+
}
|
|
399
|
+
if (!command)
|
|
400
|
+
return `${act} requires command.`;
|
|
401
|
+
if (act === "background") {
|
|
402
|
+
if (!backgroundTasks)
|
|
403
|
+
return "Background tasks not available in this session.";
|
|
404
|
+
const task = await backgroundTasks.spawn(command, workDir);
|
|
405
|
+
if (runPath)
|
|
406
|
+
await logEvent(runPath, "tool.bash.background", { taskId: task.id, command });
|
|
407
|
+
return `Started background task ${task.id} (pid ${task.pid}): ${command}\nUse bash action=output taskId=${task.id} to read output.`;
|
|
408
|
+
}
|
|
409
|
+
if (!await gate("bash", `Run in ${relative(workspacePath, workDir) || "."}:\n\n${command}`)) {
|
|
409
410
|
return "Command rejected by user.";
|
|
410
411
|
}
|
|
412
|
+
if (runPath)
|
|
413
|
+
await logEvent(runPath, "tool.bash", { command, cwd: workDir });
|
|
411
414
|
try {
|
|
412
415
|
const { stdout, stderr } = await execAsync(command, {
|
|
413
416
|
cwd: workDir,
|
package/dist/agent/tools.test.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { resolveInsideRun } from "./tools.js";
|
|
3
|
+
import { isRunArtifactPath, resolveProjectRoot, resolveToolPath } from "./workspace.js";
|
|
3
4
|
const RUN = "/tmp/scira-test-run";
|
|
5
|
+
const PROJECT = "/Users/me/my-app";
|
|
6
|
+
const RUN_UNDER_SCIRA = `${PROJECT}/.scira/runs/2024-test-abc`;
|
|
4
7
|
describe("resolveInsideRun", () => {
|
|
5
8
|
it("resolves a relative path inside the run dir", () => {
|
|
6
9
|
expect(resolveInsideRun(RUN, "notes.md")).toBe(`${RUN}/notes.md`);
|
|
@@ -25,3 +28,33 @@ describe("resolveInsideRun", () => {
|
|
|
25
28
|
expect(() => resolveInsideRun(RUN, home)).toThrow("outside the run directory");
|
|
26
29
|
});
|
|
27
30
|
});
|
|
31
|
+
describe("resolveProjectRoot", () => {
|
|
32
|
+
it("returns parent of .scira when run is under .scira/runs", () => {
|
|
33
|
+
expect(resolveProjectRoot(RUN_UNDER_SCIRA)).toBe(PROJECT);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("isRunArtifactPath", () => {
|
|
37
|
+
it("treats harness filenames as run artifacts", () => {
|
|
38
|
+
expect(isRunArtifactPath("plan.md")).toBe(true);
|
|
39
|
+
expect(isRunArtifactPath("notes.md")).toBe(true);
|
|
40
|
+
expect(isRunArtifactPath("src/foo.ts")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it("treats run: prefix as run artifact", () => {
|
|
43
|
+
expect(isRunArtifactPath("run:custom.md")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("resolveToolPath", () => {
|
|
47
|
+
it("routes source paths to workspace", () => {
|
|
48
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "src/index.ts");
|
|
49
|
+
expect(resolved.scope).toBe("workspace");
|
|
50
|
+
expect(resolved.abs).toBe(`${PROJECT}/src/index.ts`);
|
|
51
|
+
});
|
|
52
|
+
it("routes plan.md to run directory", () => {
|
|
53
|
+
const resolved = resolveToolPath(RUN_UNDER_SCIRA, PROJECT, "plan.md");
|
|
54
|
+
expect(resolved.scope).toBe("run");
|
|
55
|
+
expect(resolved.abs).toBe(`${RUN_UNDER_SCIRA}/plan.md`);
|
|
56
|
+
});
|
|
57
|
+
it("blocks writes into .scira from workspace paths", () => {
|
|
58
|
+
expect(() => resolveToolPath(RUN_UNDER_SCIRA, PROJECT, ".scira/config.json")).toThrow("inside .scira");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
/** Harness files that live under .scira/runs/<id>/, not in the project codebase. */
|
|
3
|
+
export const RUN_ARTIFACT_FILES = new Set([
|
|
4
|
+
"plan.md",
|
|
5
|
+
"notes.md",
|
|
6
|
+
"report.md",
|
|
7
|
+
"sources.jsonl",
|
|
8
|
+
"claims.jsonl",
|
|
9
|
+
"goal.md",
|
|
10
|
+
"RESEARCH.md",
|
|
11
|
+
"scope.json",
|
|
12
|
+
"progress.md",
|
|
13
|
+
"handoff.md",
|
|
14
|
+
"convo.json",
|
|
15
|
+
"todos.json",
|
|
16
|
+
"background-tasks.json",
|
|
17
|
+
"title.md",
|
|
18
|
+
"run.log.jsonl"
|
|
19
|
+
]);
|
|
20
|
+
export function isRunArtifactPath(candidate) {
|
|
21
|
+
if (candidate.startsWith("run:"))
|
|
22
|
+
return true;
|
|
23
|
+
const normalized = candidate.replace(/\\/g, "/").replace(/^\.\//u, "");
|
|
24
|
+
if (normalized.startsWith("snapshots/") || normalized.startsWith("artifacts/"))
|
|
25
|
+
return true;
|
|
26
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
27
|
+
return RUN_ARTIFACT_FILES.has(base);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Project root: parent of `.scira` when the run lives under `.scira/runs/…`,
|
|
31
|
+
* otherwise the current working directory (unless cwd is inside `.scira`).
|
|
32
|
+
*/
|
|
33
|
+
export function resolveProjectRoot(runPath, cwd = process.cwd()) {
|
|
34
|
+
const fromRun = projectRootFromPath(runPath);
|
|
35
|
+
if (fromRun)
|
|
36
|
+
return fromRun;
|
|
37
|
+
const fromCwd = projectRootFromPath(resolve(cwd));
|
|
38
|
+
if (fromCwd)
|
|
39
|
+
return fromCwd;
|
|
40
|
+
return resolve(cwd);
|
|
41
|
+
}
|
|
42
|
+
function projectRootFromPath(absPath) {
|
|
43
|
+
const normalized = resolve(absPath).replace(/\\/g, "/");
|
|
44
|
+
const marker = "/.scira/";
|
|
45
|
+
const idx = normalized.indexOf(marker);
|
|
46
|
+
if (idx >= 0)
|
|
47
|
+
return normalized.slice(0, idx) || "/";
|
|
48
|
+
if (normalized.endsWith("/.scira"))
|
|
49
|
+
return normalized.slice(0, -"/.scira".length) || "/";
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
export function resolveToolPath(runPath, workspacePath, candidate) {
|
|
53
|
+
const raw = candidate.trim();
|
|
54
|
+
if (raw.startsWith("run:")) {
|
|
55
|
+
const inner = raw.slice(4);
|
|
56
|
+
const abs = resolveInsideRun(runPath, inner);
|
|
57
|
+
return { abs, displayPath: inner, scope: "run" };
|
|
58
|
+
}
|
|
59
|
+
if (workspacePath && !isRunArtifactPath(raw)) {
|
|
60
|
+
const abs = resolveInsideWorkspace(workspacePath, raw);
|
|
61
|
+
return { abs, displayPath: raw, scope: "workspace" };
|
|
62
|
+
}
|
|
63
|
+
const abs = resolveInsideRun(runPath, raw);
|
|
64
|
+
return { abs, displayPath: raw, scope: "run" };
|
|
65
|
+
}
|
|
66
|
+
export function resolveInsideRun(runPath, candidate) {
|
|
67
|
+
const abs = isAbsolute(candidate) ? candidate : resolve(runPath, candidate);
|
|
68
|
+
const rel = relative(runPath, abs);
|
|
69
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
70
|
+
throw new Error(`Path "${candidate}" is outside the run directory and is not allowed.`);
|
|
71
|
+
}
|
|
72
|
+
return abs;
|
|
73
|
+
}
|
|
74
|
+
export function resolveInsideWorkspace(workspacePath, candidate) {
|
|
75
|
+
const abs = isAbsolute(candidate) ? candidate : resolve(workspacePath, candidate);
|
|
76
|
+
const rel = relative(workspacePath, abs);
|
|
77
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
78
|
+
throw new Error(`Path "${candidate}" is outside the project workspace.`);
|
|
79
|
+
}
|
|
80
|
+
const norm = rel.replace(/\\/g, "/");
|
|
81
|
+
if (norm === ".scira" || norm.startsWith(".scira/")) {
|
|
82
|
+
throw new Error(`Path "${candidate}" is inside .scira. Harness files use bare names (plan.md, notes.md, report.md). Source code paths are relative to the project root.`);
|
|
83
|
+
}
|
|
84
|
+
return abs;
|
|
85
|
+
}
|