@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
@@ -0,0 +1,143 @@
1
+ import { promises as fs } from "node:fs";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ /** Ask the OS for a free TCP port by binding to :0, then release it for the bridge to claim. */
5
+ function reserveFreePort() {
6
+ return new Promise((resolve, reject) => {
7
+ const srv = net.createServer();
8
+ srv.once("error", reject);
9
+ srv.listen(0, "127.0.0.1", () => {
10
+ const address = srv.address();
11
+ const port = typeof address === "object" && address ? address.port : 0;
12
+ srv.close(() => (port ? resolve(port) : reject(new Error("Could not reserve a free port"))));
13
+ });
14
+ });
15
+ }
16
+ function spawnProcess(command, opts, rootDir, baseEnv, stripEnv) {
17
+ const env = { ...process.env, ...baseEnv, ...opts.env };
18
+ for (const name of stripEnv)
19
+ delete env[name];
20
+ // Bun.spawn takes an argv array, not a shell string — wrap in the platform shell.
21
+ const argv = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", command];
22
+ const proc = Bun.spawn(argv, {
23
+ cwd: opts.workingDirectory ?? rootDir,
24
+ env,
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ ...(opts.abortSignal ? { signal: opts.abortSignal } : {}),
28
+ });
29
+ return {
30
+ pid: proc.pid,
31
+ // Bun.spawn pipes are already web ReadableStreams — no node:stream bridging.
32
+ stdout: proc.stdout,
33
+ stderr: proc.stderr,
34
+ wait: () => proc.exited.then((exitCode) => ({ exitCode })),
35
+ kill: async () => {
36
+ proc.kill();
37
+ },
38
+ };
39
+ }
40
+ function createLocalSession(rootDir, baseEnv, stripEnv) {
41
+ const resolve = (p) => (path.isAbsolute(p) ? p : path.join(rootDir, p));
42
+ return {
43
+ description: `Local machine sandbox. Root directory: ${rootDir}. Commands run as the ` +
44
+ `current OS user with full local filesystem and network access.`,
45
+ readFile: async ({ path: p }) => {
46
+ const file = Bun.file(resolve(p));
47
+ return (await file.exists()) ? file.stream() : null;
48
+ },
49
+ readBinaryFile: async ({ path: p }) => {
50
+ try {
51
+ return new Uint8Array(await fs.readFile(resolve(p)));
52
+ }
53
+ catch (e) {
54
+ if (e.code === "ENOENT")
55
+ return null;
56
+ throw e;
57
+ }
58
+ },
59
+ readTextFile: async ({ path: p, encoding = "utf-8", startLine, endLine }) => {
60
+ let text;
61
+ try {
62
+ text = await fs.readFile(resolve(p), { encoding: encoding });
63
+ }
64
+ catch (e) {
65
+ if (e.code === "ENOENT")
66
+ return null;
67
+ throw e;
68
+ }
69
+ if (startLine === undefined && endLine === undefined)
70
+ return text;
71
+ const lines = text.split("\n");
72
+ return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n");
73
+ },
74
+ writeFile: async ({ path: p, content }) => {
75
+ await fs.mkdir(path.dirname(resolve(p)), { recursive: true });
76
+ const chunks = [];
77
+ for await (const chunk of content) {
78
+ chunks.push(Buffer.from(chunk));
79
+ }
80
+ await fs.writeFile(resolve(p), Buffer.concat(chunks));
81
+ },
82
+ writeBinaryFile: async ({ path: p, content }) => {
83
+ await fs.mkdir(path.dirname(resolve(p)), { recursive: true });
84
+ await fs.writeFile(resolve(p), content);
85
+ },
86
+ writeTextFile: async ({ path: p, content, encoding = "utf-8" }) => {
87
+ await fs.mkdir(path.dirname(resolve(p)), { recursive: true });
88
+ await fs.writeFile(resolve(p), content, { encoding: encoding });
89
+ },
90
+ spawn: async (options) => spawnProcess(options.command, options, rootDir, baseEnv, stripEnv),
91
+ run: async (options) => {
92
+ const proc = spawnProcess(options.command, options, rootDir, baseEnv, stripEnv);
93
+ const [stdout, stderr, { exitCode }] = await Promise.all([
94
+ new Response(proc.stdout).text(),
95
+ new Response(proc.stderr).text(),
96
+ proc.wait(),
97
+ ]);
98
+ return { exitCode, stdout, stderr };
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * A {@link HarnessV1SandboxProvider} that runs everything on the local machine.
104
+ * Pair it with `claudeCode`/`codex` from the harness adapters.
105
+ */
106
+ export function createLocalSandbox(options = {}) {
107
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
108
+ const baseEnv = options.env ?? {};
109
+ const stripEnv = options.stripEnv ?? [];
110
+ // A local "sandbox" has no durable remote resource — the bridge process is
111
+ // the only state, and it dies with this process. So create and resume are the
112
+ // same operation: a fresh bridge bound to the same rootDir. Cross-process
113
+ // continuity comes from the adapter's resume state (the CLI's native session
114
+ // id) replaying conversation history from ~/.claude / ~/.codex, plus the files
115
+ // persisting on disk.
116
+ const buildSession = async () => {
117
+ await fs.mkdir(rootDir, { recursive: true });
118
+ const session = createLocalSession(rootDir, baseEnv, stripEnv);
119
+ // The bridge binds to a sandbox-declared TCP port. On a local sandbox we
120
+ // reserve a free loopback port and advertise it; the bridge listens there
121
+ // and we reach it over 127.0.0.1.
122
+ const bridgePort = await reserveFreePort();
123
+ return {
124
+ ...session,
125
+ id: `local-${path.basename(rootDir)}`,
126
+ defaultWorkingDirectory: rootDir,
127
+ ports: [bridgePort],
128
+ getPortUrl: async ({ port, protocol = "http" }) => {
129
+ const scheme = protocol === "ws" ? "ws" : protocol;
130
+ return `${scheme}://127.0.0.1:${port}`;
131
+ },
132
+ stop: async () => { },
133
+ destroy: async () => { },
134
+ restricted: () => session,
135
+ };
136
+ };
137
+ return {
138
+ specificationVersion: "harness-sandbox-v1",
139
+ providerId: "local-fs",
140
+ createSession: buildSession,
141
+ resumeSession: buildSession,
142
+ };
143
+ }
@@ -1,5 +1,5 @@
1
1
  import { generateText, gateway } from "ai";
2
- import { getLanguageModel, requireLlmKeys, defaultModelFor } from "./registry.js";
2
+ import { getLanguageModel, requireLlmKeys, defaultModelFor, isHarnessProvider } from "./registry.js";
3
3
  async function fetchModelsRaw() {
4
4
  const headers = {};
5
5
  if (process.env.AI_GATEWAY_API_KEY) {
@@ -55,8 +55,11 @@ export async function chooseConfiguredModel(config) {
55
55
  return config.model || defaultModelFor(config.llmProvider);
56
56
  }
57
57
  export async function generateWithGateway(config, prompt, system) {
58
+ // Harness providers aren't language models; use the AI Gateway directly for
59
+ // these utility generations (e.g. run titles) when a gateway key is present.
60
+ const model = isHarnessProvider(config.llmProvider) ? gateway(DEFAULT_MODEL) : getLanguageModel(config);
58
61
  const result = await generateText({
59
- model: getLanguageModel(config),
62
+ model,
60
63
  system,
61
64
  prompt
62
65
  });
@@ -24,6 +24,17 @@ const STATIC_MODELS = {
24
24
  { id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B Instruct" },
25
25
  { id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B Instruct v0.3" },
26
26
  { id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" }
27
+ ],
28
+ "claude-code": [
29
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
30
+ { id: "claude-opus-4-8", name: "Claude Opus 4.8" },
31
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5" }
32
+ ],
33
+ codex: [
34
+ { id: "gpt-5.5", name: "GPT-5.5" },
35
+ { id: "gpt-5.4", name: "GPT-5.4" },
36
+ { id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
37
+ { id: "gpt-5.3-codex-spark", name: "GPT-5.3-Codex-Spark" }
27
38
  ]
28
39
  };
29
40
  async function listXaiModels() {
@@ -92,6 +103,8 @@ export async function listModels(config) {
92
103
  case "xai": return listXaiModels();
93
104
  case "workers-ai": return listWorkersAiModels();
94
105
  case "huggingface": return listHuggingFaceModelsWrapper();
106
+ case "claude-code": return STATIC_MODELS["claude-code"];
107
+ case "codex": return STATIC_MODELS.codex;
95
108
  default: return (await listToolUseModels()).map((m) => ({ id: m.id, name: m.name }));
96
109
  }
97
110
  }
@@ -25,17 +25,21 @@ export function requireSearchProvider(provider) {
25
25
  }
26
26
  const LLM_ENV_CHECKS = [
27
27
  { name: AI_GATEWAY_ENV, provider: "gateway", purpose: "Vercel AI Gateway LLM access" },
28
+ { name: "ANTHROPIC_API_KEY", provider: "claude-code", purpose: "Claude Code (local harness) access" },
29
+ { name: "OPENAI_API_KEY", provider: "codex", purpose: "Codex (local harness) access" },
28
30
  { name: "XAI_API_KEY", provider: "xai", purpose: "xAI (Grok) LLM access" },
29
31
  { name: "CLOUDFLARE_ACCOUNT_ID", provider: "workers-ai", purpose: "Cloudflare Workers AI account" },
30
32
  { name: "CLOUDFLARE_API_TOKEN", provider: "workers-ai", purpose: "Cloudflare Workers AI LLM access" },
31
33
  { name: "HF_API_KEY", provider: "huggingface", purpose: "HuggingFace Inference API access" }
32
34
  ];
33
35
  export function detectEnv(provider, llmProvider = "gateway") {
36
+ // Local harness providers authenticate via the CLI login, so their API key is optional.
37
+ const harnessActive = llmProvider === "claude-code" || llmProvider === "codex";
34
38
  const checks = LLM_ENV_CHECKS.map((c) => ({
35
39
  name: c.name,
36
40
  present: hasEnv(c.name),
37
41
  purpose: c.purpose,
38
- required: c.provider === llmProvider
42
+ required: c.provider === llmProvider && !harnessActive
39
43
  }));
40
44
  for (const key of Object.keys(PROVIDER_ENV)) {
41
45
  const name = PROVIDER_ENV[key];
@@ -3,31 +3,49 @@ import { createXai } from "@ai-sdk/xai";
3
3
  import { createWorkersAI } from "workers-ai-provider";
4
4
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
5
5
  import { hasEnv } from "./readiness.js";
6
- export const LLM_PROVIDERS = ["gateway", "xai", "workers-ai", "huggingface"];
6
+ /** Providers that run a local agent harness (their own tool loop + CLI) rather than a bare language model. */
7
+ export const HARNESS_PROVIDERS = ["claude-code", "codex"];
8
+ export function isHarnessProvider(provider) {
9
+ return HARNESS_PROVIDERS.includes(provider);
10
+ }
11
+ export const LLM_PROVIDERS = ["gateway", "xai", "workers-ai", "huggingface", "claude-code", "codex"];
7
12
  /** Human-readable names for the provider picker and status messages. */
8
13
  export const LLM_PROVIDER_LABELS = {
9
14
  gateway: "Vercel AI Gateway",
10
15
  xai: "xAI",
11
16
  "workers-ai": "Cloudflare Workers AI",
12
- huggingface: "HuggingFace"
17
+ huggingface: "HuggingFace",
18
+ "claude-code": "Claude Code (local)",
19
+ codex: "Codex (local)"
13
20
  };
14
21
  /** Env vars each LLM provider needs before it can generate. */
15
22
  export const LLM_PROVIDER_ENV = {
16
23
  gateway: ["AI_GATEWAY_API_KEY"],
17
24
  xai: ["XAI_API_KEY"],
18
25
  "workers-ai": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
19
- huggingface: ["HF_API_KEY"]
26
+ huggingface: ["HF_API_KEY"],
27
+ // Harness providers accept their native key OR the AI Gateway key (OR-checked
28
+ // in requireLlmKeys, not the default AND-of-all logic).
29
+ "claude-code": ["ANTHROPIC_API_KEY"],
30
+ codex: ["OPENAI_API_KEY"]
20
31
  };
21
32
  export function defaultModelFor(provider) {
22
33
  switch (provider) {
23
34
  case "xai": return "grok-build-0.1";
24
35
  case "workers-ai": return "@cf/moonshotai/kimi-k2.6";
25
36
  case "huggingface": return "meta-llama/Llama-3.3-70B-Instruct";
37
+ case "claude-code": return "claude-sonnet-4-6";
38
+ case "codex": return "gpt-5.5";
26
39
  default: return "deepseek/deepseek-v4-flash";
27
40
  }
28
41
  }
29
42
  /** Throw a setup-oriented error when the active LLM provider's env keys are missing. */
30
43
  export function requireLlmKeys(config) {
44
+ if (isHarnessProvider(config.llmProvider)) {
45
+ // No key required: the local harness uses the user's CLI login
46
+ // (claude login / codex login). An explicit API key is optional.
47
+ return;
48
+ }
31
49
  const missing = LLM_PROVIDER_ENV[config.llmProvider].filter((name) => !hasEnv(name));
32
50
  if (missing.length > 0) {
33
51
  throw new Error(`${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} required for the "${config.llmProvider}" LLM provider. Set ${missing.length === 1 ? "it" : "them"} with /key or in your environment.`);
@@ -36,6 +54,9 @@ export function requireLlmKeys(config) {
36
54
  /** Build the AI SDK language model for the configured provider + model id. */
37
55
  export function getLanguageModel(config) {
38
56
  requireLlmKeys(config);
57
+ if (isHarnessProvider(config.llmProvider)) {
58
+ throw new Error(`The "${config.llmProvider}" provider runs as a local agent harness, not a language model — drive it through the harness agent path, not getLanguageModel().`);
59
+ }
39
60
  switch (config.llmProvider) {
40
61
  case "xai":
41
62
  return createXai({ apiKey: process.env.XAI_API_KEY })(config.model);
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  export async function appendJsonl(path, value) {
4
4
  await mkdir(dirname(path), { recursive: true });
@@ -6,7 +6,7 @@ export async function appendJsonl(path, value) {
6
6
  }
7
7
  export async function readJsonl(path) {
8
8
  try {
9
- const text = await readFile(path, "utf8");
9
+ const text = await Bun.file(path).text();
10
10
  const results = [];
11
11
  for (const line of text.split("\n")) {
12
12
  if (!line.trim())
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { createRunId } from "../utils/ids.js";
4
4
  import { appendJsonl, readJsonl } from "./jsonl.js";
@@ -26,26 +26,26 @@ export async function createRun(goal, config, projectRoot = process.cwd()) {
26
26
  const paths = getRunPaths(runPath);
27
27
  await mkdir(paths.artifacts, { recursive: true });
28
28
  await mkdir(paths.snapshots, { recursive: true });
29
- await writeFile(paths.goal, `# Goal\n\n${goal}\n`);
30
- await writeFile(paths.plan, "# Research Plan\n\nPending plan generation.\n");
31
- await writeFile(paths.research, researchInstructions());
32
- await writeFile(paths.scope, `${JSON.stringify({ goal, maxSources: config.maxSources, citationPolicy: config.citationPolicy }, null, 2)}\n`);
33
- await writeFile(paths.progress, progressText("created", "Generate and approve research plan."));
34
- await writeFile(paths.sources, "");
35
- await writeFile(paths.claims, "");
36
- await writeFile(paths.notes, "# Notes\n\n");
37
- await writeFile(paths.report, "# Report\n\nDraft not generated yet.\n");
38
- await writeFile(paths.handoff, handoffText(goal, "created"));
29
+ await Bun.write(paths.goal, `# Goal\n\n${goal}\n`);
30
+ await Bun.write(paths.plan, "# Research Plan\n\nPending plan generation.\n");
31
+ await Bun.write(paths.research, researchInstructions());
32
+ await Bun.write(paths.scope, `${JSON.stringify({ goal, maxSources: config.maxSources, citationPolicy: config.citationPolicy }, null, 2)}\n`);
33
+ await Bun.write(paths.progress, progressText("created", "Generate and approve research plan."));
34
+ await Bun.write(paths.sources, "");
35
+ await Bun.write(paths.claims, "");
36
+ await Bun.write(paths.notes, "# Notes\n\n");
37
+ await Bun.write(paths.report, "# Report\n\nDraft not generated yet.\n");
38
+ await Bun.write(paths.handoff, handoffText(goal, "created"));
39
39
  await logEvent(paths.root, "run.created", { goal });
40
40
  return summarizeRun(paths.root);
41
41
  }
42
42
  export async function summarizeRun(runPath) {
43
43
  const paths = getRunPaths(runPath);
44
- const goal = (await readFile(paths.goal, "utf8").catch(() => "")).replace(/^# Goal\s*/u, "").trim();
45
- const title = (await readFile(join(runPath, "title.md"), "utf8").catch(() => "")).trim() || undefined;
44
+ const goal = (await Bun.file(paths.goal).text().catch(() => "")).replace(/^# Goal\s*/u, "").trim();
45
+ const title = (await Bun.file(join(runPath, "title.md")).text().catch(() => "")).trim() || undefined;
46
46
  const sources = await readJsonl(paths.sources);
47
47
  const claims = await readJsonl(paths.claims);
48
- const report = await readFile(paths.report, "utf8").catch(() => "");
48
+ const report = await Bun.file(paths.report).text().catch(() => "");
49
49
  // last activity = newest mtime among the files that change as a run progresses
50
50
  const mtimes = await Promise.all([join(runPath, "convo.json"), paths.report, runPath].map((p) => stat(p).then((s) => s.mtimeMs).catch(() => 0)));
51
51
  const updatedAt = Math.max(0, ...mtimes);
@@ -63,7 +63,7 @@ export async function summarizeRun(runPath) {
63
63
  };
64
64
  }
65
65
  export async function setRunTitle(runPath, title) {
66
- await writeFile(join(runPath, "title.md"), title.trim());
66
+ await Bun.write(join(runPath, "title.md"), title.trim());
67
67
  }
68
68
  export async function deleteRun(runPath) {
69
69
  await rm(runPath, { recursive: true, force: true });
@@ -1,6 +1,6 @@
1
1
  import { exec, execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
3
+ import { mkdir, readdir } from "node:fs/promises";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { tool } from "ai";
6
6
  import { z } from "zod";
@@ -226,7 +226,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
226
226
  if (needsApproval) {
227
227
  let description;
228
228
  if (harnessName === "report.md" && resolved.scope === "run") {
229
- const existing = await readFile(resolved.abs, "utf8").catch(() => "");
229
+ const existing = await Bun.file(resolved.abs).text().catch(() => "");
230
230
  const parts = diffLines(existing, content);
231
231
  const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
232
232
  const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
@@ -248,7 +248,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
248
248
  return `Write to ${resolved.displayPath} rejected by user.`;
249
249
  }
250
250
  await mkdir(dirname(resolved.abs), { recursive: true });
251
- await writeFile(resolved.abs, content);
251
+ await Bun.write(resolved.abs, content);
252
252
  const eventType = harnessName === "report.md" ? "report.updated" : harnessName === "plan.md" ? "plan.updated" : "file.written";
253
253
  await logEvent(runPath, eventType, { path: resolved.displayPath, scope: resolved.scope, chars: content.length });
254
254
  const where = resolved.scope === "workspace" ? "workspace" : "run";
@@ -268,7 +268,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
268
268
  const blocked = planModeBlocksEdit(getPlanMode, resolved.scope, harnessName);
269
269
  if (blocked)
270
270
  return blocked;
271
- const current = await readFile(resolved.abs, "utf8");
271
+ const current = await Bun.file(resolved.abs).text();
272
272
  const occurrences = current.split(oldString).length - 1;
273
273
  if (occurrences === 0) {
274
274
  return `No match for the given oldString in ${resolved.displayPath}. No changes made.`;
@@ -287,7 +287,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
287
287
  return `Edit to ${resolved.displayPath} rejected by user.`;
288
288
  }
289
289
  }
290
- await writeFile(resolved.abs, current.replace(oldString, newString));
290
+ await Bun.write(resolved.abs, current.replace(oldString, newString));
291
291
  await logEvent(runPath, "file.edited", { path: resolved.displayPath, scope: resolved.scope });
292
292
  return `Edited ${resolved.displayPath}`;
293
293
  }
@@ -327,7 +327,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
327
327
  }
328
328
  claims[idx] = { ...claims[idx], status, reason };
329
329
  await mkdir(dirname(claimsPath), { recursive: true });
330
- await writeFile(claimsPath, claims.map((c) => JSON.stringify(c)).join("\n") + "\n");
330
+ await Bun.write(claimsPath, claims.map((c) => JSON.stringify(c)).join("\n") + "\n");
331
331
  await logEvent(runPath, "claim.verified", { id, status });
332
332
  return `Claim ${id} → ${status}`;
333
333
  }
@@ -339,7 +339,7 @@ export function createResearchTools(runPath, config, onApprovalRequired, workspa
339
339
  }),
340
340
  execute: async ({ path }) => {
341
341
  const resolved = resolveToolPath(runPath, workspacePath, path);
342
- return truncate(await readFile(resolved.abs, "utf8"));
342
+ return truncate(await Bun.file(resolved.abs).text());
343
343
  }
344
344
  }),
345
345
  webSearch: tool({
@@ -1,6 +1,5 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { spawn } from "node:child_process";
3
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { mkdir } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  const MAX_OUTPUT_LINES = 500;
6
5
  const MAX_TAIL_CHARS = 4000;
@@ -48,7 +47,7 @@ export class BackgroundTaskManager {
48
47
  runtime = new Map();
49
48
  records = [];
50
49
  loaded = false;
51
- sessionToken = randomUUID();
50
+ sessionToken = crypto.randomUUID();
52
51
  constructor(persistPath, defaultCwd) {
53
52
  this.persistPath = persistPath;
54
53
  this.defaultCwd = defaultCwd;
@@ -58,7 +57,7 @@ export class BackgroundTaskManager {
58
57
  return;
59
58
  this.loaded = true;
60
59
  try {
61
- const raw = await readFile(this.persistPath, "utf8");
60
+ const raw = await Bun.file(this.persistPath).text();
62
61
  const parsed = JSON.parse(raw);
63
62
  if (Array.isArray(parsed)) {
64
63
  this.records = parsed.filter(isValidRecord);
@@ -94,7 +93,7 @@ export class BackgroundTaskManager {
94
93
  }
95
94
  async persist() {
96
95
  await mkdir(dirname(this.persistPath), { recursive: true });
97
- await writeFile(this.persistPath, JSON.stringify(this.records, null, 2) + "\n");
96
+ await Bun.write(this.persistPath, JSON.stringify(this.records, null, 2) + "\n");
98
97
  }
99
98
  syncRecord(task) {
100
99
  const idx = this.records.findIndex((r) => r.id === task.record.id);
@@ -1,15 +1,12 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { createServer } from "node:http";
3
- import { exec } from "node:child_process";
4
1
  import { saveGlobalMcpConfig } from "../config/load-config.js";
5
2
  function toBase64Url(buf) {
6
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3
+ return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7
4
  }
8
5
  function createVerifier() {
9
- return toBase64Url(randomBytes(48));
6
+ return toBase64Url(crypto.getRandomValues(new Uint8Array(48)));
10
7
  }
11
8
  function createChallenge(verifier) {
12
- return toBase64Url(createHash("sha256").update(verifier).digest());
9
+ return toBase64Url(new Bun.CryptoHasher("sha256").update(verifier).digest());
13
10
  }
14
11
  async function fetchJson(url) {
15
12
  try {
@@ -128,31 +125,38 @@ function patchClientId(mcp, name, clientId, clientSecret) {
128
125
  };
129
126
  }
130
127
  function openBrowser(url) {
131
- const cmd = process.platform === "darwin" ? `open "${url}"` :
132
- process.platform === "win32" ? `start "" "${url}"` :
133
- `xdg-open "${url}"`;
134
- exec(cmd, () => { });
128
+ const argv = process.platform === "darwin" ? ["open", url] :
129
+ process.platform === "win32" ? ["cmd", "/c", "start", "", url] :
130
+ ["xdg-open", url];
131
+ Bun.spawn(argv, { stdout: "ignore", stderr: "ignore" });
135
132
  }
136
133
  function waitForCallback(port, timeoutMs = 120_000) {
137
134
  return new Promise((resolve, reject) => {
138
- const server = createServer((req, res) => {
139
- const u = new URL(req.url ?? "/", `http://localhost:${port}`);
140
- const code = u.searchParams.get("code");
141
- const state = u.searchParams.get("state");
142
- res.writeHead(200, { "Content-Type": "text/html" });
143
- res.end("<html><body><h2>OAuth connected you can close this tab.</h2></body></html>");
144
- server.close();
145
- if (code && state)
146
- resolve({ code, state });
147
- else
148
- reject(new Error("OAuth callback missing code or state"));
135
+ const server = Bun.serve({
136
+ port,
137
+ hostname: "127.0.0.1",
138
+ fetch(req) {
139
+ const u = new URL(req.url);
140
+ const code = u.searchParams.get("code");
141
+ const state = u.searchParams.get("state");
142
+ queueMicrotask(() => {
143
+ clearTimeout(timer);
144
+ // Graceful stop: let the response flush to the browser before closing.
145
+ void server.stop();
146
+ if (code && state)
147
+ resolve({ code, state });
148
+ else
149
+ reject(new Error("OAuth callback missing code or state"));
150
+ });
151
+ return new Response("<html><body><h2>OAuth connected — you can close this tab.</h2></body></html>", {
152
+ headers: { "Content-Type": "text/html" },
153
+ });
154
+ },
149
155
  });
150
- server.listen(port, "127.0.0.1");
151
156
  const timer = setTimeout(() => {
152
- server.close();
157
+ void server.stop(true);
153
158
  reject(new Error("OAuth flow timed out (2 min). Run `scira mcp oauth <name>` to retry."));
154
159
  }, timeoutMs);
155
- server.once("close", () => clearTimeout(timer));
156
160
  });
157
161
  }
158
162
  async function exchangeCode(opts) {
@@ -235,7 +239,7 @@ export async function runOAuthFlow(srv, config) {
235
239
  }
236
240
  const verifier = createVerifier();
237
241
  const challenge = createChallenge(verifier);
238
- const state = toBase64Url(randomBytes(16));
242
+ const state = toBase64Url(crypto.getRandomValues(new Uint8Array(16)));
239
243
  const scope = srv.oauthScopes ?? endpoints.suggestedScope ?? undefined;
240
244
  const params = new URLSearchParams({
241
245
  response_type: "code",
@@ -1,5 +1,4 @@
1
1
  import process from "node:process";
2
- import { writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { Readability } from "@mozilla/readability";
5
4
  import { JSDOM } from "jsdom";
@@ -94,6 +93,6 @@ export async function openUrl(url, config) {
94
93
  }
95
94
  export async function writeSnapshot(snapshotsDir, sourceId, page) {
96
95
  const path = join(snapshotsDir, `${sourceId}.md`);
97
- await writeFile(path, `# ${page.title}\n\n${page.text}\n`);
96
+ await Bun.write(path, `# ${page.title}\n\n${page.text}\n`);
98
97
  return path;
99
98
  }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
1
+ import { mkdir } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { tool } from "ai";
4
4
  import { z } from "zod";
@@ -14,7 +14,7 @@ function nextTodoId(existing) {
14
14
  }
15
15
  async function loadTodos(path) {
16
16
  try {
17
- const raw = await readFile(path, "utf8");
17
+ const raw = await Bun.file(path).text();
18
18
  const parsed = JSON.parse(raw);
19
19
  if (!Array.isArray(parsed))
20
20
  return [];
@@ -26,7 +26,7 @@ async function loadTodos(path) {
26
26
  }
27
27
  async function saveTodos(path, items) {
28
28
  await mkdir(dirname(path), { recursive: true });
29
- await writeFile(path, JSON.stringify(items, null, 2) + "\n");
29
+ await Bun.write(path, JSON.stringify(items, null, 2) + "\n");
30
30
  }
31
31
  function formatTodoList(items) {
32
32
  if (items.length === 0)
@@ -3,11 +3,23 @@ export const ApprovalModeSchema = z.enum(["manual", "suggest", "auto"]);
3
3
  export const ThemeSchema = z.enum(["dark", "light", "auto"]).default("auto");
4
4
  export const SciraConfigSchema = z.object({
5
5
  theme: ThemeSchema,
6
- llmProvider: z.enum(["gateway", "xai", "workers-ai", "huggingface"]).default("gateway"),
6
+ llmProvider: z.enum(["gateway", "xai", "workers-ai", "huggingface", "claude-code", "codex"]).default("gateway"),
7
7
  model: z.string().default("deepseek/deepseek-v4-flash"),
8
8
  // last selected model per LLM provider, restored when switching back
9
9
  lastModels: z.record(z.string(), z.string()).default({}),
10
10
  approvalMode: ApprovalModeSchema.default("suggest"),
11
+ // Settings for the local agent harnesses (claude-code / codex providers).
12
+ harness: z.object({
13
+ // Claude Code extended-thinking control.
14
+ thinking: z.enum(["off", "on", "adaptive"]).default("adaptive"),
15
+ // Codex reasoning effort for reasoning-capable models.
16
+ reasoningEffort: z.enum(["low", "medium", "high"]).default("medium"),
17
+ // Hard cap on Claude Code internal turns per prompt (unset = CLI default).
18
+ maxTurns: z.number().int().positive().optional()
19
+ }).default({
20
+ thinking: "adaptive",
21
+ reasoningEffort: "medium"
22
+ }),
11
23
  alwaysAllowLinks: z.boolean().default(false),
12
24
  runDirectory: z.string().default(".scira/runs"),
13
25
  maxSources: z.number().int().min(1).max(100).default(20),