@scira/cli 0.1.5 → 0.1.6

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.
Files changed (57) hide show
  1. package/dist/agent/harness-agent.js +206 -0
  2. package/dist/agent/{research-agent.js → main-agent.js} +20 -1
  3. package/dist/cli/commands/init.js +7 -5
  4. package/dist/cli/index.js +52 -11
  5. package/dist/cli/shell/shell.js +4 -5
  6. package/dist/cli/shell/tui.js +5 -2
  7. package/dist/config/env-guide.js +24 -0
  8. package/dist/config/env-store.js +5 -3
  9. package/dist/config/load-config.js +9 -14
  10. package/dist/providers/harness/local-sandbox.js +143 -0
  11. package/dist/providers/llm/gateway.js +5 -2
  12. package/dist/providers/llm/models.js +13 -0
  13. package/dist/providers/llm/readiness.js +5 -1
  14. package/dist/providers/llm/registry.js +24 -3
  15. package/dist/storage/jsonl.js +2 -2
  16. package/dist/storage/run-store.js +15 -15
  17. package/dist/tools/agent-tools.js +7 -7
  18. package/dist/tools/background-tasks.js +4 -5
  19. package/dist/tools/mcp-oauth.js +29 -25
  20. package/dist/tools/open-url.js +1 -2
  21. package/dist/tools/todos.js +3 -3
  22. package/dist/types/index.js +13 -1
  23. package/dist/ui/ink/SciraApp.js +10 -6
  24. package/dist/ui/ink/components/home-screen.js +2 -2
  25. package/dist/ui/ink/components/overlays.js +73 -15
  26. package/dist/ui/ink/constants.js +10 -7
  27. package/dist/ui/ink/hooks/use-agent-turn.js +14 -5
  28. package/dist/ui/ink/hooks/use-feed-lines.js +31 -6
  29. package/dist/ui/ink/hooks/use-keyboard.js +28 -5
  30. package/dist/ui/ink/hooks/use-session.js +7 -5
  31. package/dist/ui/ink/hooks/use-settings.js +20 -0
  32. package/dist/ui/ink/hooks/use-submit.js +15 -8
  33. package/dist/ui/ink/lib/file-mentions.js +1 -2
  34. package/dist/ui/ink/lib/tool-result.js +201 -2
  35. package/dist/ui/ink/lib/utils.js +52 -28
  36. package/dist/ui/ink/theme.js +5 -10
  37. package/dist/watch/runner.js +2 -2
  38. package/package.json +13 -11
  39. package/dist/agent/background-tasks.js +0 -173
  40. package/dist/agent/todos.js +0 -140
  41. package/dist/agent/tools.js +0 -432
  42. package/dist/agent/tools.test.js +0 -60
  43. package/dist/agent/workspace.js +0 -85
  44. package/dist/config/env-guide.test.js +0 -18
  45. package/dist/config/env-store.test.js +0 -60
  46. package/dist/storage/jsonl.test.js +0 -38
  47. package/dist/storage/run-store.test.js +0 -65
  48. package/dist/tools/bash-policy.test.js +0 -38
  49. package/dist/tools/search-web.test.js +0 -24
  50. package/dist/tools/workspace.test.js +0 -75
  51. package/dist/types/schema.test.js +0 -61
  52. package/dist/ui/ink/hooks/use-feed-lines.test.js +0 -16
  53. package/dist/ui/ink/lib/tool-result.test.js +0 -60
  54. package/dist/ui/ink/lib/utils.test.js +0 -48
  55. package/dist/ui/ink/session-manager.test.js +0 -31
  56. package/dist/ui/ink/terminal-probe.test.js +0 -12
  57. package/dist/ui/ink/theme.test.js +0 -68
@@ -1,140 +0,0 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { tool } from "ai";
4
- import { z } from "zod";
5
- import { logEvent } from "../storage/run-store.js";
6
- const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
7
- function nextTodoId(existing) {
8
- const nums = existing
9
- .map((t) => /^todo_(\d+)$/u.exec(t.id)?.[1])
10
- .filter((n) => Boolean(n))
11
- .map((n) => Number.parseInt(n, 10));
12
- const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
13
- return `todo_${String(next).padStart(3, "0")}`;
14
- }
15
- async function loadTodos(path) {
16
- try {
17
- const raw = await readFile(path, "utf8");
18
- const parsed = JSON.parse(raw);
19
- if (!Array.isArray(parsed))
20
- return [];
21
- return parsed.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string");
22
- }
23
- catch {
24
- return [];
25
- }
26
- }
27
- async function saveTodos(path, items) {
28
- await mkdir(dirname(path), { recursive: true });
29
- await writeFile(path, JSON.stringify(items, null, 2) + "\n");
30
- }
31
- function formatTodoList(items) {
32
- if (items.length === 0)
33
- return "No todos.";
34
- const icon = {
35
- pending: "[ ]",
36
- in_progress: "[~]",
37
- completed: "[x]",
38
- cancelled: "[-]"
39
- };
40
- return items
41
- .map((t) => `${icon[t.status]} ${t.id}: ${t.content} (${t.status})`)
42
- .join("\n");
43
- }
44
- export function createTodoTool(runPath) {
45
- const todosPath = join(runPath, "todos.json");
46
- return tool({
47
- description: "Manage structured task todos for the current session. " +
48
- "Actions: create (add items), edit (change content), mark (set status), remove (delete one), rewrite (replace entire list), list (show all). " +
49
- "Statuses: pending, in_progress, completed, cancelled.",
50
- inputSchema: z.object({
51
- action: z.enum(["create", "edit", "mark", "remove", "rewrite", "list"]),
52
- id: z.string().optional().describe("Todo id for edit, mark, or remove."),
53
- content: z.string().optional().describe("Todo text for create, edit, or rewrite items."),
54
- status: TodoStatusSchema.optional().describe("Status for mark action or rewrite items."),
55
- items: z
56
- .array(z.object({
57
- id: z.string().optional(),
58
- content: z.string(),
59
- status: TodoStatusSchema.optional()
60
- }))
61
- .optional()
62
- .describe("Items for create or rewrite.")
63
- }),
64
- execute: async ({ action, id, content, status, items }) => {
65
- const now = new Date().toISOString();
66
- let todos = await loadTodos(todosPath);
67
- switch (action) {
68
- case "list":
69
- return formatTodoList(todos);
70
- case "create": {
71
- const toAdd = items ?? (content ? [{ content, status: status ?? "pending" }] : []);
72
- if (toAdd.length === 0)
73
- return "create requires content or items.";
74
- for (const item of toAdd) {
75
- const todoId = item.id ?? nextTodoId(todos);
76
- todos.push({
77
- id: todoId,
78
- content: item.content,
79
- status: item.status ?? "pending",
80
- createdAt: now,
81
- updatedAt: now
82
- });
83
- }
84
- await saveTodos(todosPath, todos);
85
- await logEvent(runPath, "todo.created", { count: toAdd.length });
86
- return `Created ${toAdd.length} todo(s).\n\n${formatTodoList(todos)}`;
87
- }
88
- case "edit": {
89
- if (!id || !content)
90
- return "edit requires id and content.";
91
- const idx = todos.findIndex((t) => t.id === id);
92
- if (idx === -1)
93
- return `Todo "${id}" not found.`;
94
- todos[idx] = { ...todos[idx], content, updatedAt: now };
95
- await saveTodos(todosPath, todos);
96
- await logEvent(runPath, "todo.edited", { id });
97
- return `Updated ${id}.\n\n${formatTodoList(todos)}`;
98
- }
99
- case "mark": {
100
- if (!id || !status)
101
- return "mark requires id and status.";
102
- const idx = todos.findIndex((t) => t.id === id);
103
- if (idx === -1)
104
- return `Todo "${id}" not found.`;
105
- todos[idx] = { ...todos[idx], status, updatedAt: now };
106
- await saveTodos(todosPath, todos);
107
- await logEvent(runPath, "todo.marked", { id, status });
108
- return `Marked ${id} as ${status}.\n\n${formatTodoList(todos)}`;
109
- }
110
- case "remove": {
111
- if (!id)
112
- return "remove requires id.";
113
- const before = todos.length;
114
- todos = todos.filter((t) => t.id !== id);
115
- if (todos.length === before)
116
- return `Todo "${id}" not found.`;
117
- await saveTodos(todosPath, todos);
118
- await logEvent(runPath, "todo.removed", { id });
119
- return `Removed ${id}.\n\n${formatTodoList(todos)}`;
120
- }
121
- case "rewrite": {
122
- if (!items || items.length === 0)
123
- return "rewrite requires a non-empty items array.";
124
- todos = items.map((item, i) => ({
125
- id: item.id ?? `todo_${String(i + 1).padStart(3, "0")}`,
126
- content: item.content,
127
- status: item.status ?? "pending",
128
- createdAt: now,
129
- updatedAt: now
130
- }));
131
- await saveTodos(todosPath, todos);
132
- await logEvent(runPath, "todo.rewritten", { count: todos.length });
133
- return `Rewrote todo list (${todos.length} items).\n\n${formatTodoList(todos)}`;
134
- }
135
- default:
136
- return `Unknown action: ${action}`;
137
- }
138
- }
139
- });
140
- }
@@ -1,432 +0,0 @@
1
- import { exec } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import { readFile, writeFile, mkdir } from "node:fs/promises";
4
- import { dirname, join, relative } from "node:path";
5
- import { tool } from "ai";
6
- import { z } from "zod";
7
- import { multiSearchWeb } from "../tools/search-web.js";
8
- import { openUrl, writeSnapshot } from "../tools/open-url.js";
9
- import { appendJsonl, readJsonl } from "../storage/jsonl.js";
10
- import { logEvent } from "../storage/run-store.js";
11
- import { diffLines } from "diff";
12
- import { SKILL_NAMES, getSkill } from "./skills.js";
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";
17
- const execAsync = promisify(exec);
18
- const MAX_OUTPUT = 8000;
19
- function truncate(text, max = MAX_OUTPUT) {
20
- if (text.length <= max)
21
- return text;
22
- return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]`;
23
- }
24
- /** Derive a stable, filesystem-safe snapshot filename slug from a URL. */
25
- function snapshotSlug(url) {
26
- return url.replace(/^https?:\/\//u, "").replace(/[^a-zA-Z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "page";
27
- }
28
- export function createResearchTools(runPath, config, onApprovalRequired, workspacePath) {
29
- /** Gate a tool behind user approval unless approvalMode is "auto". */
30
- async function gate(toolName, description) {
31
- if (config.approvalMode === "auto" || !onApprovalRequired)
32
- return true;
33
- return onApprovalRequired(toolName, description);
34
- }
35
- const claimsPath = join(runPath, "claims.jsonl");
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).")
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 }),
75
- writeFile: tool({
76
- description: `Create or overwrite a file. ${filePathHint}`,
77
- inputSchema: z.object({
78
- path: z.string().describe("File path."),
79
- content: z.string().describe("Full file content to write.")
80
- }),
81
- execute: async ({ path, content }) => {
82
- const resolved = resolveToolPath(runPath, workspacePath, path);
83
- const needsApproval = resolved.scope === "workspace"
84
- || path === "plan.md"
85
- || path === "report.md";
86
- if (needsApproval) {
87
- let description;
88
- if (path === "report.md" && resolved.scope === "run") {
89
- const existing = await readFile(resolved.abs, "utf8").catch(() => "");
90
- const parts = diffLines(existing, content);
91
- const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
92
- const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
93
- const diffPreview = parts
94
- .filter((p) => p.added || p.removed)
95
- .flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
96
- .slice(0, 20)
97
- .join("\n");
98
- description = `report.md (+${added} / -${removed} lines)\n\n${diffPreview}`;
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
- }
104
- else {
105
- description = `${path}\n\n${content.length > 600 ? `${content.slice(0, 600)}\n…` : content}`;
106
- }
107
- if (!await gate("writeFile", description))
108
- return `Write to ${resolved.displayPath} rejected by user.`;
109
- }
110
- await mkdir(dirname(resolved.abs), { recursive: true });
111
- await writeFile(resolved.abs, content);
112
- const eventType = path === "report.md" ? "report.updated" : path === "plan.md" ? "plan.updated" : "file.written";
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})`;
116
- }
117
- }),
118
- editFile: tool({
119
- description: `Replace an exact string in an existing file. ${filePathHint} The oldString must match exactly and be unique.`,
120
- inputSchema: z.object({
121
- path: z.string().describe("File path."),
122
- oldString: z.string().describe("Exact text to replace."),
123
- newString: z.string().describe("Replacement text.")
124
- }),
125
- execute: async ({ path, oldString, newString }) => {
126
- const resolved = resolveToolPath(runPath, workspacePath, path);
127
- const current = await readFile(resolved.abs, "utf8");
128
- const occurrences = current.split(oldString).length - 1;
129
- if (occurrences === 0) {
130
- return `No match for the given oldString in ${resolved.displayPath}. No changes made.`;
131
- }
132
- if (occurrences > 1) {
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
- }
145
- }
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}`;
149
- }
150
- }),
151
- createClaim: tool({
152
- description: "Record a structured research claim into claims.jsonl. Call after reading a source to track a significant finding.",
153
- inputSchema: z.object({
154
- id: z.string().describe("Unique claim ID, e.g. claim_001."),
155
- text: z.string().describe("The claim statement."),
156
- confidence: z.enum(["low", "medium", "high"]).describe("Confidence level."),
157
- sourceIds: z.array(z.string()).describe("Source IDs supporting this claim."),
158
- reason: z.string().describe("Why these sources support the claim.")
159
- }),
160
- execute: async ({ id, text, confidence, sourceIds, reason }) => {
161
- const claim = { id, text, confidence, status: "draft", sourceIds, reason, createdAt: new Date().toISOString() };
162
- await appendJsonl(claimsPath, claim);
163
- await logEvent(runPath, "claim.created", { id, confidence, sourceIds });
164
- return `Claim ${id} recorded.`;
165
- }
166
- }),
167
- verifyClaim: tool({
168
- description: "Update a claim's verification status after checking its evidence. Call after confirming or questioning a claim.",
169
- inputSchema: z.object({
170
- id: z.string().describe("Claim ID to update."),
171
- status: z.enum(["verified", "weak", "contradicted", "needs_review"]).describe("New verification status."),
172
- reason: z.string().describe("Explanation of the verification result.")
173
- }),
174
- execute: async ({ id, status, reason }) => {
175
- const claims = await readJsonl(claimsPath);
176
- const idx = claims.findIndex((c) => c.id === id);
177
- if (idx === -1) {
178
- return `Claim "${id}" not found. Present IDs: ${claims.map((c) => c.id).join(", ") || "none"}`;
179
- }
180
- claims[idx] = { ...claims[idx], status, reason };
181
- await mkdir(dirname(claimsPath), { recursive: true });
182
- await writeFile(claimsPath, claims.map((c) => JSON.stringify(c)).join("\n") + "\n");
183
- await logEvent(runPath, "claim.verified", { id, status });
184
- return `Claim ${id} → ${status}`;
185
- }
186
- }),
187
- readFile: tool({
188
- description: `Read a file. ${filePathHint}`,
189
- inputSchema: z.object({
190
- path: z.string().describe("File path.")
191
- }),
192
- execute: async ({ path }) => {
193
- const resolved = resolveToolPath(runPath, workspacePath, path);
194
- return truncate(await readFile(resolved.abs, "utf8"));
195
- }
196
- }),
197
- webSearch: tool({
198
- description: `Search the web with multiple parallel queries. Always use 3-5 queries per call to cover the topic from different angles.
199
- - Include date context in queries: "${new Date().getFullYear()}", "latest", "recent".
200
- - Use topic:"news" for breaking events, quality:"best" only when depth is essential.
201
- - Never invent sources — only cite URLs returned by this tool.`,
202
- inputSchema: z.object({
203
- queries: z.array(z.string()).min(3).max(10).describe("3-5 search queries covering different angles of the topic."),
204
- maxResults: z.array(z.number().int().min(1).max(20)).max(10).optional().describe("Max results per query (default 10)."),
205
- topics: z.array(z.enum(["general", "news"])).optional().describe("Topic type per query. Default: general."),
206
- quality: z.array(z.enum(["default", "best"])).optional().describe("Search quality per query. Use best sparingly."),
207
- startDates: z.array(z.string().nullable().optional()).optional().describe("ISO date filter per query (YYYY-MM-DD). Omit for no filter.")
208
- }),
209
- execute: async ({ queries, maxResults, topics, quality, startDates }) => {
210
- const perQuery = queries.map((_, i) => ({
211
- maxResults: maxResults?.[i] ?? 10,
212
- topic: (topics?.[i] ?? "general"),
213
- quality: (quality?.[i] ?? "default"),
214
- startDate: startDates?.[i] ?? null
215
- }));
216
- const searches = await multiSearchWeb(queries, perQuery, config);
217
- await logEvent(runPath, "tool.search", { queries, resultCount: searches.reduce((n, s) => n + s.results.length, 0) });
218
- return JSON.stringify(searches.map((s) => ({
219
- query: s.query,
220
- results: s.results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet, publishedDate: r.publishedDate }))
221
- })), null, 2);
222
- }
223
- }),
224
- readUrl: tool({
225
- description: "Fetch and extract the readable content of a web page so you can read and cite it.",
226
- inputSchema: z.object({
227
- url: z.string().describe("The URL to open and extract.")
228
- }),
229
- execute: async ({ url }) => {
230
- const page = await openUrl(url, config);
231
- const snapshotPath = await writeSnapshot(join(runPath, "snapshots"), snapshotSlug(url), page);
232
- const snapshotRel = relative(runPath, snapshotPath);
233
- await logEvent(runPath, "tool.open_url", { url, title: page.title, snapshot: snapshotRel });
234
- return truncate(`# ${page.title}\n(snapshot saved to ${snapshotRel} — record this as snapshotPath in sources.jsonl)\n\n${page.text}`);
235
- }
236
- }),
237
- listSkills: tool({
238
- description: "List the names and one-line summaries of all built-in research skills.",
239
- inputSchema: z.object({}),
240
- execute: async () => {
241
- return SKILL_NAMES.map((n) => {
242
- const s = getSkill(n);
243
- return `${n}: ${s?.summary ?? ""}`;
244
- }).join("\n");
245
- }
246
- }),
247
- readSkill: tool({
248
- description: "Read the full content of a built-in research skill by name. " +
249
- "The available skill names are listed in your instructions.",
250
- inputSchema: z.object({
251
- name: z.string().describe("Skill name exactly as listed in the instructions.")
252
- }),
253
- execute: async ({ name }) => {
254
- const skill = getSkill(name);
255
- if (!skill) {
256
- return `Unknown skill "${name}". Available: ${SKILL_NAMES.join(", ")}`;
257
- }
258
- return skill.content;
259
- }
260
- }),
261
- todo: createTodoTool(runPath),
262
- ...(config.files ? createFileTools(runPath, config, onApprovalRequired) : {})
263
- };
264
- }
265
- /**
266
- * Lightweight toolset for quick one-shot answers: web search + page reading,
267
- * plus a single escalation tool the model can call (with user approval) to
268
- * switch into the full research harness.
269
- */
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) : {};
273
- async function gate(toolName, description) {
274
- if (config.approvalMode === "auto" || !onApprovalRequired)
275
- return true;
276
- return onApprovalRequired(toolName, description);
277
- }
278
- return {
279
- webSearch: all.webSearch,
280
- readUrl: all.readUrl,
281
- todo: all.todo,
282
- ...coding,
283
- requestFullResearch: tool({
284
- description: "Escalate from quick one-shot mode to the FULL research harness (skills, plan.md, claims, verification, sources.jsonl, report.md). " +
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
- "For simple factual questions (e.g. 'what is the capital of France?') do NOT call this — just answer directly.",
287
- inputSchema: z.object({
288
- reason: z.string().describe("One sentence on why full research is warranted.")
289
- }),
290
- execute: async ({ reason }) => {
291
- const approved = await gate("requestFullResearch", `Escalate to the full research harness?\n\nReason: ${reason}`);
292
- if (!approved) {
293
- return "User declined escalation. Answer the question concisely now using webSearch and readUrl only — do not ask again.";
294
- }
295
- onEscalate?.();
296
- return "Approved. Stop now and do not call more tools — the full research harness will take over and complete the work.";
297
- }
298
- })
299
- };
300
- }
301
- /**
302
- * Workspace-aware tools for coding agent capabilities.
303
- * Unlike research tools, these operate on the full workspace, not just the run directory.
304
- */
305
- export function createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath) {
306
- async function gate(toolName, description) {
307
- if (config.approvalMode === "auto" || !onApprovalRequired)
308
- return true;
309
- return onApprovalRequired(toolName, description);
310
- }
311
- function resolveWorkspacePath(candidate) {
312
- return resolveInsideWorkspace(workspacePath, candidate);
313
- }
314
- return {
315
- listWorkspaceDir: tool({
316
- description: "List files and directories in the project workspace.",
317
- inputSchema: z.object({
318
- path: z.string().describe("Directory path (absolute or relative to workspace root)."),
319
- recursive: z.boolean().optional().describe("Recursively list subdirectories (default false).")
320
- }),
321
- execute: async ({ path, recursive }) => {
322
- const abs = resolveWorkspacePath(path);
323
- const cmd = recursive ? `find "${abs}" -type f -o -type d | head -200` : `ls -lah "${abs}"`;
324
- try {
325
- const { stdout } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024 });
326
- return truncate(stdout.trim());
327
- }
328
- catch (error) {
329
- const err = error;
330
- return `Failed to list ${path}: ${err.stderr ?? err.message}`;
331
- }
332
- }
333
- }),
334
- grepWorkspace: tool({
335
- description: "Search for a pattern across workspace files using grep. Essential for finding code references.",
336
- inputSchema: z.object({
337
- pattern: z.string().describe("Search pattern (regex supported)."),
338
- path: z.string().optional().describe("Directory to search (default: workspace root)."),
339
- filePattern: z.string().optional().describe("File pattern to include (e.g., '*.ts', '*.{js,jsx}').")
340
- }),
341
- execute: async ({ pattern, path, filePattern }) => {
342
- const searchPath = path ? resolveWorkspacePath(path) : workspacePath;
343
- const fileArg = filePattern ? `--include="${filePattern}"` : "";
344
- const cmd = `grep -rn ${fileArg} -E "${pattern.replace(/"/g, '\\"')}" "${searchPath}" 2>/dev/null | head -100`;
345
- try {
346
- const { stdout } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024 });
347
- const result = stdout.trim() || `No matches found for pattern: ${pattern}`;
348
- return truncate(result);
349
- }
350
- catch (error) {
351
- const err = error;
352
- const result = err.stdout?.trim() || `No matches found for pattern: ${pattern}`;
353
- return truncate(result);
354
- }
355
- }
356
- }),
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.",
361
- inputSchema: z.object({
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."),
368
- cwd: z.string().optional().describe("Working directory (default: workspace root)."),
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).")
371
- }),
372
- execute: async ({ action, command, taskId, cwd, timeoutMs, tailLines }) => {
373
- const act = action ?? "run";
374
- const workDir = cwd ? resolveWorkspacePath(cwd) : workspacePath;
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}`)) {
410
- return "Command rejected by user.";
411
- }
412
- if (runPath)
413
- await logEvent(runPath, "tool.bash", { command, cwd: workDir });
414
- try {
415
- const { stdout, stderr } = await execAsync(command, {
416
- cwd: workDir,
417
- timeout: timeoutMs ?? 60000,
418
- maxBuffer: 10 * 1024 * 1024,
419
- shell: "/bin/bash"
420
- });
421
- const out = [stdout, stderr].filter(Boolean).join("\n").trim();
422
- return truncate(out || "(no output)");
423
- }
424
- catch (error) {
425
- const err = error;
426
- const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
427
- return truncate(`Command failed:\n${out}`);
428
- }
429
- }
430
- })
431
- };
432
- }
@@ -1,60 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { resolveInsideRun } from "./tools.js";
3
- import { isRunArtifactPath, resolveProjectRoot, resolveToolPath } from "./workspace.js";
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`;
7
- describe("resolveInsideRun", () => {
8
- it("resolves a relative path inside the run dir", () => {
9
- expect(resolveInsideRun(RUN, "notes.md")).toBe(`${RUN}/notes.md`);
10
- });
11
- it("resolves a nested relative path inside the run dir", () => {
12
- expect(resolveInsideRun(RUN, "artifacts/output.txt")).toBe(`${RUN}/artifacts/output.txt`);
13
- });
14
- it("resolves an absolute path that is inside the run dir", () => {
15
- expect(resolveInsideRun(RUN, `${RUN}/plan.md`)).toBe(`${RUN}/plan.md`);
16
- });
17
- it("throws for a path that escapes with ../", () => {
18
- expect(() => resolveInsideRun(RUN, "../outside.txt")).toThrow("outside the run directory");
19
- });
20
- it("throws for a deep escape path", () => {
21
- expect(() => resolveInsideRun(RUN, "a/../../outside.txt")).toThrow("outside the run directory");
22
- });
23
- it("throws for an absolute path outside the run dir", () => {
24
- expect(() => resolveInsideRun(RUN, "/etc/passwd")).toThrow("outside the run directory");
25
- });
26
- it("throws for a home-dir escape", () => {
27
- const home = `${process.env.HOME ?? "/root"}/evil.sh`;
28
- expect(() => resolveInsideRun(RUN, home)).toThrow("outside the run directory");
29
- });
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
- });