@scira/cli 0.1.4 → 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 +53 -12
  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 +37 -7
  27. package/dist/ui/ink/hooks/use-agent-turn.js +17 -6
  28. package/dist/ui/ink/hooks/use-feed-lines.js +34 -7
  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 +205 -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 +15 -13
  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,10 +1,9 @@
1
1
  import { useCallback } from "react";
2
- import { readFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { listRuns, summarizeRun } from "../../../storage/run-store.js";
5
4
  import { getSession, attachSubscriber } from "../session-manager.js";
6
5
  export function useSession(o) {
7
- const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
6
+ const { config, currentRunPath, conversationRef, feedRef, turnsRef, startedRef, pendingPlanModeRef, setSessions, setRunState, setCurrentRunPath, setInputText, setCursorPos, setFeed, setUsage, setScrollOffset, setScreen, setMode, setPlanMode, setPendingPlanMode, setBusy, setApprovalPending, getSubscriber, } = o;
8
7
  const refreshSessions = useCallback(async () => {
9
8
  const runs = await listRuns(config);
10
9
  setSessions(runs);
@@ -14,8 +13,11 @@ export function useSession(o) {
14
13
  setRunState(await summarizeRun(currentRunPath));
15
14
  }, [currentRunPath]);
16
15
  const openRun = useCallback(async (runPath, initialQuestion) => {
17
- if (runPath !== currentRunPath)
18
- setPlanMode(false);
16
+ if (runPath !== currentRunPath) {
17
+ // Honor a plan-mode preference armed from the home screen, then disarm it.
18
+ setPlanMode(pendingPlanModeRef.current);
19
+ setPendingPlanMode(false);
20
+ }
19
21
  setCurrentRunPath(runPath);
20
22
  setInputText("");
21
23
  setCursorPos(0);
@@ -39,7 +41,7 @@ export function useSession(o) {
39
41
  return undefined;
40
42
  }
41
43
  try {
42
- const raw = await readFile(join(runPath, "convo.json"), "utf8");
44
+ const raw = await Bun.file(join(runPath, "convo.json")).text();
43
45
  const saved = JSON.parse(raw);
44
46
  if (saved.feed && saved.feed.length > 0) {
45
47
  const filteredFeed = saved.feed.filter((item) => !(item.kind === "status" && item.text === "This will wipe the conversation history. Press /rerun again to confirm."));
@@ -146,6 +146,26 @@ export function useSettings({ config, setConfig, screen, pushFeed, setNotice })
146
146
  await setEnvKey(name, value);
147
147
  return `${name} saved to ~/.scira/.env and active for this session. Use .scira/.env in a project to scope keys to that repo.`;
148
148
  }
149
+ if (cmd === "/thinking") {
150
+ if (!arg)
151
+ return `Claude Code thinking: ${config.harness.thinking}\nOptions: off, on, adaptive`;
152
+ if (!["off", "on", "adaptive"].includes(arg))
153
+ return `Unknown thinking mode "${arg}". Options: off, on, adaptive`;
154
+ const next = { ...config, harness: { ...config.harness, thinking: arg } };
155
+ setConfig(next);
156
+ await saveGlobalConfig(next);
157
+ return `Claude Code thinking set to ${arg}.`;
158
+ }
159
+ if (cmd === "/reasoning") {
160
+ if (!arg)
161
+ return `Codex reasoning effort: ${config.harness.reasoningEffort}\nOptions: low, medium, high`;
162
+ if (!["low", "medium", "high"].includes(arg))
163
+ return `Unknown reasoning effort "${arg}". Options: low, medium, high`;
164
+ const next = { ...config, harness: { ...config.harness, reasoningEffort: arg } };
165
+ setConfig(next);
166
+ await saveGlobalConfig(next);
167
+ return `Codex reasoning effort set to ${arg}.`;
168
+ }
149
169
  if (cmd === "/theme") {
150
170
  if (!arg) {
151
171
  const terminal = detectTerminalTheme();
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { readFile } from "node:fs/promises";
3
2
  import { createRun, getRunPaths, setRunTitle } from "../../../storage/run-store.js";
4
3
  import { readJsonl } from "../../../storage/jsonl.js";
5
4
  import { fmtDuration, fmtTokens, copyToClipboard } from "../lib/utils.js";
@@ -7,8 +6,8 @@ import { detachSubscriber, abortSession } from "../session-manager.js";
7
6
  import { saveGlobalMcpConfig } from "../../../config/load-config.js";
8
7
  export function useSubmit(o) {
9
8
  const { config, currentRunPath, sessions, selectedIdx, busy, usage, pendingRerun } = o.state;
10
- const { queuedPromptRef, fullModeRef, planModeRef, conversationRef, feedRef } = o.refs;
11
- const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
9
+ const { queuedPromptRef, fullModeRef, planModeRef, pendingPlanModeRef, conversationRef, feedRef } = o.refs;
10
+ const { setApprovalPending, setInputText, setCursorPos, setInputHistory, setHistoryIndex, setHelpOpen, setNotice, setBusy, setScreen, setFeed, setRunState, setPendingRerun, setMode, setPlanMode, setPendingPlanMode, setConfig, setMcpOpen, setHeroHidden, } = o.setters;
12
11
  const { pushFeed, refreshSessions, openRun, openMenu, handleSettings, runTurn, exit } = o.actions;
13
12
  const rerunConfirmRef = useRef(false);
14
13
  const abortTurn = useCallback(() => {
@@ -27,7 +26,7 @@ export function useSubmit(o) {
27
26
  void openRun(selected.path);
28
27
  return;
29
28
  }
30
- if (text === "q" || text === "/quit" || text === "/q") {
29
+ if (text === "/quit" || text === "/q") {
31
30
  exit();
32
31
  return;
33
32
  }
@@ -60,7 +59,15 @@ export function useSubmit(o) {
60
59
  setMcpOpen(true);
61
60
  return;
62
61
  }
63
- setNotice("Open a research session first to use /mcp enable/disable/add.");
62
+ setNotice("Open a session first to use /mcp enable/disable/add.");
63
+ return;
64
+ }
65
+ if (text === "/plan") {
66
+ const next = !pendingPlanModeRef.current;
67
+ setPendingPlanMode(next);
68
+ setNotice(next
69
+ ? "Plan mode armed — the next run you start will open in plan mode."
70
+ : "Plan mode disarmed.");
64
71
  return;
65
72
  }
66
73
  if (text.startsWith("/")) {
@@ -136,7 +143,7 @@ export function useSubmit(o) {
136
143
  void openMenu("provider");
137
144
  return;
138
145
  }
139
- if (["/key", "/keys", "/llm", "/theme"].includes(text.split(/\s+/u)[0])) {
146
+ if (["/key", "/keys", "/llm", "/theme", "/thinking", "/reasoning"].includes(text.split(/\s+/u)[0])) {
140
147
  void (async () => {
141
148
  const result = await handleSettings(text);
142
149
  if (result)
@@ -147,7 +154,7 @@ export function useSubmit(o) {
147
154
  if (text === "/report") {
148
155
  void (async () => {
149
156
  try {
150
- const report = await readFile(getRunPaths(currentRunPath).report, "utf8");
157
+ const report = await Bun.file(getRunPaths(currentRunPath).report).text();
151
158
  pushFeed({ kind: "text", text: report });
152
159
  }
153
160
  catch {
@@ -290,7 +297,7 @@ export function useSubmit(o) {
290
297
  void (async () => {
291
298
  const currentSession = sessions.find(s => s.path === currentRunPath);
292
299
  const report = currentSession?.isFull
293
- ? await readFile(getRunPaths(currentRunPath).report, "utf8").catch(() => "")
300
+ ? await Bun.file(getRunPaths(currentRunPath).report).text().catch(() => "")
294
301
  : "";
295
302
  const lastText = [...feedRef.current].reverse().find((it) => it.kind === "text")?.text ?? "";
296
303
  const content = report.trim() || lastText;
@@ -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",
@@ -20,6 +57,10 @@ export function isCollapsibleToolName(name) {
20
57
  return name.length > 0;
21
58
  }
22
59
  export function defaultCollapsedToolName(name) {
60
+ // Chrome DevTools MCP tools (prefixed `devtools_`) produce long browser
61
+ // snapshots/output, so collapse them by default like the built-in tools.
62
+ if (name.startsWith("devtools_"))
63
+ return true;
23
64
  return DEFAULT_COLLAPSED_TOOLS.has(name);
24
65
  }
25
66
  export function isToolItemCollapsed(id, name, status, expandState) {
@@ -287,7 +328,8 @@ function formatBody(name, result, width, theme) {
287
328
  }
288
329
  }
289
330
  /** One-line preview for a collapsed tool header. */
290
- export function formatToolResultPreview(name, inputSummary, result, status) {
331
+ export function formatToolResultPreview(rawName, inputSummary, result, status) {
332
+ const name = canonicalToolName(rawName);
291
333
  const input = inputSummary.replace(/\s+/gu, " ").trim();
292
334
  if (status === "running")
293
335
  return input ? `${input} · running…` : "running…";
@@ -341,10 +383,171 @@ export function formatToolResultPreview(name, inputSummary, result, status) {
341
383
  const first = result.replace(/\s+/gu, " ").trim();
342
384
  return first.length > 140 ? `${first.slice(0, 137)}…` : first;
343
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
+ }
344
528
  /** Multi-line formatted tool output for the feed panel. */
345
- 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);
346
532
  if (!expanded)
347
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
+ }
348
551
  const width = Math.max(16, contentWidth);
349
552
  const lines = [];
350
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.4",
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.48",
43
- "@ai-sdk/openai-compatible": "^2.0.49",
44
- "@ai-sdk/xai": "^3.0.94",
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",
46
+ "@ai-sdk/openai-compatible": "^2.0.50",
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.202",
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"