@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,5 +1,4 @@
1
1
  import { readdirSync, statSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { FILE_MENTION_SKIP, FILE_MENTION_MAX_CHARS } from "../constants.js";
5
4
  export function listMentionableFiles(root = process.cwd(), max = 300) {
@@ -52,7 +51,7 @@ export async function promptWithFileMentions(prompt) {
52
51
  for (const file of files) {
53
52
  const abs = join(process.cwd(), file);
54
53
  try {
55
- const content = await readFile(abs, "utf8");
54
+ const content = await Bun.file(abs).text();
56
55
  const body = content.length > FILE_MENTION_MAX_CHARS
57
56
  ? `${content.slice(0, FILE_MENTION_MAX_CHARS)}\n...[truncated ${content.length - FILE_MENTION_MAX_CHARS} chars]`
58
57
  : content;
@@ -1,8 +1,45 @@
1
+ import { diffLines } from "diff";
1
2
  import { markdownToSegLines } from "./markdown.js";
2
3
  import { wrapText } from "./utils.js";
4
+ const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
5
+ /** Map Claude Code / Codex built-in (and harness host) tool names onto Scira's renderers. */
6
+ const CANONICAL_TOOL = {
7
+ // Scira host tools exposed to the CLI
8
+ multiWebSearch: "webSearch",
9
+ // Claude Code built-ins
10
+ Read: "readFile",
11
+ Write: "writeFile",
12
+ Edit: "editFile",
13
+ MultiEdit: "editFile",
14
+ NotebookEdit: "editFile",
15
+ Bash: "bash",
16
+ BashOutput: "bash",
17
+ Grep: "grepWorkspace",
18
+ Glob: "listWorkspaceDir",
19
+ LS: "listWorkspaceDir",
20
+ TodoWrite: "todo",
21
+ WebFetch: "readUrl",
22
+ WebSearch: "webSearch",
23
+ // Codex built-ins
24
+ shell: "bash",
25
+ };
26
+ /** Strip the harness host-tool MCP prefix so `mcp__harness-tools__readUrl` reads as `readUrl`. */
27
+ export function displayToolName(name) {
28
+ return name.startsWith(HARNESS_TOOL_PREFIX) ? name.slice(HARNESS_TOOL_PREFIX.length) : name;
29
+ }
30
+ /**
31
+ * Resolve a harness/CLI tool name to the Scira renderer key. The harness exposes
32
+ * our host tools as `mcp__harness-tools__*` and the CLIs have their own builtin
33
+ * names (Read, Bash, Grep, …); both should render like Scira's equivalents.
34
+ */
35
+ export function canonicalToolName(name) {
36
+ const stripped = displayToolName(name);
37
+ return CANONICAL_TOOL[stripped] ?? stripped;
38
+ }
3
39
  /** Tools that start collapsed in the timeline (long output). */
4
40
  export const DEFAULT_COLLAPSED_TOOLS = new Set([
5
41
  "webSearch",
42
+ "multiWebSearch",
6
43
  "readUrl",
7
44
  "readFile",
8
45
  "readWorkspaceFile",
@@ -291,7 +328,8 @@ function formatBody(name, result, width, theme) {
291
328
  }
292
329
  }
293
330
  /** One-line preview for a collapsed tool header. */
294
- export function formatToolResultPreview(name, inputSummary, result, status) {
331
+ export function formatToolResultPreview(rawName, inputSummary, result, status) {
332
+ const name = canonicalToolName(rawName);
295
333
  const input = inputSummary.replace(/\s+/gu, " ").trim();
296
334
  if (status === "running")
297
335
  return input ? `${input} · running…` : "running…";
@@ -345,10 +383,171 @@ export function formatToolResultPreview(name, inputSummary, result, status) {
345
383
  const first = result.replace(/\s+/gu, " ").trim();
346
384
  return first.length > 140 ? `${first.slice(0, 137)}…` : first;
347
385
  }
386
+ // --- Dedicated renderers for Claude Code / Codex built-in tools ---
387
+ function parseObj(s) {
388
+ if (!s)
389
+ return null;
390
+ try {
391
+ const v = JSON.parse(s);
392
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ /** Unified-ish diff between two strings: removed lines red, added green, a little context dim. */
399
+ function diffSegLines(oldStr, newStr, width, theme) {
400
+ const parts = diffLines(oldStr ?? "", newStr ?? "");
401
+ const out = [];
402
+ const MAX = 60;
403
+ let count = 0;
404
+ for (const part of parts) {
405
+ const sign = part.added ? "+" : part.removed ? "-" : " ";
406
+ const color = part.added ? theme.success : part.removed ? theme.error : theme.textDim;
407
+ const linesIn = part.value.replace(/\n$/u, "").split("\n");
408
+ for (const ln of linesIn) {
409
+ if (count >= MAX) {
410
+ out.push([seg("… diff truncated", { dim: true, color: theme.textDim })]);
411
+ return out;
412
+ }
413
+ for (const wrapped of wrapText(`${sign} ${ln}`, width)) {
414
+ out.push([seg(wrapped, { color, dim: !part.added && !part.removed })]);
415
+ }
416
+ count++;
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+ function pathHeader(p, theme) {
422
+ return [seg("path ", { dim: true, color: theme.textDim }), seg(String(p ?? ""), { color: theme.text })];
423
+ }
424
+ /** Edit / MultiEdit → file path + colored diff(s). */
425
+ function formatEditBody(input, width, theme) {
426
+ const lines = [pathHeader(input.file_path ?? input.notebook_path, theme)];
427
+ const edits = Array.isArray(input.edits)
428
+ ? input.edits
429
+ : [{ old_string: input.old_string, new_string: input.new_string }];
430
+ edits.forEach((e, i) => {
431
+ if (edits.length > 1)
432
+ lines.push([seg(`edit ${i + 1}`, { dim: true, color: theme.textDim })]);
433
+ lines.push(...diffSegLines(String(e.old_string ?? ""), String(e.new_string ?? input.new_source ?? ""), width, theme));
434
+ });
435
+ return lines;
436
+ }
437
+ /** TodoWrite → checklist with status glyphs. */
438
+ function formatTodoBody(input, width, theme) {
439
+ const todos = Array.isArray(input.todos) ? input.todos : [];
440
+ if (todos.length === 0)
441
+ return [[seg("(no todos)", { dim: true, color: theme.textDim })]];
442
+ return todos.flatMap((t) => {
443
+ const status = String(t.status ?? "pending");
444
+ const glyph = status === "completed" ? "☑" : status === "in_progress" ? "◐" : "☐";
445
+ const color = status === "completed" ? theme.success : status === "in_progress" ? theme.warning : theme.textDim;
446
+ const text = String(t.content ?? t.activeForm ?? "");
447
+ const wrapped = wrapText(text, Math.max(8, width - 2));
448
+ return wrapped.map((w, i) => [seg(i === 0 ? `${glyph} ` : " ", { color }), seg(w, { color: status === "completed" ? theme.textDim : theme.text })]);
449
+ });
450
+ }
451
+ /** Write → file path + content preview. */
452
+ function formatWriteBody(input, width, theme) {
453
+ const lines = [pathHeader(input.file_path, theme), blank()];
454
+ const allLines = String(input.content ?? "").split("\n");
455
+ const shown = allLines.slice(0, 40);
456
+ for (const ln of shown)
457
+ lines.push(...plainLines(ln, width, { color: theme.text }));
458
+ if (allLines.length > shown.length)
459
+ lines.push([seg(`… +${allLines.length - shown.length} more lines`, { dim: true, color: theme.textDim })]);
460
+ return lines;
461
+ }
462
+ /** WebFetch → url + fetched/answer text. */
463
+ function formatWebFetchBody(input, result, width, theme) {
464
+ const lines = [];
465
+ const url = input?.url;
466
+ if (url)
467
+ lines.push([seg("url ", { dim: true, color: theme.textDim }), seg(String(url), { color: theme.accent, underline: true, url: String(url) })]);
468
+ if (result.trim()) {
469
+ if (lines.length > 0)
470
+ lines.push(blank());
471
+ lines.push(...plainLines(result, width, { color: theme.text }));
472
+ }
473
+ return lines;
474
+ }
475
+ /** Task / Agent (subagent) → description + output. */
476
+ function formatSubagentBody(input, result, width, theme) {
477
+ const lines = [];
478
+ const desc = input?.description ?? input?.subagent_type;
479
+ if (desc)
480
+ lines.push([seg("task ", { dim: true, color: theme.textDim }), seg(String(desc), { color: theme.text })]);
481
+ if (result.trim()) {
482
+ if (lines.length > 0)
483
+ lines.push(blank());
484
+ lines.push(...markdownToSegLines(result, width, theme));
485
+ }
486
+ return lines;
487
+ }
488
+ /** ToolSearch → query + which tool reference it loaded. */
489
+ function formatToolSearchBody(input, result, width, theme) {
490
+ const lines = [];
491
+ if (input?.query)
492
+ lines.push([seg("query ", { dim: true, color: theme.textDim }), seg(String(input.query), { color: theme.text })]);
493
+ const ref = parseObj(result);
494
+ if (ref?.tool_name)
495
+ lines.push([seg("loaded ", { dim: true, color: theme.textDim }), seg(String(ref.tool_name), { color: theme.accent })]);
496
+ else if (result.trim())
497
+ lines.push(...plainLines(result, width, { color: theme.textDim }));
498
+ return lines;
499
+ }
500
+ /**
501
+ * Dedicated body for a Claude Code / Codex built-in tool, keyed by its real
502
+ * (un-prefixed) name. Returns null to fall through to the generic renderer.
503
+ */
504
+ function formatBuiltinBody(real, rawInput, result, width, theme) {
505
+ const input = parseObj(rawInput);
506
+ switch (real) {
507
+ case "Edit":
508
+ case "edit":
509
+ case "MultiEdit":
510
+ case "NotebookEdit":
511
+ return input ? formatEditBody(input, width, theme) : null;
512
+ case "TodoWrite":
513
+ return input ? formatTodoBody(input, width, theme) : null;
514
+ case "Write":
515
+ case "write":
516
+ return input ? formatWriteBody(input, width, theme) : null;
517
+ case "WebFetch":
518
+ return formatWebFetchBody(input, result, width, theme);
519
+ case "Task":
520
+ case "Agent":
521
+ return formatSubagentBody(input, result, width, theme);
522
+ case "ToolSearch":
523
+ return formatToolSearchBody(input, result, width, theme);
524
+ default:
525
+ return null;
526
+ }
527
+ }
348
528
  /** Multi-line formatted tool output for the feed panel. */
349
- export function formatToolResultLines(name, inputSummary, result, status, contentWidth, theme, expanded = true) {
529
+ export function formatToolResultLines(rawName, inputSummary, rawResult, status, contentWidth, theme, expanded = true, rawInput) {
530
+ const name = canonicalToolName(rawName);
531
+ const real = displayToolName(rawName);
350
532
  if (!expanded)
351
533
  return [];
534
+ // Bound the text we lay out per render — a terminal can't show a 1MB result,
535
+ // and wrapping/parsing that much on every frame is what stalls the renderer.
536
+ // The full result stays in the stored feed; only what we format is capped.
537
+ const MAX_RENDER = 60_000;
538
+ const result = rawResult && rawResult.length > MAX_RENDER
539
+ ? `${rawResult.slice(0, MAX_RENDER)}\n\n… [${rawResult.length - MAX_RENDER} more chars not shown]`
540
+ : rawResult;
541
+ // Dedicated built-in tool rendering (diffs, checklists, …). Input-driven ones
542
+ // (Edit, Write, TodoWrite) render even while the tool is still running.
543
+ if (status !== "error") {
544
+ const builtin = formatBuiltinBody(real, rawInput, result ?? "", Math.max(16, contentWidth), theme);
545
+ if (builtin && builtin.length > 0) {
546
+ if (status === "running" && !result?.trim())
547
+ builtin.push([seg("running…", { dim: true, color: theme.textDim })]);
548
+ return builtin;
549
+ }
550
+ }
352
551
  const width = Math.max(16, contentWidth);
353
552
  const lines = [];
354
553
  const input = inputSummary.replace(/\s+/gu, " ").trim();
@@ -1,29 +1,31 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3
- import { spawn } from "node:child_process";
2
+ import * as Bun from "bun";
3
+ import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { FULL_MODE_TRIGGERS } from "../constants.js";
8
8
  export const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../../package.json"), "utf8")).version;
9
9
  /** Pipe text to the OS clipboard (pbcopy / clip / xclip). Resolves false when unavailable. */
10
- export function copyToClipboard(text) {
11
- return new Promise((res) => {
12
- const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
13
- const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
14
- const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
15
- child.on("error", () => res(false));
16
- child.on("close", (code) => res(code === 0));
17
- child.stdin.write(text);
18
- child.stdin.end();
19
- });
10
+ export async function copyToClipboard(text) {
11
+ const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
12
+ const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
13
+ try {
14
+ const proc = Bun.spawn([cmd, ...args], { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
15
+ proc.stdin.write(text);
16
+ await proc.stdin.end();
17
+ return (await proc.exited) === 0;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
20
22
  }
21
23
  function historyFile(runDirectory) {
22
24
  return resolve(process.cwd(), runDirectory, "..", "input-history.json");
23
25
  }
24
26
  export async function loadInputHistory(runDirectory) {
25
27
  try {
26
- const parsed = JSON.parse(await readFile(historyFile(runDirectory), "utf8"));
28
+ const parsed = await Bun.file(historyFile(runDirectory)).json();
27
29
  return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(-50) : [];
28
30
  }
29
31
  catch {
@@ -34,7 +36,7 @@ export async function saveInputHistory(runDirectory, history) {
34
36
  try {
35
37
  const file = historyFile(runDirectory);
36
38
  await mkdir(dirname(file), { recursive: true });
37
- await writeFile(file, JSON.stringify(history.slice(-50), null, 2));
39
+ await Bun.write(file, JSON.stringify(history.slice(-50), null, 2));
38
40
  }
39
41
  catch { /* non-fatal */ }
40
42
  }
@@ -200,15 +202,17 @@ export function linkAtMouseColumn(links, x) {
200
202
  return undefined;
201
203
  }
202
204
  /** Open a URL in the system browser. */
203
- export function openExternalUrl(url) {
204
- return new Promise((res) => {
205
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
206
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
207
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
208
- child.on("error", () => res(false));
209
- child.on("close", (code) => res(code === 0));
210
- child.unref();
211
- });
205
+ export async function openExternalUrl(url) {
206
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
207
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
208
+ try {
209
+ const proc = Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore" });
210
+ proc.unref();
211
+ return (await proc.exited) === 0;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
212
216
  }
213
217
  /** True if the prompt clearly asks for full, report-grade research. */
214
218
  export function wantsFullResearch(prompt) {
@@ -279,16 +283,32 @@ function toolOutputText(output) {
279
283
  return String(output);
280
284
  }
281
285
  }
282
- export function summarizeToolInput(name, input) {
286
+ // Harness/CLI tool names mapped to Scira renderer keys. Kept local to avoid a
287
+ // circular import with tool-result.ts (which imports from this file).
288
+ const HARNESS_TOOL_PREFIX = "mcp__harness-tools__";
289
+ const SUMMARY_CANONICAL = {
290
+ multiWebSearch: "webSearch",
291
+ Read: "readFile", Write: "writeFile", Edit: "editFile", MultiEdit: "editFile", NotebookEdit: "editFile",
292
+ Bash: "bash", BashOutput: "bash", shell: "bash",
293
+ Grep: "grepWorkspace", Glob: "listWorkspaceDir", LS: "listWorkspaceDir",
294
+ TodoWrite: "todo", WebFetch: "readUrl", WebSearch: "webSearch",
295
+ };
296
+ export function summarizeToolInput(rawName, input) {
297
+ const stripped = rawName.startsWith(HARNESS_TOOL_PREFIX) ? rawName.slice(HARNESS_TOOL_PREFIX.length) : rawName;
298
+ const name = SUMMARY_CANONICAL[stripped] ?? stripped;
283
299
  const obj = (input ?? {});
300
+ const path = obj.path ?? obj.file_path ?? obj.notebook_path;
284
301
  if (name === "bash" || name === "runBash" || name === "runWorkspaceCommand") {
285
302
  const action = obj.action;
286
303
  if (action && action !== "run")
287
304
  return `${action}${obj.taskId ? ` ${obj.taskId}` : ""}`;
288
305
  return String(obj.command ?? "");
289
306
  }
290
- if (name === "todo")
307
+ if (name === "todo") {
308
+ if (Array.isArray(obj.todos))
309
+ return `${obj.todos.length} item(s)`;
291
310
  return `${String(obj.action ?? "list")}${obj.id ? ` ${obj.id}` : ""}`;
311
+ }
292
312
  if (name === "webSearch" || name === "xSearch") {
293
313
  const queries = Array.isArray(obj.queries) ? obj.queries : [];
294
314
  return queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : String(obj.query ?? "");
@@ -296,14 +316,18 @@ export function summarizeToolInput(name, input) {
296
316
  if (name === "readUrl")
297
317
  return String(obj.url ?? "");
298
318
  if (name === "writeFile" || name === "editFile" || name === "readFile" || name === "readWorkspaceFile" || name === "writeWorkspaceFile" || name === "editWorkspaceFile") {
299
- return String(obj.path ?? "");
319
+ return String(path ?? "");
300
320
  }
301
321
  if (name === "listWorkspaceDir" || name === "grepWorkspace")
302
- return String(obj.path ?? obj.pattern ?? "");
322
+ return String(obj.pattern ?? path ?? "");
303
323
  if (name === "readSkill" || name === "listSkills")
304
- return String(obj.name ?? "");
324
+ return String(obj.name ?? obj.skill ?? "");
305
325
  if (name === "createClaim" || name === "verifyClaim")
306
326
  return String(obj.id ?? "");
327
+ if (stripped === "ToolSearch")
328
+ return String(obj.query ?? "");
329
+ if (stripped === "Task" || stripped === "Agent")
330
+ return String(obj.description ?? obj.subagent_type ?? "");
307
331
  try {
308
332
  return JSON.stringify(obj).slice(0, 80);
309
333
  }
@@ -1,5 +1,4 @@
1
1
  import { readFileSync, unwatchFile, watchFile } from "node:fs";
2
- import { execSync } from "node:child_process";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  export const DARK_THEME = {
@@ -124,11 +123,9 @@ function readEditorColorTheme() {
124
123
  function readSystemAppearance() {
125
124
  if (process.platform === "darwin") {
126
125
  try {
127
- const style = execSync("defaults read -g AppleInterfaceStyle 2>/dev/null", {
128
- encoding: "utf8",
129
- stdio: ["ignore", "pipe", "ignore"],
130
- }).trim();
131
- return style === "Dark" ? "dark" : "light";
126
+ const r = Bun.spawnSync(["defaults", "read", "-g", "AppleInterfaceStyle"], { stdout: "pipe", stderr: "ignore" });
127
+ // The key is absent (and `defaults` exits non-zero) in light mode.
128
+ return r.stdout.toString().trim() === "Dark" ? "dark" : "light";
132
129
  }
133
130
  catch {
134
131
  return "light";
@@ -136,10 +133,8 @@ function readSystemAppearance() {
136
133
  }
137
134
  if (process.platform === "linux") {
138
135
  try {
139
- const scheme = execSync("gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null", {
140
- encoding: "utf8",
141
- stdio: ["ignore", "pipe", "ignore"],
142
- }).trim();
136
+ const r = Bun.spawnSync(["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], { stdout: "pipe", stderr: "ignore" });
137
+ const scheme = r.stdout.toString().trim();
143
138
  if (/dark/i.test(scheme))
144
139
  return "dark";
145
140
  if (/light/i.test(scheme))
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { diffLines } from "diff";
3
3
  import { createRun, listRuns, getRunPaths } from "../storage/run-store.js";
4
- import { runResearchAgent } from "../agent/research-agent.js";
4
+ import { runResearchAgent } from "../agent/main-agent.js";
5
5
  /** Compare two report.md texts and return a human-readable diff summary. */
6
6
  export function diffReports(prev, next) {
7
7
  const changes = diffLines(prev, next);
@@ -47,7 +47,7 @@ export async function watchLoop(opts, signal) {
47
47
  opts.onRunStart?.(runPath, tick);
48
48
  try {
49
49
  await runResearchAgent(runPath, goal, config);
50
- const nextReport = await readFile(getRunPaths(runPath).report, "utf8").catch(() => "");
50
+ const nextReport = await Bun.file(getRunPaths(runPath).report).text().catch(() => "");
51
51
  const diffText = diffReports(prevReport, nextReport);
52
52
  opts.onRunComplete?.(runPath, diffText, tick);
53
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scira/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Scira — terminal-native AI research agent with grounded sources, verified claims, and local run storage.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,22 +35,26 @@
35
35
  "dev": "bun src/cli/index.ts",
36
36
  "docs:dev": "bun run --cwd docs dev",
37
37
  "docs:build": "NODE_ENV=production bun run --cwd docs build",
38
- "test": "vitest run",
39
- "test:watch": "vitest"
38
+ "test": "bun test src",
39
+ "test:watch": "bun test --watch src"
40
40
  },
41
41
  "dependencies": {
42
- "@ai-sdk/mcp": "^1.0.49",
42
+ "@ai-sdk/harness": "^1.0.0-canary.9",
43
+ "@ai-sdk/harness-claude-code": "^1.0.0-canary.5",
44
+ "@ai-sdk/harness-codex": "^1.0.0-canary.5",
45
+ "@ai-sdk/mcp": "^1.0.50",
43
46
  "@ai-sdk/openai-compatible": "^2.0.50",
44
47
  "@ai-sdk/xai": "^3.0.95",
45
48
  "@clack/prompts": "^1.5.1",
46
- "@mendable/firecrawl-js": "^4.25.3",
49
+ "@mendable/firecrawl-js": "^4.25.4",
47
50
  "@modelcontextprotocol/sdk": "^1.29.0",
48
51
  "@mozilla/readability": "^0.6.0",
49
- "ai": "^6.0.203",
52
+ "ai": "^6.0.204",
53
+ "bun": "^1.3.14",
50
54
  "diff": "^9.0.0",
51
55
  "exa-js": "^2.13.0",
52
56
  "files-sdk": "^1.8.0",
53
- "ink": "^7.0.5",
57
+ "ink": "^7.0.6",
54
58
  "ink-link": "^5.0.0",
55
59
  "jsdom": "^29.1.1",
56
60
  "parallel-web": "^1.1.0",
@@ -62,14 +66,12 @@
62
66
  "zod": "^4.4.3"
63
67
  },
64
68
  "devDependencies": {
65
- "bun-types": "^1.3.14",
69
+ "@types/bun": "^1.3.14",
66
70
  "@types/jsdom": "^28.0.3",
67
71
  "@types/node": "^25.9.3",
68
72
  "@types/react": "^19.2.17",
69
- "@vitest/coverage-v8": "^4.1.8",
70
73
  "tsx": "^4.22.4",
71
- "typescript": "^6.0.3",
72
- "vitest": "^4.1.8"
74
+ "typescript": "^6.0.3"
73
75
  },
74
76
  "engines": {
75
77
  "bun": ">=1.2.0"
@@ -1,173 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
4
- const MAX_OUTPUT_LINES = 500;
5
- const MAX_TAIL_CHARS = 4000;
6
- function nextTaskId(existing) {
7
- const nums = existing
8
- .map((t) => /^task_(\d+)$/u.exec(t.id)?.[1])
9
- .filter((n) => Boolean(n))
10
- .map((n) => Number.parseInt(n, 10));
11
- const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
12
- return `task_${String(next).padStart(3, "0")}`;
13
- }
14
- function tailText(lines, maxChars = MAX_TAIL_CHARS) {
15
- const joined = lines.join("\n");
16
- if (joined.length <= maxChars)
17
- return joined;
18
- return `…[truncated]\n${joined.slice(-maxChars)}`;
19
- }
20
- export class BackgroundTaskManager {
21
- persistPath;
22
- defaultCwd;
23
- runtime = new Map();
24
- records = [];
25
- loaded = false;
26
- constructor(persistPath, defaultCwd) {
27
- this.persistPath = persistPath;
28
- this.defaultCwd = defaultCwd;
29
- }
30
- async ensureLoaded() {
31
- if (this.loaded)
32
- return;
33
- this.loaded = true;
34
- try {
35
- const raw = await readFile(this.persistPath, "utf8");
36
- const parsed = JSON.parse(raw);
37
- if (Array.isArray(parsed)) {
38
- this.records = parsed.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string");
39
- }
40
- }
41
- catch {
42
- this.records = [];
43
- }
44
- }
45
- async persist() {
46
- await mkdir(dirname(this.persistPath), { recursive: true });
47
- await writeFile(this.persistPath, JSON.stringify(this.records, null, 2) + "\n");
48
- }
49
- syncRecord(task) {
50
- const idx = this.records.findIndex((r) => r.id === task.record.id);
51
- task.record.outputTail = tailText(task.output);
52
- if (idx === -1)
53
- this.records.push({ ...task.record });
54
- else
55
- this.records[idx] = { ...task.record };
56
- }
57
- async spawn(command, cwd) {
58
- await this.ensureLoaded();
59
- const id = nextTaskId(this.records);
60
- const workDir = cwd ?? this.defaultCwd;
61
- const proc = spawn(command, {
62
- cwd: workDir,
63
- shell: "/bin/bash",
64
- env: process.env,
65
- detached: false,
66
- stdio: ["ignore", "pipe", "pipe"]
67
- });
68
- const record = {
69
- id,
70
- command,
71
- cwd: workDir,
72
- pid: proc.pid ?? 0,
73
- startedAt: new Date().toISOString(),
74
- status: "running",
75
- exitCode: null,
76
- outputTail: ""
77
- };
78
- const output = [];
79
- const append = (chunk) => {
80
- const text = chunk.toString();
81
- for (const line of text.split("\n")) {
82
- if (line.length > 0)
83
- output.push(line);
84
- }
85
- while (output.length > MAX_OUTPUT_LINES)
86
- output.shift();
87
- const rt = this.runtime.get(id);
88
- if (rt) {
89
- rt.output = output;
90
- rt.record.outputTail = tailText(output);
91
- }
92
- };
93
- proc.stdout?.on("data", append);
94
- proc.stderr?.on("data", append);
95
- const runtime = { record, proc, output };
96
- this.runtime.set(id, runtime);
97
- this.records.push({ ...record });
98
- await this.persist();
99
- proc.on("close", (code) => {
100
- record.status = "exited";
101
- record.exitCode = code;
102
- record.outputTail = tailText(output);
103
- this.syncRecord(runtime);
104
- void this.persist();
105
- this.runtime.delete(id);
106
- });
107
- proc.on("error", (err) => {
108
- output.push(`[spawn error] ${err.message}`);
109
- record.status = "exited";
110
- record.exitCode = 1;
111
- record.outputTail = tailText(output);
112
- this.syncRecord(runtime);
113
- void this.persist();
114
- this.runtime.delete(id);
115
- });
116
- return { ...record };
117
- }
118
- async list() {
119
- await this.ensureLoaded();
120
- for (const rt of this.runtime.values()) {
121
- rt.record.outputTail = tailText(rt.output);
122
- this.syncRecord(rt);
123
- }
124
- return this.records.map((r) => {
125
- const live = this.runtime.get(r.id);
126
- return live ? { ...live.record } : { ...r };
127
- });
128
- }
129
- async getOutput(taskId, tailLines = 50) {
130
- await this.ensureLoaded();
131
- const live = this.runtime.get(taskId);
132
- if (live) {
133
- const lines = live.output.slice(-tailLines);
134
- return lines.length > 0 ? lines.join("\n") : "(no output yet)";
135
- }
136
- const rec = this.records.find((r) => r.id === taskId);
137
- if (!rec)
138
- return `Task "${taskId}" not found.`;
139
- const lines = rec.outputTail.split("\n").slice(-tailLines);
140
- return lines.length > 0 ? lines.join("\n") : "(no output)";
141
- }
142
- async kill(taskId) {
143
- await this.ensureLoaded();
144
- const live = this.runtime.get(taskId);
145
- if (live) {
146
- live.proc.kill("SIGTERM");
147
- live.record.status = "killed";
148
- live.record.exitCode = live.record.exitCode ?? 143;
149
- this.syncRecord(live);
150
- await this.persist();
151
- return `Killed ${taskId} (pid ${live.record.pid}).`;
152
- }
153
- const rec = this.records.find((r) => r.id === taskId);
154
- if (!rec)
155
- return `Task "${taskId}" not found.`;
156
- if (rec.status !== "running")
157
- return `${taskId} is already ${rec.status}.`;
158
- rec.status = "killed";
159
- await this.persist();
160
- return `Marked ${taskId} as killed (process not tracked in this session).`;
161
- }
162
- async formatContextForAgent() {
163
- const tasks = await this.list();
164
- const active = tasks.filter((t) => t.status === "running");
165
- if (active.length === 0)
166
- return "";
167
- const lines = active.map((t) => ` - ${t.id}: [running pid ${t.pid}] ${t.command} (cwd: ${t.cwd})`);
168
- return `\nActive background tasks:\n${lines.join("\n")}\nUse bash with action "output" and taskId to read logs, or action "kill" to stop a task.\n`;
169
- }
170
- }
171
- export function createBackgroundTaskManager(runPath, workspacePath) {
172
- return new BackgroundTaskManager(join(runPath, "background-tasks.json"), workspacePath);
173
- }