@consultancy-outfit/ai-draftsman-cli 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ function unreachableError(url, error) {
50
50
  }
51
51
 
52
52
  // src/lib/version.ts
53
- var VERSION = "2.0.1";
53
+ var VERSION = "2.1.0";
54
54
 
55
55
  // src/lib/runtime.ts
56
56
  var state = {};
@@ -172,8 +172,8 @@ async function readCache(config2, maxAgeMs) {
172
172
  try {
173
173
  const file = JSON.parse(await readFile(cachePath(), "utf8"));
174
174
  if (file.baseUrl !== config2.baseUrl || file.keyTag !== keyTag(config2)) return void 0;
175
- const age = Date.now() - file.fetchedAt;
176
- if (age > maxAgeMs || file.info?.toolCalls === false && age > TOOLS_OFF_MAX_AGE_MS) return void 0;
175
+ const age2 = Date.now() - file.fetchedAt;
176
+ if (age2 > maxAgeMs || file.info?.toolCalls === false && age2 > TOOLS_OFF_MAX_AGE_MS) return void 0;
177
177
  return Array.isArray(file.info?.agents) ? file.info : void 0;
178
178
  } catch {
179
179
  return void 0;
@@ -609,11 +609,11 @@ var DeviceFlow = class {
609
609
  throw new CliError(`Sign-in failed (${res.status}): ${text2}`, 2);
610
610
  }
611
611
  }
612
- post(path18, body, signal) {
612
+ post(path27, body, signal) {
613
613
  if (!isSafeUrl(`${this.base}/`)) {
614
614
  throw new CliError("baseUrl must use https (http is allowed for localhost only).", 3);
615
615
  }
616
- return this.fetchImpl(this.base + path18, {
616
+ return this.fetchImpl(this.base + path27, {
617
617
  method: "POST",
618
618
  signal,
619
619
  headers: { "content-type": "application/json", accept: "application/json" },
@@ -772,10 +772,10 @@ async function resolveConfig(overrides = {}) {
772
772
  import { render } from "ink";
773
773
 
774
774
  // src/tui/App.tsx
775
- import { access, appendFile, readFile as readFile11 } from "fs/promises";
776
- import path15 from "path";
775
+ import { access, appendFile, readFile as readFile15 } from "fs/promises";
776
+ import path21 from "path";
777
777
  import { pathToFileURL } from "url";
778
- import { Box as Box7, Static, Text as Text7, useApp, useInput as useInput3, useStdout } from "ink";
778
+ import { Box as Box8, Static, Text as Text8, useApp, useInput as useInput3, useStdout } from "ink";
779
779
  import { useCallback, useEffect as useEffect3, useMemo as useMemo4, useRef as useRef2, useState as useState4 } from "react";
780
780
 
781
781
  // src/agent/client.ts
@@ -1246,7 +1246,7 @@ async function saveInputHistory(entries) {
1246
1246
  }
1247
1247
 
1248
1248
  // src/agent/loop.ts
1249
- import { randomUUID as randomUUID2 } from "crypto";
1249
+ import { randomUUID as randomUUID3 } from "crypto";
1250
1250
 
1251
1251
  // src/agent/tools/bash.ts
1252
1252
  import { spawn as spawn2, spawnSync } from "child_process";
@@ -1267,15 +1267,15 @@ function resolveReal(cwd, p) {
1267
1267
  try {
1268
1268
  return path6.join(realpathSync.native(current), ...tail);
1269
1269
  } catch {
1270
- const link = (() => {
1270
+ const link2 = (() => {
1271
1271
  try {
1272
1272
  return lstatSync(current).isSymbolicLink() ? readlinkSync(current) : void 0;
1273
1273
  } catch {
1274
1274
  return void 0;
1275
1275
  }
1276
1276
  })();
1277
- if (link !== void 0) {
1278
- current = path6.resolve(path6.dirname(current), link);
1277
+ if (link2 !== void 0) {
1278
+ current = path6.resolve(path6.dirname(current), link2);
1279
1279
  continue;
1280
1280
  }
1281
1281
  const parent = path6.dirname(current);
@@ -1517,6 +1517,7 @@ function ruleKey(tool, args) {
1517
1517
  const a = args;
1518
1518
  return `connector:${a.connector}/${a.action}`;
1519
1519
  }
1520
+ if (tool.kind === "browser") return `browser:${args.action}`;
1520
1521
  return tool.name;
1521
1522
  }
1522
1523
  function evaluate(tool, args, mode, rules, cwd) {
@@ -1540,6 +1541,14 @@ function evaluate(tool, args, mode, rules, cwd) {
1540
1541
  const key2 = ruleKey(tool, args);
1541
1542
  return rules.has(key2) ? { action: "allow" } : { action: "ask", key: key2 };
1542
1543
  }
1544
+ if (tool.kind === "browser") {
1545
+ if (mode === "plan") {
1546
+ return { action: "deny", reason: "Plan mode is on, so browser actions are not allowed yet. Describe the browser steps in the plan, then call exit_plan_mode." };
1547
+ }
1548
+ if (mode === "bypass" || rules.grants(tool, args)) return { action: "allow" };
1549
+ const key2 = ruleKey(tool, args);
1550
+ return rules.has(key2) ? { action: "allow" } : { action: "ask", key: key2 };
1551
+ }
1543
1552
  const target = tool.targetPath?.(args);
1544
1553
  const outside = target !== void 0 && !isInside(resolveReal(cwd, "."), resolveReal(cwd, target));
1545
1554
  if (tool.kind === "read") {
@@ -1673,9 +1682,10 @@ async function buildSystemPrompt(cwd, options) {
1673
1682
  // src/agent/tools/task.ts
1674
1683
  var SUBAGENT_TOOLS = {
1675
1684
  // read-only research: fast, cannot change anything
1676
- explore: (name) => ["read_file", "list_files", "grep", "web_fetch", "list_connectors"].includes(name),
1685
+ // (message_agent / list_agents only work for a worker with a team; elsewhere they explain that they cannot)
1686
+ explore: (name) => ["read_file", "list_files", "grep", "web_fetch", "list_connectors", "message_agent", "list_agents"].includes(name),
1677
1687
  // everything except starting more sub-agents, planning, and the shared todo list
1678
- general: (name) => !["task", "exit_plan_mode", "todo_write"].includes(name)
1688
+ general: (name) => !["task", "exit_plan_mode", "todo_write", "spawn_agent", "list_sessions", "send_to_session"].includes(name)
1679
1689
  };
1680
1690
  var taskTool = {
1681
1691
  name: "task",
@@ -1856,9 +1866,9 @@ function parseBlock(raw, where) {
1856
1866
  })
1857
1867
  };
1858
1868
  case "callout": {
1859
- const tone = b.tone ?? "info";
1860
- if (!["info", "success", "warning", "danger"].includes(tone)) return fail(`${where}.tone`, "must be info, success, warning or danger.");
1861
- return { type: "callout", tone, title: text(b.title, `${where}.title`, true), text: text(b.text, `${where}.text`) };
1869
+ const tone2 = b.tone ?? "info";
1870
+ if (!["info", "success", "warning", "danger"].includes(tone2)) return fail(`${where}.tone`, "must be info, success, warning or danger.");
1871
+ return { type: "callout", tone: tone2, title: text(b.title, `${where}.title`, true), text: text(b.text, `${where}.text`) };
1862
1872
  }
1863
1873
  case "code":
1864
1874
  return { type: "code", language: text(b.language, `${where}.language`, true), code: text(b.code, `${where}.code`) };
@@ -1990,8 +2000,8 @@ function renderBlock(block) {
1990
2000
  case "steps":
1991
2001
  return `<ol class="steps">${block.items.map((s) => `<li><div class="step-title">${inline(s.title)}</div>${s.text ? `<div class="step-text">${inline(s.text)}</div>` : ""}</li>`).join("")}</ol>`;
1992
2002
  case "callout": {
1993
- const tone = block.tone ?? "info";
1994
- return `<aside class="callout ${tone}" role="note"><span class="callout-icon" aria-hidden="true">${CALLOUT_ICON[tone]}</span><div>${block.title ? `<div class="callout-title">${inline(block.title)}</div>` : ""}<div class="callout-text">${inline(block.text)}</div></div></aside>`;
2003
+ const tone2 = block.tone ?? "info";
2004
+ return `<aside class="callout ${tone2}" role="note"><span class="callout-icon" aria-hidden="true">${CALLOUT_ICON[tone2]}</span><div>${block.title ? `<div class="callout-title">${inline(block.title)}</div>` : ""}<div class="callout-text">${inline(block.text)}</div></div></aside>`;
1995
2005
  }
1996
2006
  case "code":
1997
2007
  return `<figure class="code"><figcaption><span>${escapeHtml(block.language ?? "code")}</span><button type="button" class="copy" aria-label="Copy code">Copy</button></figcaption><pre><code>${escapeHtml(
@@ -2598,8 +2608,188 @@ var createArtifactTool = {
2598
2608
  }
2599
2609
  };
2600
2610
 
2601
- // src/agent/tools/connectors.ts
2611
+ // src/lib/browser/client.ts
2612
+ import { randomUUID as randomUUID2 } from "crypto";
2613
+ import { readFile as readFile6 } from "fs/promises";
2614
+ import net from "net";
2615
+
2616
+ // src/lib/browser/protocol.ts
2617
+ import { createHash as createHash2 } from "crypto";
2618
+ import path10 from "path";
2619
+ var HOST_NAME = "com.aidraftsman.browser";
2620
+ var BROWSER_ACTIONS = ["list_tabs", "navigate", "read_page", "click", "type"];
2621
+ var MAX_HOST_MESSAGE_BYTES = 1024 * 1024;
2622
+ var MAX_CLIENT_LINE_BYTES = 256 * 1024;
2623
+ function hostInfoPath() {
2624
+ return path10.join(getConfigDir(), "browser", "host.json");
2625
+ }
2626
+ function defaultEndpoint() {
2627
+ if (process.platform === "win32") {
2628
+ const who = createHash2("sha256").update(getConfigDir()).digest("hex").slice(0, 12);
2629
+ return "\\\\.\\pipe\\aidraftsman-browser-" + who;
2630
+ }
2631
+ return path10.join(getConfigDir(), "browser", "host.sock");
2632
+ }
2633
+ function encodeFrame(message) {
2634
+ const body = Buffer.from(JSON.stringify(message), "utf8");
2635
+ if (body.length > MAX_HOST_MESSAGE_BYTES) throw new Error(`Message to the browser is ${body.length} bytes; the limit is ${MAX_HOST_MESSAGE_BYTES}.`);
2636
+ const header = Buffer.alloc(4);
2637
+ header.writeUInt32LE(body.length, 0);
2638
+ return Buffer.concat([header, body]);
2639
+ }
2640
+ var FrameDecoder = class {
2641
+ constructor(maxBytes = 64 * 1024 * 1024) {
2642
+ this.maxBytes = maxBytes;
2643
+ }
2644
+ maxBytes;
2645
+ buffer = Buffer.alloc(0);
2646
+ push(chunk) {
2647
+ this.buffer = Buffer.concat([this.buffer, chunk]);
2648
+ const out = [];
2649
+ while (this.buffer.length >= 4) {
2650
+ const length = this.buffer.readUInt32LE(0);
2651
+ if (length > this.maxBytes) throw new Error(`Native message of ${length} bytes is over the ${this.maxBytes} byte limit.`);
2652
+ if (this.buffer.length < 4 + length) break;
2653
+ const body = this.buffer.subarray(4, 4 + length).toString("utf8");
2654
+ this.buffer = this.buffer.subarray(4 + length);
2655
+ out.push(JSON.parse(body));
2656
+ }
2657
+ return out;
2658
+ }
2659
+ };
2660
+ function isBrowserAction(value) {
2661
+ return typeof value === "string" && BROWSER_ACTIONS.includes(value);
2662
+ }
2663
+
2664
+ // src/lib/browser/client.ts
2665
+ var NOT_RUNNING = "The AI Draftsman browser extension is not running. Install it with `ad browser install`, load it in Chrome (chrome://extensions \u2192 Load unpacked), and keep Chrome open.";
2666
+ async function readInfo(infoPath) {
2667
+ try {
2668
+ const info = JSON.parse(await readFile6(infoPath, "utf8"));
2669
+ if (typeof info.endpoint !== "string" || typeof info.token !== "string") throw new Error("bad");
2670
+ return info;
2671
+ } catch {
2672
+ throw new Error(NOT_RUNNING);
2673
+ }
2674
+ }
2675
+ async function browserCall(action, args = {}, options = {}) {
2676
+ if (options.signal?.aborted) throw new Error("Interrupted by the user.");
2677
+ const info = await readInfo(options.infoPath ?? hostInfoPath());
2678
+ return new Promise((resolve, reject) => {
2679
+ const socket = net.connect(info.endpoint);
2680
+ const authId = randomUUID2();
2681
+ const callId = randomUUID2();
2682
+ let buffer = "";
2683
+ let done = false;
2684
+ const finish = (error, value) => {
2685
+ if (done) return;
2686
+ done = true;
2687
+ clearTimeout(timer);
2688
+ options.signal?.removeEventListener("abort", onAbort);
2689
+ socket.destroy();
2690
+ if (error) reject(error);
2691
+ else resolve(value);
2692
+ };
2693
+ const onAbort = () => finish(new Error("Interrupted by the user."));
2694
+ const timer = setTimeout(() => finish(new Error("Timed out talking to the browser bridge.")), options.timeoutMs ?? 4e4);
2695
+ options.signal?.addEventListener("abort", onAbort, { once: true });
2696
+ socket.setEncoding("utf8");
2697
+ socket.on("connect", () => socket.write(`${JSON.stringify({ id: authId, token: info.token })}
2698
+ `));
2699
+ socket.on("error", () => finish(new Error(NOT_RUNNING)));
2700
+ socket.on("close", () => finish(new Error("The browser bridge closed the connection.")));
2701
+ socket.on("data", (chunk) => {
2702
+ buffer += chunk;
2703
+ let nl;
2704
+ while ((nl = buffer.indexOf("\n")) >= 0) {
2705
+ const line = buffer.slice(0, nl);
2706
+ buffer = buffer.slice(nl + 1);
2707
+ const response = JSON.parse(line);
2708
+ if (response.id === authId) {
2709
+ if (!response.ok) return finish(new Error(response.error));
2710
+ socket.write(`${JSON.stringify({ id: callId, action, args })}
2711
+ `);
2712
+ } else if (response.id === callId) {
2713
+ if (response.ok) finish(void 0, response.result);
2714
+ else finish(new Error(response.error));
2715
+ }
2716
+ }
2717
+ });
2718
+ });
2719
+ }
2720
+
2721
+ // src/agent/tools/browser.ts
2602
2722
  var MAX_OUTPUT_CHARS = 3e4;
2723
+ var browserTool = {
2724
+ name: "browser",
2725
+ description: "Control the user's Chrome through the AI Draftsman extension (their real browser, already signed in). Actions: list_tabs; navigate {url, tab_id?} (omit tab_id to open a new tab); read_page {tab_id?} returns the page text and a numbered list of interactive elements; click {ref, tab_id?}; type {ref, text, submit?, tab_id?}. Refs come from the latest read_page of that tab: read the page again after anything that changes it. Never put passwords or payment details into a page.",
2726
+ kind: "browser",
2727
+ parameters: {
2728
+ type: "object",
2729
+ properties: {
2730
+ action: { type: "string", enum: [...BROWSER_ACTIONS], description: "What to do." },
2731
+ tab_id: { type: "number", description: "Tab from list_tabs. Default: the active tab." },
2732
+ url: { type: "string", description: "For navigate: an http(s) URL." },
2733
+ ref: { type: "number", description: "For click/type: the [number] of an element from read_page." },
2734
+ text: { type: "string", description: "For type: the text to enter." },
2735
+ submit: { type: "boolean", description: "For type: press Enter afterwards." }
2736
+ },
2737
+ required: ["action"]
2738
+ },
2739
+ parse: (raw) => {
2740
+ const action = str(raw, "action");
2741
+ if (!BROWSER_ACTIONS.includes(action)) {
2742
+ throw new Error(`Unknown action "${action}". Use one of: ${BROWSER_ACTIONS.join(", ")}.`);
2743
+ }
2744
+ const args = { action };
2745
+ const tab = num(raw, "tab_id");
2746
+ if (tab !== void 0) args.tab_id = tab;
2747
+ if (action === "navigate") {
2748
+ const url = str(raw, "url").trim();
2749
+ let parsed;
2750
+ try {
2751
+ parsed = new URL(url);
2752
+ } catch {
2753
+ throw new Error("navigate needs a full URL such as https://example.com.");
2754
+ }
2755
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("navigate only opens http and https URLs.");
2756
+ args.url = parsed.toString();
2757
+ }
2758
+ if (action === "click" || action === "type") {
2759
+ const ref = num(raw, "ref");
2760
+ if (ref === void 0 || !Number.isInteger(ref) || ref < 0) throw new Error("ref must be the [number] of an element from read_page.");
2761
+ args.ref = ref;
2762
+ }
2763
+ if (action === "type") {
2764
+ args.text = str(raw, "text");
2765
+ args.submit = raw.submit === true;
2766
+ }
2767
+ return args;
2768
+ },
2769
+ label: (args) => {
2770
+ switch (args.action) {
2771
+ case "navigate":
2772
+ return `open ${args.url}`;
2773
+ case "click":
2774
+ return `click [${args.ref}]`;
2775
+ case "type":
2776
+ return `type into [${args.ref}]`;
2777
+ case "read_page":
2778
+ return "read page";
2779
+ default:
2780
+ return "list tabs";
2781
+ }
2782
+ },
2783
+ async run(args, ctx) {
2784
+ const { action, ...rest } = args;
2785
+ const result = await browserCall(action, rest, { signal: ctx.signal });
2786
+ const text2 = typeof result === "string" ? result : JSON.stringify(result, null, 2);
2787
+ return { output: truncate(text2, MAX_OUTPUT_CHARS) };
2788
+ }
2789
+ };
2790
+
2791
+ // src/agent/tools/connectors.ts
2792
+ var MAX_OUTPUT_CHARS2 = 3e4;
2603
2793
  var configPromise;
2604
2794
  function config() {
2605
2795
  configPromise ??= resolveConfig();
@@ -2615,7 +2805,7 @@ async function call(name, args, signal) {
2615
2805
  const result = await callMcpTool(await config(), name, args);
2616
2806
  const text2 = textOf(result);
2617
2807
  if (result.isError) throw new Error(text2 || `${name} failed.`);
2618
- return truncate(text2, MAX_OUTPUT_CHARS);
2808
+ return truncate(text2, MAX_OUTPUT_CHARS2);
2619
2809
  }
2620
2810
  var listConnectorsTool = {
2621
2811
  name: "list_connectors",
@@ -2679,16 +2869,16 @@ var callConnectorTool = {
2679
2869
  };
2680
2870
 
2681
2871
  // src/agent/tools/edit-file.ts
2682
- import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
2872
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
2683
2873
 
2684
2874
  // src/agent/tools/write-file.ts
2685
- import { mkdir as mkdir5, readFile as readFile6, stat, writeFile as writeFile5 } from "fs/promises";
2686
- import path10 from "path";
2875
+ import { mkdir as mkdir5, readFile as readFile7, stat, writeFile as writeFile5 } from "fs/promises";
2876
+ import path11 from "path";
2687
2877
  import { createTwoFilesPatch } from "diff";
2688
2878
  async function existing(file) {
2689
2879
  try {
2690
2880
  await stat(file);
2691
- return await readFile6(file, "utf8");
2881
+ return await readFile7(file, "utf8");
2692
2882
  } catch {
2693
2883
  return void 0;
2694
2884
  }
@@ -2730,7 +2920,7 @@ var writeFileTool = {
2730
2920
  if (before !== void 0 && !ctx.readFiles.has(file)) {
2731
2921
  throw new Error(`${args.path} already exists. Read it with read_file before overwriting it.`);
2732
2922
  }
2733
- await mkdir5(path10.dirname(file), { recursive: true });
2923
+ await mkdir5(path11.dirname(file), { recursive: true });
2734
2924
  await writeFile5(file, args.content, "utf8");
2735
2925
  ctx.readFiles.add(file);
2736
2926
  const display = buildDiff(ctx.cwd, file, before ?? "", args.content);
@@ -2765,7 +2955,7 @@ function plan(content, args) {
2765
2955
  }
2766
2956
  async function preview2(args, ctx) {
2767
2957
  const file = resolveInCwd(ctx.cwd, args.path);
2768
- const content = await readFile7(file, "utf8");
2958
+ const content = await readFile8(file, "utf8");
2769
2959
  return buildDiff(ctx.cwd, file, content, plan(content, args).updated);
2770
2960
  }
2771
2961
  var editFileTool = {
@@ -2798,7 +2988,7 @@ var editFileTool = {
2798
2988
  }
2799
2989
  let content;
2800
2990
  try {
2801
- content = await readFile7(file, "utf8");
2991
+ content = await readFile8(file, "utf8");
2802
2992
  } catch {
2803
2993
  throw new Error(`File not found: ${args.path}`);
2804
2994
  }
@@ -2812,7 +3002,7 @@ var editFileTool = {
2812
3002
  };
2813
3003
 
2814
3004
  // src/agent/tools/read-file.ts
2815
- import { readFile as readFile8, stat as stat2 } from "fs/promises";
3005
+ import { readFile as readFile9, stat as stat2 } from "fs/promises";
2816
3006
  var MAX_LINES = 800;
2817
3007
  var MAX_BYTES = 512 * 1024;
2818
3008
  var readFileTool = {
@@ -2844,7 +3034,7 @@ var readFileTool = {
2844
3034
  if (info.size > MAX_BYTES && args.offset === void 0 && args.limit === void 0) {
2845
3035
  throw new Error(`File is ${Math.round(info.size / 1024)} KB. Pass offset and limit to read part of it.`);
2846
3036
  }
2847
- const buffer = await readFile8(file);
3037
+ const buffer = await readFile9(file);
2848
3038
  if (looksBinary(buffer)) throw new Error(`${args.path} looks like a binary file.`);
2849
3039
  const lines = buffer.toString("utf8").split(/\r?\n/);
2850
3040
  const start = Math.max(1, Math.floor(args.offset ?? 1));
@@ -2862,7 +3052,7 @@ var readFileTool = {
2862
3052
 
2863
3053
  // src/agent/tools/search.ts
2864
3054
  import { stat as stat3 } from "fs/promises";
2865
- import path11 from "path";
3055
+ import path12 from "path";
2866
3056
  import { Worker } from "worker_threads";
2867
3057
  import { glob } from "tinyglobby";
2868
3058
  var MAX_FILES = 150;
@@ -2870,14 +3060,14 @@ var MAX_MATCHES = 120;
2870
3060
  var MAX_FILE_BYTES = 1024 * 1024;
2871
3061
  var GREP_TIMEOUT_MS = 1e4;
2872
3062
  function checkGlob(pattern) {
2873
- if (/(^|[\\/])\.\.([\\/]|$)/.test(pattern) || path11.isAbsolute(pattern) || /^[\\/]/.test(pattern)) {
3063
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(pattern) || path12.isAbsolute(pattern) || /^[\\/]/.test(pattern)) {
2874
3064
  throw new Error("The glob must stay inside the search path. To search elsewhere, pass that directory as path instead.");
2875
3065
  }
2876
3066
  }
2877
3067
  async function globInside(pattern, root) {
2878
3068
  checkGlob(pattern);
2879
3069
  const files = await glob(pattern, { cwd: root, ignore: IGNORED_DIRS, onlyFiles: true, deep: 12, followSymbolicLinks: false });
2880
- return files.filter((f) => isInside(root, path11.resolve(root, f)));
3070
+ return files.filter((f) => isInside(root, path12.resolve(root, f)));
2881
3071
  }
2882
3072
  var GREP_WORKER = `
2883
3073
  const { parentPort, workerData } = require('node:worker_threads');
@@ -2950,10 +3140,10 @@ var listFilesTool = {
2950
3140
  const files = await globInside(args.pattern, root);
2951
3141
  if (files.length === 0) return { output: "No files matched." };
2952
3142
  const withTimes = await Promise.all(
2953
- files.slice(0, 2e3).map(async (f) => ({ f, t: (await stat3(path11.join(root, f)).catch(() => void 0))?.mtimeMs ?? 0 }))
3143
+ files.slice(0, 2e3).map(async (f) => ({ f, t: (await stat3(path12.join(root, f)).catch(() => void 0))?.mtimeMs ?? 0 }))
2954
3144
  );
2955
3145
  withTimes.sort((a, b) => b.t - a.t);
2956
- const shown = withTimes.slice(0, MAX_FILES).map((x) => displayPath(ctx.cwd, path11.join(root, x.f)));
3146
+ const shown = withTimes.slice(0, MAX_FILES).map((x) => displayPath(ctx.cwd, path12.join(root, x.f)));
2957
3147
  const extra = files.length > MAX_FILES ? `
2958
3148
  \u2026 ${files.length - MAX_FILES} more. Narrow the pattern.` : "";
2959
3149
  return { output: shown.join("\n") + extra, display: { kind: "text", summary: `Found ${files.length} file${files.length === 1 ? "" : "s"}` } };
@@ -2991,7 +3181,7 @@ var grepTool = {
2991
3181
  const target = resolveInCwd(ctx.cwd, args.path ?? ".");
2992
3182
  const info = await stat3(target).catch(() => void 0);
2993
3183
  if (!info) throw new Error(`Path not found: ${args.path}`);
2994
- const files = info.isFile() ? [target] : (await globInside(args.glob ?? "**/*", target)).map((f) => path11.join(target, f));
3184
+ const files = info.isFile() ? [target] : (await globInside(args.glob ?? "**/*", target)).map((f) => path12.join(target, f));
2995
3185
  const { results: hits, total } = await searchFiles(files, args.pattern, args.case_insensitive ? "i" : "", { signal: ctx.signal });
2996
3186
  const results = hits.map((h) => `${displayPath(ctx.cwd, h.file)}:${h.line}:${h.text}`);
2997
3187
  if (total === 0) return { output: "No matches." };
@@ -3001,6 +3191,121 @@ var grepTool = {
3001
3191
  }
3002
3192
  };
3003
3193
 
3194
+ // src/agent/tools/sessions.ts
3195
+ var NO_LINK = "Messaging other sessions is not available here (this is not an interactive session). Report your findings in your answer instead.";
3196
+ function link(ctx) {
3197
+ if (!ctx.sessions) throw new Error(NO_LINK);
3198
+ return ctx.sessions;
3199
+ }
3200
+ var listSessionsTool = {
3201
+ name: "list_sessions",
3202
+ description: "List the other AI Draftsman sessions running on this machine right now (their id, title, folder and agent), so you can pass something to one of them with send_to_session.",
3203
+ kind: "meta",
3204
+ parameters: { type: "object", properties: {} },
3205
+ parse: () => ({}),
3206
+ label: () => "sessions",
3207
+ async run(_args, ctx) {
3208
+ const output = await link(ctx).list();
3209
+ return { output, display: { kind: "text", summary: "Listed running sessions" } };
3210
+ }
3211
+ };
3212
+ var sendToSessionTool = {
3213
+ name: "send_to_session",
3214
+ description: "Send a message to another running AI Draftsman session on this machine (by id or title from list_sessions): a decision, a finding, or a question. The other session does not act on it by itself: its user sees it, and it reaches that session's agent with their next prompt. Keep it short and self-contained (the other session cannot see this conversation), and only send things the user would want shared: never secrets or credentials.",
3215
+ kind: "meta",
3216
+ parameters: {
3217
+ type: "object",
3218
+ properties: {
3219
+ to: { type: "string", description: "Session id (or a unique prefix of it) or a unique part of its title." },
3220
+ message: { type: "string", description: "What to tell it." }
3221
+ },
3222
+ required: ["to", "message"]
3223
+ },
3224
+ parse: (raw) => {
3225
+ const message = str(raw, "message");
3226
+ if (!message.trim()) throw new Error("message is empty.");
3227
+ const to = str(raw, "to").trim();
3228
+ if (!to) throw new Error("to is empty: give a session id or title (see list_sessions).");
3229
+ return { to, message };
3230
+ },
3231
+ label: (args) => `\u2192 ${args.to}`,
3232
+ async run(args, ctx) {
3233
+ const output = await link(ctx).send(args.to, args.message);
3234
+ return { output, display: { kind: "text", summary: output } };
3235
+ }
3236
+ };
3237
+
3238
+ // src/agent/tools/team.ts
3239
+ var NO_TEAM = "There is no agent team in this session (it is not running interactively). Do the work yourself instead.";
3240
+ function team(ctx) {
3241
+ if (!ctx.team) throw new Error(NO_TEAM);
3242
+ return ctx.team;
3243
+ }
3244
+ var spawnAgentTool = {
3245
+ name: "spawn_agent",
3246
+ description: "Start a worker agent in the background and return immediately. Use it for long or independent jobs you want to run in parallel with other workers (for example a researcher, an implementer and a reviewer working on one task together). Workers cannot see this conversation: put everything they need in the prompt, and tell them which teammates exist so they can coordinate with message_agent. Prefer `task` when you need the answer before you can continue.",
3247
+ kind: "meta",
3248
+ parameters: {
3249
+ type: "object",
3250
+ properties: {
3251
+ description: { type: "string", description: "3-6 word label shown in the workers list." },
3252
+ prompt: { type: "string", description: "Full instructions, and what to report when finished." },
3253
+ subagent_type: { type: "string", enum: ["explore", "general"], description: '"explore" is read-only; "general" can also edit and run commands. Default "general".' },
3254
+ name: { type: "string", description: 'Short unique name teammates use to address this worker, e.g. "researcher". Optional.' }
3255
+ },
3256
+ required: ["description", "prompt"]
3257
+ },
3258
+ parse: (raw) => {
3259
+ const type = raw?.subagent_type ?? "general";
3260
+ if (type !== "explore" && type !== "general") throw new Error('subagent_type must be "explore" or "general".');
3261
+ const prompt = str(raw, "prompt");
3262
+ if (!prompt.trim()) throw new Error("prompt is empty.");
3263
+ return { description: str(raw, "description"), prompt, subagent_type: type, name: str(raw, "name", true)?.trim() || void 0 };
3264
+ },
3265
+ label: (args) => args.description,
3266
+ async run(args, ctx) {
3267
+ const t = team(ctx);
3268
+ if (!t.spawn) throw new Error("Workers cannot start other workers. Message the lead agent if you need more help.");
3269
+ const output = t.spawn({ description: args.description, prompt: args.prompt, type: args.subagent_type, name: args.name });
3270
+ return { output, display: { kind: "text", summary: output.split("\n")[0] } };
3271
+ }
3272
+ };
3273
+ var messageAgentTool = {
3274
+ name: "message_agent",
3275
+ description: `Send a message to a teammate: a worker by name or id, or "main" for the lead agent the user is talking to. It is delivered before the recipient's next step (a finished worker wakes up to handle it). Use it to hand over results, ask a question or ask for a review. Keep it short and self-contained; it does not wait for a reply.`,
3276
+ kind: "meta",
3277
+ parameters: {
3278
+ type: "object",
3279
+ properties: {
3280
+ to: { type: "string", description: 'Worker name or id, or "main".' },
3281
+ message: { type: "string", description: "What to tell them." }
3282
+ },
3283
+ required: ["to", "message"]
3284
+ },
3285
+ parse: (raw) => {
3286
+ const message = str(raw, "message");
3287
+ if (!message.trim()) throw new Error("message is empty.");
3288
+ return { to: str(raw, "to").trim(), message };
3289
+ },
3290
+ label: (args) => `\u2192 ${args.to}`,
3291
+ async run(args, ctx) {
3292
+ const output = team(ctx).send(args.to, args.message);
3293
+ return { output, display: { kind: "text", summary: output } };
3294
+ }
3295
+ };
3296
+ var listAgentsTool = {
3297
+ name: "list_agents",
3298
+ description: "List the agents on this team (the lead and every worker) with each one's status and what it is doing.",
3299
+ kind: "meta",
3300
+ parameters: { type: "object", properties: {} },
3301
+ parse: () => ({}),
3302
+ label: () => "team",
3303
+ async run(_args, ctx) {
3304
+ const output = team(ctx).list();
3305
+ return { output, display: { kind: "text", summary: "Listed the team" } };
3306
+ }
3307
+ };
3308
+
3004
3309
  // src/agent/tools/todo.ts
3005
3310
  var todoWriteTool = {
3006
3311
  name: "todo_write",
@@ -3240,12 +3545,20 @@ var ALL_TOOLS = [
3240
3545
  createArtifactTool,
3241
3546
  bashTool,
3242
3547
  webFetchTool,
3548
+ browserTool,
3243
3549
  listConnectorsTool,
3244
3550
  callConnectorTool,
3245
3551
  taskTool,
3552
+ spawnAgentTool,
3553
+ messageAgentTool,
3554
+ listAgentsTool,
3555
+ listSessionsTool,
3556
+ sendToSessionTool,
3246
3557
  todoWriteTool,
3247
3558
  exitPlanModeTool
3248
3559
  ];
3560
+ var TEAM_TOOLS = /* @__PURE__ */ new Set(["spawn_agent", "message_agent", "list_agents"]);
3561
+ var SESSION_TOOLS = /* @__PURE__ */ new Set(["list_sessions", "send_to_session"]);
3249
3562
  function findTool(name) {
3250
3563
  return ALL_TOOLS.find((t) => t.name === name);
3251
3564
  }
@@ -3267,6 +3580,8 @@ function toolDefs(allow) {
3267
3580
  function sessionToolFilter(profile) {
3268
3581
  return (name) => {
3269
3582
  if (name === "create_artifact") return profile.artifacts;
3583
+ if (TEAM_TOOLS.has(name)) return profile.orchestration === true;
3584
+ if (SESSION_TOOLS.has(name)) return profile.sessions === true;
3270
3585
  if (name === "exit_plan_mode") return profile.planMode !== false;
3271
3586
  return true;
3272
3587
  };
@@ -3332,6 +3647,11 @@ async function attemptSubagent(options, request, callId, model, named, progress)
3332
3647
  maxSteps: options.subagentMaxSteps ?? DEFAULT_SUBAGENT_MAX_STEPS,
3333
3648
  // A sub-agent stops at its limit and reports; only the person at the main prompt is asked to go on.
3334
3649
  confirmContinue: void 0,
3650
+ // A `task` sub-agent is not a teammate: it neither speaks for the lead nor reads its mail.
3651
+ team: void 0,
3652
+ drainInbox: void 0,
3653
+ // And it does not speak for the session to other sessions.
3654
+ sessions: void 0,
3335
3655
  // Its own run, linked to the turn that started it.
3336
3656
  runId: void 0,
3337
3657
  parentRunId: options.runId,
@@ -3412,13 +3732,15 @@ async function runAgentTurn(options) {
3412
3732
  const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
3413
3733
  const verify = verifyWatcher(options.onEvent);
3414
3734
  const onEvent = verify.watch;
3415
- const runId = options.runId ?? randomUUID2();
3735
+ const runId = options.runId ?? randomUUID3();
3416
3736
  options = { ...options, onEvent, runId };
3417
3737
  const subagentSlot = concurrencyLimit(options.maxParallel ?? DEFAULT_MAX_PARALLEL_SUBAGENTS);
3418
3738
  const canVerify = (options.depth ?? 0) === 0 && options.tools !== false && (!options.toolFilter || options.toolFilter("bash"));
3419
3739
  const ctx = {
3420
3740
  cwd: options.cwd,
3421
3741
  signal,
3742
+ ...options.team ? { team: options.team } : {},
3743
+ ...options.sessions ? { sessions: options.sessions } : {},
3422
3744
  readFiles: options.readFiles,
3423
3745
  setTodos: (todos) => onEvent({ type: "todos", todos }),
3424
3746
  setMode: options.setMode,
@@ -3433,6 +3755,8 @@ async function runAgentTurn(options) {
3433
3755
  if (signal?.aborted) throw new CliError("Interrupted.", 130);
3434
3756
  limit += maxSteps;
3435
3757
  }
3758
+ const mail = options.drainInbox?.();
3759
+ if (mail?.length) history.push(...mail);
3436
3760
  const send = (extra = []) => streamChat(
3437
3761
  options.config,
3438
3762
  {
@@ -3488,8 +3812,8 @@ async function runAgentTurn(options) {
3488
3812
  let end = index;
3489
3813
  while (end < calls.length && isParallel(calls[end])) end++;
3490
3814
  const batch = calls.slice(index, end);
3491
- const run = (call2) => executeCall(call2, { ...ctx, callId: call2.id }, options);
3492
- history.push(...await Promise.all(batch.map((call2) => call2.function.name === "task" ? subagentSlot(() => run(call2)) : run(call2))));
3815
+ const run2 = (call2) => executeCall(call2, { ...ctx, callId: call2.id }, options);
3816
+ history.push(...await Promise.all(batch.map((call2) => call2.function.name === "task" ? subagentSlot(() => run2(call2)) : run2(call2))));
3493
3817
  index = end;
3494
3818
  } else {
3495
3819
  history.push(await executeCall(calls[index], { ...ctx, callId: calls[index].id }, options));
@@ -3553,137 +3877,1461 @@ async function executeCall(call2, ctx, options) {
3553
3877
  if (decision === "allow_always") options.rules.add(verdict.key || ruleKey(tool, args));
3554
3878
  }
3555
3879
  try {
3556
- const result = await tool.run(args, ctx);
3880
+ if (options.checkpoint && tool.kind === "write" && tool.targetPath) {
3881
+ const target = tool.targetPath(args);
3882
+ if (target) await options.checkpoint(resolveInCwd(ctx.cwd, target)).catch(() => void 0);
3883
+ }
3884
+ const result = options.shellTracker && tool.kind === "exec" ? await options.shellTracker(ctx.cwd, () => tool.run(args, ctx)) : await tool.run(args, ctx);
3557
3885
  return reply(result.output, false, result.display);
3558
3886
  } catch (error) {
3559
3887
  return reply(error.message, true);
3560
3888
  }
3561
3889
  }
3562
3890
 
3563
- // src/lib/diagnostics.ts
3564
- async function probeServer(config2, fetchImpl = fetch) {
3565
- if (!config2.baseUrl) return {};
3566
- const started = Date.now();
3891
+ // src/agent/checkpoints.ts
3892
+ import { mkdir as mkdir6, readFile as readFile10, realpath, rm as rm3, stat as stat5, writeFile as writeFile7 } from "fs/promises";
3893
+ import path14 from "path";
3894
+
3895
+ // src/agent/tree-snapshot.ts
3896
+ import { execFile } from "child_process";
3897
+ import { copyFile, mkdtemp, rm as rm2, rmdir, stat as stat4 } from "fs/promises";
3898
+ import os4 from "os";
3899
+ import path13 from "path";
3900
+ var GIT_TIMEOUT_MS = 2e4;
3901
+ var NO_EOL_CONVERSION = ["-c", "core.autocrlf=false", "-c", "core.safecrlf=false"];
3902
+ var EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
3903
+ var attrSource;
3904
+ function supportsAttrSource(root) {
3905
+ attrSource ??= git(root, [`--attr-source=${EMPTY_TREE}`, "rev-parse", "--git-dir"]).then(
3906
+ () => true,
3907
+ () => false
3908
+ );
3909
+ return attrSource;
3910
+ }
3911
+ async function exactBytes(root) {
3912
+ return [...NO_EOL_CONVERSION, ...await supportsAttrSource(root) ? [`--attr-source=${EMPTY_TREE}`] : []];
3913
+ }
3914
+ var seedable = /* @__PURE__ */ new Map();
3915
+ async function canSeed(root) {
3916
+ const known = seedable.get(root);
3917
+ if (known !== void 0) return known;
3918
+ let ok = true;
3919
+ const config2 = async (key) => (await git(root, ["config", "--get", key]).catch(() => Buffer.alloc(0))).toString("utf8").trim().toLowerCase();
3920
+ const autocrlf = await config2("core.autocrlf");
3921
+ if (autocrlf === "true" || autocrlf === "input") ok = false;
3922
+ if (ok && await config2("core.attributesfile")) ok = false;
3923
+ if (ok) {
3924
+ const attrs = await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ":(glob)**/.gitattributes"]).catch(() => Buffer.alloc(0));
3925
+ const gitDir = (await git(root, ["rev-parse", "--git-dir"]).catch(() => Buffer.alloc(0))).toString("utf8").trim();
3926
+ const info = gitDir ? await stat4(path13.resolve(root, gitDir, "info", "attributes")).then(() => true, () => false) : false;
3927
+ if (attrs.length > 0 || info) ok = false;
3928
+ }
3929
+ seedable.set(root, ok);
3930
+ return ok;
3931
+ }
3932
+ function git(cwd, args, options = {}) {
3933
+ return new Promise((resolve, reject) => {
3934
+ const child = execFile(
3935
+ "git",
3936
+ args,
3937
+ { cwd, env: { ...process.env, ...options.env }, encoding: "buffer", maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS, windowsHide: true },
3938
+ (error, stdout) => error ? reject(error) : resolve(stdout)
3939
+ );
3940
+ child.stdin?.on("error", () => void 0);
3941
+ child.stdin?.end(options.input);
3942
+ });
3943
+ }
3944
+ async function repoRoot(cwd) {
3567
3945
  try {
3568
- const res = await fetchImpl(new URL("/v1/models", config2.baseUrl), {
3569
- headers: { accept: "application/json", ...clientHeaders(), ...config2.apiKey ? { authorization: `Bearer ${config2.apiKey}` } : {} },
3570
- signal: AbortSignal.timeout(1e4)
3571
- });
3572
- await res.body?.cancel().catch(() => void 0);
3573
- const served = servedBy(res.headers);
3574
- return { status: res.status, ms: Date.now() - started, ...served ? { served } : {} };
3946
+ const out = (await git(cwd, ["rev-parse", "--show-toplevel"])).toString("utf8").trim();
3947
+ return out ? path13.resolve(out) : void 0;
3575
3948
  } catch {
3576
- return {};
3949
+ return void 0;
3577
3950
  }
3578
3951
  }
3579
-
3580
- // src/tui/commands.ts
3581
- import { readdir as readdir3, readFile as readFile9 } from "fs/promises";
3582
- import path12 from "path";
3583
- var INIT_PROMPT = `Analyze this codebase and create an AD.md file in the project root. It is read at the start of every session, so keep it short and useful. Include:
3584
- - the commands to build, run, lint and test (check package.json, Makefile, etc.),
3585
- - the high-level architecture and the directories that matter,
3586
- - conventions that differ from the defaults.
3587
- Do not invent anything: read the code first. If AD.md already exists, improve it instead of replacing it.`;
3588
- function builtinCommands() {
3589
- return [
3590
- {
3591
- name: "help",
3592
- description: "Show commands and shortcuts",
3593
- run: (ctx) => {
3594
- const lines = ["Commands:"];
3595
- for (const c of ctx.allCommands()) lines.push(` /${(c.name + (c.args ? ` ${c.args}` : "")).padEnd(22)}${c.description}`);
3596
- lines.push("", "Shortcuts:", " @file mention a file (or @app: one of your connected connectors) \xB7 !cmd run a shell command \xB7 #note save a note to AD.md", " shift+tab cycle permission mode \xB7 esc interrupt \xB7 ctrl+j new line");
3597
- ctx.info(lines.join("\n"));
3598
- }
3599
- },
3600
- { name: "agents", description: "Switch the agent", aliases: ["agent"], run: (ctx) => ctx.pickAgent() },
3601
- {
3602
- name: "model",
3603
- description: "Use a specific model instead of an agent",
3604
- args: "<model>",
3605
- run: (ctx, args) => args.trim() ? ctx.setModel(args.trim()) : ctx.pickAgent()
3606
- },
3607
- { name: "clear", description: "Start a new conversation", aliases: ["new", "reset"], run: (ctx) => ctx.clear() },
3608
- {
3609
- name: "compact",
3610
- description: "Summarize the conversation to free up context",
3611
- args: "[focus]",
3612
- run: (ctx, args) => ctx.compact(args.trim() || void 0)
3613
- },
3614
- { name: "resume", description: "Resume an earlier conversation", aliases: ["continue"], run: (ctx) => ctx.pickSession() },
3615
- { name: "plan", description: "Plan first: research without changing anything", run: (ctx) => ctx.setMode("plan") },
3616
- {
3617
- name: "open",
3618
- description: "Open the last artifact (or a .html file) in your browser",
3619
- args: "[file]",
3620
- run: (ctx, args) => ctx.openFile(args.trim() || void 0)
3621
- },
3622
- { name: "init", description: "Create an AD.md with project instructions", run: (ctx) => ctx.send(INIT_PROMPT, "/init") },
3623
- { name: "status", description: "Show account, agent and session details", run: (ctx) => ctx.info(ctx.describeStatus().join("\n")) },
3624
- { name: "usage", description: "Show token usage for this session", aliases: ["cost"], run: (ctx) => ctx.info(ctx.describeUsage().join("\n")) },
3625
- {
3626
- name: "permissions",
3627
- description: "Show the permission mode and what is allowed",
3628
- run: (ctx) => ctx.info(ctx.describePermissions().join("\n"))
3629
- },
3630
- {
3631
- name: "doctor",
3632
- description: "Check the connection and your setup",
3633
- run: async (ctx) => ctx.info((await ctx.doctor()).join("\n"))
3634
- },
3635
- { name: "login", description: "Sign in again or switch account", run: (ctx) => ctx.exit("login") },
3636
- { name: "logout", description: "Sign out", run: (ctx) => ctx.exit("logout") },
3637
- { name: "exit", description: "Quit", aliases: ["quit"], run: (ctx) => ctx.exit("exit") }
3638
- ];
3952
+ async function withTempIndex(root, seed, use) {
3953
+ const dir = await mkdtemp(path13.join(os4.tmpdir(), "ad-index-"));
3954
+ const index = path13.join(dir, "index");
3955
+ try {
3956
+ if (seed) {
3957
+ const real = (await git(root, ["rev-parse", "--git-path", "index"])).toString("utf8").trim();
3958
+ await copyFile(path13.resolve(root, real), index).catch(() => void 0);
3959
+ }
3960
+ return await use({ GIT_INDEX_FILE: index });
3961
+ } finally {
3962
+ await rm2(dir, { recursive: true, force: true }).catch(() => void 0);
3963
+ }
3639
3964
  }
3640
- function parseFrontmatter(raw) {
3641
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
3642
- if (!match) return { body: raw };
3643
- const description = /^description:\s*(.+)$/m.exec(match[1])?.[1]?.trim();
3644
- return { description, body: match[2] };
3965
+ async function snapshotTree(root) {
3966
+ const exact = await exactBytes(root);
3967
+ return withTempIndex(root, await canSeed(root), async (env) => {
3968
+ await git(root, ["-c", "gc.auto=0", ...exact, "add", "-A", "--", "."], { env });
3969
+ return (await git(root, ["write-tree"], { env })).toString("utf8").trim();
3970
+ });
3645
3971
  }
3646
- async function readCommandDir(dir, source) {
3647
- let names;
3972
+ async function changedBetween(root, before, after) {
3973
+ const out = (await git(root, ["diff-tree", "-r", "--name-status", "--no-renames", "-z", before, after])).toString("utf8");
3974
+ const parts = out.split("\0").filter((p) => p !== "");
3975
+ const changes = [];
3976
+ for (let i = 0; i + 1 < parts.length; i += 2) {
3977
+ const status = parts[i];
3978
+ if (status === "A" || status === "M" || status === "D") changes.push({ path: parts[i + 1], status });
3979
+ }
3980
+ return changes;
3981
+ }
3982
+ async function pruneEmptyDirs(root, file) {
3983
+ let dir = path13.dirname(file);
3984
+ while (dir.length > root.length && dir.startsWith(root)) {
3985
+ try {
3986
+ await rmdir(dir);
3987
+ } catch {
3988
+ return;
3989
+ }
3990
+ dir = path13.dirname(dir);
3991
+ }
3992
+ }
3993
+ async function restoreFromTree(root, tree, wanted) {
3994
+ const report = { restored: [], deleted: [], failed: [] };
3995
+ const changes = wanted.filter((c) => {
3996
+ const rel = path13.relative(root, path13.resolve(root, c.path));
3997
+ const inside = rel !== "" && !rel.startsWith("..") && !path13.isAbsolute(rel);
3998
+ if (!inside) report.failed.push({ file: c.path, reason: "outside the repository" });
3999
+ return inside;
4000
+ });
4001
+ for (const change of changes.filter((c) => c.status === "A")) {
4002
+ const file = path13.resolve(root, change.path);
4003
+ try {
4004
+ await rm2(file, { force: true });
4005
+ await pruneEmptyDirs(root, file);
4006
+ report.deleted.push(file);
4007
+ } catch (error) {
4008
+ report.failed.push({ file, reason: error instanceof Error ? error.message : String(error) });
4009
+ }
4010
+ }
4011
+ const back = changes.filter((c) => c.status !== "A");
4012
+ if (back.length === 0) return report;
4013
+ try {
4014
+ const exact = await exactBytes(root);
4015
+ await withTempIndex(root, false, async (env) => {
4016
+ await git(root, ["read-tree", tree], { env });
4017
+ await git(root, [...exact, "checkout-index", "-f", "-z", "--stdin"], { env, input: back.map((c) => c.path).join("\0") + "\0" });
4018
+ });
4019
+ for (const change of back) report.restored.push(path13.resolve(root, change.path));
4020
+ } catch (error) {
4021
+ const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
4022
+ for (const change of back) report.failed.push({ file: path13.resolve(root, change.path), reason });
4023
+ }
4024
+ return report;
4025
+ }
4026
+
4027
+ // src/agent/checkpoints.ts
4028
+ var MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024;
4029
+ var SLOW_SNAPSHOT_MS = 8e3;
4030
+ var MAX_SNAPSHOT_FAILURES = 3;
4031
+ async function canonical(file) {
4032
+ const abs = path14.resolve(file);
3648
4033
  try {
3649
- names = await readdir3(dir);
4034
+ return await realpath(abs);
3650
4035
  } catch {
3651
- return [];
3652
4036
  }
3653
- const out = [];
3654
- for (const file of names.filter((n) => n.endsWith(".md"))) {
3655
- const name = file.slice(0, -3).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
4037
+ let dir = abs;
4038
+ const rest = [];
4039
+ for (; ; ) {
4040
+ const parent = path14.dirname(dir);
4041
+ rest.unshift(path14.basename(dir));
4042
+ if (parent === dir) return abs;
3656
4043
  try {
3657
- const { description, body } = parseFrontmatter(await readFile9(path12.join(dir, file), "utf8"));
3658
- if (!body.trim()) continue;
3659
- out.push({
3660
- name,
3661
- source,
3662
- args: /\$ARGUMENTS/.test(body) ? "[arguments]" : void 0,
3663
- description: description ?? body.trim().split("\n")[0].slice(0, 70),
3664
- run: (ctx, args) => {
3665
- const prompt = /\$ARGUMENTS/.test(body) ? body.replace(/\$ARGUMENTS/g, args) : args ? `${body}
3666
-
3667
- ${args}` : body;
3668
- ctx.send(prompt.trim(), `/${name}${args ? ` ${args}` : ""}`);
3669
- }
3670
- });
4044
+ return path14.join(await realpath(parent), ...rest);
3671
4045
  } catch {
4046
+ dir = parent;
3672
4047
  }
3673
4048
  }
3674
- return out;
3675
- }
3676
- async function loadCustomCommands(cwd) {
3677
- const [project, user] = await Promise.all([
3678
- readCommandDir(path12.join(cwd, ".ad", "commands"), "project"),
3679
- readCommandDir(path12.join(getConfigDir(), "commands"), "user")
3680
- ]);
3681
- const taken = new Set(project.map((c) => c.name));
3682
- return [...project, ...user.filter((c) => !taken.has(c.name))];
3683
4049
  }
3684
- function findCommand(commands, name) {
3685
- const n = name.toLowerCase();
3686
- return commands.find((c) => c.name === n) ?? commands.find((c) => c.aliases?.includes(n));
4050
+ var TREE_ID = /^[0-9a-f]{40,64}$/;
4051
+ function isInsideRelative(p) {
4052
+ return typeof p === "string" && p.length > 0 && !p.includes("\0") && !path14.isAbsolute(p) && !p.split(/[\\/]/).includes("..");
4053
+ }
4054
+ function parseShell(raw) {
4055
+ if (raw === void 0) return [];
4056
+ if (!Array.isArray(raw)) return void 0;
4057
+ const out = [];
4058
+ for (const r of raw) {
4059
+ if (!r || typeof r.root !== "string" || typeof r.tree !== "string" || !TREE_ID.test(r.tree) || !Number.isFinite(r.at) || !Array.isArray(r.changes)) return void 0;
4060
+ const changes = [];
4061
+ for (const c of r.changes) {
4062
+ if (!c || !isInsideRelative(c.path) || !(c.status === "A" || c.status === "M" || c.status === "D")) return void 0;
4063
+ changes.push({ path: c.path, status: c.status });
4064
+ }
4065
+ out.push({ root: r.root, tree: r.tree, at: r.at, changes });
4066
+ }
4067
+ return out;
4068
+ }
4069
+ var CheckpointStore = class _CheckpointStore {
4070
+ checkpoints = [];
4071
+ nextId = 1;
4072
+ rev = 0;
4073
+ /** Files too big to snapshot, per checkpoint id. */
4074
+ oversize = /* @__PURE__ */ new Map();
4075
+ /** Repository root per working directory (null: not in one), so git is asked once. */
4076
+ repoRoots = /* @__PURE__ */ new Map();
4077
+ shellOff = false;
4078
+ snapshotFailures = 0;
4079
+ /** Changes whenever the checkpoints do, so a caller can tell whether there is anything new to save. */
4080
+ get revision() {
4081
+ return this.rev;
4082
+ }
4083
+ /** Starts a checkpoint; later `recordBefore` and `trackShell` calls belong to it. */
4084
+ begin(prompt, historyLength) {
4085
+ const checkpoint = {
4086
+ id: this.nextId++,
4087
+ prompt,
4088
+ at: Date.now(),
4089
+ historyLength,
4090
+ files: /* @__PURE__ */ new Map(),
4091
+ fileTimes: /* @__PURE__ */ new Map(),
4092
+ shell: [],
4093
+ untrackedShell: 0
4094
+ };
4095
+ this.checkpoints.push(checkpoint);
4096
+ this.rev++;
4097
+ return checkpoint;
4098
+ }
4099
+ // ---- saving with the session ---------------------------------------------------------------
4100
+ /** Everything needed to rebuild this store later (file contents as base64). */
4101
+ toJSON() {
4102
+ return {
4103
+ version: 2,
4104
+ nextId: this.nextId,
4105
+ checkpoints: this.checkpoints.map((c) => ({
4106
+ id: c.id,
4107
+ prompt: c.prompt,
4108
+ at: c.at,
4109
+ historyLength: c.historyLength,
4110
+ files: [...c.files].map(([file, content]) => [file, content === null ? null : content.toString("base64"), c.fileTimes.get(file) ?? c.at]),
4111
+ oversize: [...this.oversize.get(c.id) ?? []],
4112
+ shell: c.shell,
4113
+ untrackedShell: c.untrackedShell
4114
+ }))
4115
+ };
4116
+ }
4117
+ /** Rebuilds a store from `toJSON` output (version 1 files, from before shell tracking, load too). Anything that does not look right is refused, not guessed at. */
4118
+ static fromJSON(data) {
4119
+ const d = data;
4120
+ if (!d || d.version !== 1 && d.version !== 2 || !Array.isArray(d.checkpoints) || !Number.isInteger(d.nextId)) return void 0;
4121
+ const store = new _CheckpointStore();
4122
+ let maxId = 0;
4123
+ for (const c of d.checkpoints) {
4124
+ if (!c || !Number.isInteger(c.id) || typeof c.prompt !== "string" || !Number.isFinite(c.at) || !Number.isInteger(c.historyLength) || c.historyLength < 0) return void 0;
4125
+ if (!Array.isArray(c.files) || !Array.isArray(c.oversize) || c.id <= maxId) return void 0;
4126
+ const files = /* @__PURE__ */ new Map();
4127
+ const fileTimes = /* @__PURE__ */ new Map();
4128
+ for (const entry of c.files) {
4129
+ if (!Array.isArray(entry) || typeof entry[0] !== "string" || !(entry[1] === null || typeof entry[1] === "string")) return void 0;
4130
+ files.set(entry[0], entry[1] === null ? null : Buffer.from(entry[1], "base64"));
4131
+ fileTimes.set(entry[0], typeof entry[2] === "number" && Number.isFinite(entry[2]) ? entry[2] : c.at);
4132
+ }
4133
+ const shell = parseShell(c.shell);
4134
+ if (!shell) return void 0;
4135
+ const untracked = typeof c.untrackedShell === "number" && c.untrackedShell >= 0 ? Math.floor(c.untrackedShell) : 0;
4136
+ store.checkpoints.push({ id: c.id, prompt: c.prompt, at: c.at, historyLength: c.historyLength, files, fileTimes, shell, untrackedShell: untracked });
4137
+ if (c.oversize.length) store.oversize.set(c.id, new Set(c.oversize.filter((f) => typeof f === "string")));
4138
+ maxId = c.id;
4139
+ }
4140
+ store.nextId = Math.max(d.nextId, maxId + 1);
4141
+ return store;
4142
+ }
4143
+ /** Bytes of saved file content, which is what the size cap counts. */
4144
+ sizeBytes() {
4145
+ let total = 0;
4146
+ for (const c of this.checkpoints) for (const content of c.files.values()) total += content?.length ?? 0;
4147
+ return total;
4148
+ }
4149
+ /** Drops the OLDEST checkpoints until the saved content fits. Returns how many were dropped. */
4150
+ trimToBytes(max) {
4151
+ let dropped = 0;
4152
+ while (this.checkpoints.length > 0 && this.sizeBytes() > max) {
4153
+ const oldest = this.checkpoints.shift();
4154
+ this.oversize.delete(oldest.id);
4155
+ dropped++;
4156
+ }
4157
+ if (dropped) this.rev++;
4158
+ return dropped;
4159
+ }
4160
+ /** Forgets checkpoints that point past the end of a conversation (it was saved shorter than they remember). */
4161
+ dropBeyond(historyLength) {
4162
+ const keep = this.checkpoints.filter((c) => c.historyLength <= historyLength);
4163
+ if (keep.length !== this.checkpoints.length) {
4164
+ this.checkpoints.splice(0, this.checkpoints.length, ...keep);
4165
+ this.rev++;
4166
+ }
4167
+ }
4168
+ /** Oldest first. */
4169
+ list() {
4170
+ return [...this.checkpoints];
4171
+ }
4172
+ get(id) {
4173
+ return this.checkpoints.find((c) => c.id === id);
4174
+ }
4175
+ // ---- recording -----------------------------------------------------------------------------
4176
+ /** Remembers `file` as it is now, once per checkpoint. Call before the file is changed. */
4177
+ async recordBefore(file) {
4178
+ const current = this.checkpoints.at(-1);
4179
+ if (!current) return;
4180
+ const key = await canonical(file);
4181
+ if (current.files.has(key) || this.oversize.get(current.id)?.has(key)) return;
4182
+ const at = Date.now();
4183
+ let size;
4184
+ try {
4185
+ size = (await stat5(key)).size;
4186
+ } catch {
4187
+ current.files.set(key, null);
4188
+ current.fileTimes.set(key, at);
4189
+ this.rev++;
4190
+ return;
4191
+ }
4192
+ if (size > MAX_SNAPSHOT_BYTES) {
4193
+ const set = this.oversize.get(current.id) ?? /* @__PURE__ */ new Set();
4194
+ set.add(key);
4195
+ this.oversize.set(current.id, set);
4196
+ this.rev++;
4197
+ return;
4198
+ }
4199
+ try {
4200
+ current.files.set(key, await readFile10(key));
4201
+ current.fileTimes.set(key, at);
4202
+ this.rev++;
4203
+ } catch {
4204
+ }
4205
+ }
4206
+ /**
4207
+ * Runs a shell command and records which files it changed. Inside a git repository the work tree is
4208
+ * snapshotted before and after; the difference is what the command did. Outside one (or if git fails or is
4209
+ * too slow) the command still runs, and the checkpoint remembers that a command could not be tracked.
4210
+ */
4211
+ async trackShell(cwd, run2) {
4212
+ const current = this.checkpoints.at(-1);
4213
+ if (!current) return run2();
4214
+ const untracked = () => {
4215
+ current.untrackedShell++;
4216
+ this.rev++;
4217
+ };
4218
+ if (this.shellOff) {
4219
+ untracked();
4220
+ return run2();
4221
+ }
4222
+ let root = this.repoRoots.get(cwd);
4223
+ if (root === void 0) {
4224
+ const top = await repoRoot(cwd);
4225
+ root = top ? await canonical(top) : null;
4226
+ this.repoRoots.set(cwd, root);
4227
+ }
4228
+ if (!root) {
4229
+ untracked();
4230
+ return run2();
4231
+ }
4232
+ const at = Date.now();
4233
+ let before;
4234
+ try {
4235
+ before = await snapshotTree(root);
4236
+ this.snapshotFailures = 0;
4237
+ } catch {
4238
+ if (++this.snapshotFailures >= MAX_SNAPSHOT_FAILURES) this.shellOff = true;
4239
+ }
4240
+ if (!before) {
4241
+ untracked();
4242
+ return run2();
4243
+ }
4244
+ if (Date.now() - at > SLOW_SNAPSHOT_MS) this.shellOff = true;
4245
+ try {
4246
+ return await run2();
4247
+ } finally {
4248
+ try {
4249
+ const after = await snapshotTree(root);
4250
+ if (after !== before) {
4251
+ const changes = await changedBetween(root, before, after);
4252
+ if (changes.length) {
4253
+ current.shell.push({ root, tree: before, at, changes });
4254
+ this.rev++;
4255
+ }
4256
+ }
4257
+ } catch {
4258
+ untracked();
4259
+ }
4260
+ }
4261
+ }
4262
+ // ---- looking and undoing -------------------------------------------------------------------
4263
+ /** Files changed from this checkpoint onward, by file tools or shell commands. */
4264
+ changedSince(id) {
4265
+ const seen = /* @__PURE__ */ new Set();
4266
+ for (const c of this.checkpoints) {
4267
+ if (c.id < id) continue;
4268
+ for (const file of c.files.keys()) seen.add(file);
4269
+ for (const file of this.oversize.get(c.id) ?? []) seen.add(file);
4270
+ for (const rec of c.shell) for (const change of rec.changes) seen.add(path14.resolve(rec.root, change.path));
4271
+ }
4272
+ return [...seen];
4273
+ }
4274
+ /** How many shell commands since `id` ran where their changes could not be tracked. */
4275
+ untrackedShellSince(id) {
4276
+ return this.checkpoints.filter((c) => c.id >= id).reduce((n, c) => n + c.untrackedShell, 0);
4277
+ }
4278
+ /** How many files shell commands changed since `id`. */
4279
+ shellChangesSince(id) {
4280
+ const files = /* @__PURE__ */ new Set();
4281
+ for (const c of this.checkpoints) {
4282
+ if (c.id < id) continue;
4283
+ for (const rec of c.shell) for (const change of rec.changes) files.add(path14.resolve(rec.root, change.path));
4284
+ }
4285
+ return files.size;
4286
+ }
4287
+ /** Puts every file changed since `id` back to how it was when that checkpoint began. */
4288
+ async restoreFiles(id) {
4289
+ const report = { restored: [], deleted: [], failed: [], skipped: [] };
4290
+ const earliest = /* @__PURE__ */ new Map();
4291
+ const consider = (file, source) => {
4292
+ const have = earliest.get(file);
4293
+ if (!have || source.time < have.time) earliest.set(file, source);
4294
+ };
4295
+ const oversize = /* @__PURE__ */ new Set();
4296
+ for (const c of this.checkpoints) {
4297
+ if (c.id < id) continue;
4298
+ for (const [file, content] of c.files) consider(file, { time: c.fileTimes.get(file) ?? c.at, kind: "file", content });
4299
+ for (const rec of c.shell) {
4300
+ for (const change of rec.changes) consider(path14.resolve(rec.root, change.path), { time: rec.at, kind: "tree", root: rec.root, tree: rec.tree, change });
4301
+ }
4302
+ for (const file of this.oversize.get(c.id) ?? []) oversize.add(file);
4303
+ }
4304
+ const trees = /* @__PURE__ */ new Map();
4305
+ for (const [file, source] of earliest) {
4306
+ if (source.kind === "tree") {
4307
+ const key = `${source.root}\0${source.tree}`;
4308
+ const group = trees.get(key) ?? { root: source.root, tree: source.tree, changes: [] };
4309
+ group.changes.push(source.change);
4310
+ trees.set(key, group);
4311
+ continue;
4312
+ }
4313
+ try {
4314
+ if (source.content === null) {
4315
+ await rm3(file, { force: true });
4316
+ report.deleted.push(file);
4317
+ } else {
4318
+ await mkdir6(path14.dirname(file), { recursive: true });
4319
+ await writeFile7(file, source.content);
4320
+ report.restored.push(file);
4321
+ }
4322
+ } catch (error) {
4323
+ report.failed.push({ file, reason: error instanceof Error ? error.message : String(error) });
4324
+ }
4325
+ }
4326
+ for (const group of trees.values()) {
4327
+ const done = await restoreFromTree(group.root, group.tree, group.changes);
4328
+ report.restored.push(...done.restored);
4329
+ report.deleted.push(...done.deleted);
4330
+ report.failed.push(...done.failed);
4331
+ }
4332
+ for (const file of oversize) if (!earliest.has(file)) report.skipped.push(file);
4333
+ return report;
4334
+ }
4335
+ /** Forgets this checkpoint and everything after it (the conversation went back past them). */
4336
+ dropFrom(id) {
4337
+ for (let i = this.checkpoints.length - 1; i >= 0; i--) {
4338
+ if (this.checkpoints[i].id >= id) {
4339
+ this.oversize.delete(this.checkpoints[i].id);
4340
+ this.checkpoints.splice(i, 1);
4341
+ }
4342
+ }
4343
+ this.rev++;
4344
+ }
4345
+ /** Keeps the checkpoints but forgets their saved changes (code was restored, so they are stale). */
4346
+ forgetFilesFrom(id) {
4347
+ for (const c of this.checkpoints) {
4348
+ if (c.id >= id) {
4349
+ c.files.clear();
4350
+ c.fileTimes.clear();
4351
+ c.shell = [];
4352
+ c.untrackedShell = 0;
4353
+ this.oversize.delete(c.id);
4354
+ }
4355
+ }
4356
+ this.rev++;
4357
+ }
4358
+ /** Forgets everything: the conversation was cleared, compacted or replaced, so message positions no longer mean anything. */
4359
+ clear() {
4360
+ this.checkpoints.length = 0;
4361
+ this.oversize.clear();
4362
+ this.rev++;
4363
+ }
4364
+ };
4365
+
4366
+ // src/agent/checkpoint-file.ts
4367
+ import { readFile as readFile11, rm as rm4 } from "fs/promises";
4368
+ import path15 from "path";
4369
+ var MAX_PERSISTED_BYTES = 48 * 1024 * 1024;
4370
+ function fileFor2(sessionId) {
4371
+ if (!/^[A-Za-z0-9-]+$/.test(sessionId)) throw new Error("Invalid session id.");
4372
+ return path15.join(getConfigDir(), "checkpoints", `${sessionId}.json`);
4373
+ }
4374
+ async function saveCheckpoints(sessionId, store) {
4375
+ const file = fileFor2(sessionId);
4376
+ if (store.list().length === 0) {
4377
+ await rm4(file, { force: true });
4378
+ return;
4379
+ }
4380
+ store.trimToBytes(MAX_PERSISTED_BYTES);
4381
+ await writePrivateFile(file, JSON.stringify(store.toJSON()));
4382
+ }
4383
+ async function loadCheckpoints(sessionId, messageCount) {
4384
+ try {
4385
+ const store = CheckpointStore.fromJSON(JSON.parse(await readFile11(fileFor2(sessionId), "utf8")));
4386
+ if (!store) return void 0;
4387
+ store.dropBeyond(messageCount);
4388
+ return store.list().length ? store : void 0;
4389
+ } catch {
4390
+ return void 0;
4391
+ }
4392
+ }
4393
+
4394
+ // src/agent/coordinator.ts
4395
+ var MAX_COORDINATOR_ROUNDS = 5;
4396
+ var MAX_EAGER_ROUNDS = 12;
4397
+ function coordinatorPrompt(goal, options = {}) {
4398
+ const wakeNote = options.eager ? "you will be woken, with its report, each time a worker finishes (others may still be running). Use each early report to start follow-up work, but give the user the final result only once every worker has finished." : "you will be woken automatically, with their reports, once every worker has finished.";
4399
+ return `You are now acting as a COORDINATOR for this goal:
4400
+
4401
+ ${goal.trim()}
4402
+
4403
+ How to work:
4404
+ 1. Look around first, read-only, just enough to plan well. Do not start implementing yourself.
4405
+ 2. Split the goal into 2-5 independent sub-tasks that can run in parallel without editing the same files. If it cannot be split sensibly, say so and do it yourself instead.
4406
+ 3. Start one worker per sub-task with spawn_agent. Give each a short name and a complete, self-contained prompt: the overall goal, its scope, which files it owns, constraints, and exactly what to report back. Tell each worker who its teammates are so it can hand work over with message_agent. Use subagent_type "explore" for pure research or review and "general" for anything that changes files.
4407
+ 4. Then stop, and say in a few lines what you started. Do NOT wait or poll: ${wakeNote}
4408
+ 5. When woken, check each report against the goal, deal with anything that failed or is missing (start follow-up workers or fix it yourself), run the project's tests or build if code changed, and give the user one consolidated result.`;
4409
+ }
4410
+ function wakePrompt(goal, round, max = MAX_COORDINATOR_ROUNDS, stillRunning = 0) {
4411
+ if (stillRunning > 0) {
4412
+ return `[coordinator] Round ${round} of ${max}: some background agents have finished (reports above); ${stillRunning} ${stillRunning === 1 ? "is" : "are"} still running. Check what finished against the goal ("${goal.trim().slice(0, 200)}") and act on anything failed or missing (you may start follow-up workers). Do NOT give the final answer yet: say in a line what you have so far and stop. You will be woken again as the rest finish.`;
4413
+ }
4414
+ return `[coordinator] Round ${round} of ${max}: every background agent has finished; their reports are above. Check them against the goal ("${goal.trim().slice(0, 200)}"), fix or follow up on anything failed or missing (you may start more workers), verify the result (run the tests or build if code changed), and give the user the final consolidated answer. If you start more workers, say so and stop: you will be woken again when they finish.`;
4415
+ }
4416
+ function shouldWake(input) {
4417
+ if (input.running) return "wait";
4418
+ if (input.finished > 0) {
4419
+ if (input.live > 0 && !input.eager) return "wait";
4420
+ const max = input.maxRounds ?? (input.eager ? MAX_EAGER_ROUNDS : MAX_COORDINATOR_ROUNDS);
4421
+ return input.round >= max ? "stop-limit" : "wake";
4422
+ }
4423
+ if (input.live > 0) return "wait";
4424
+ return input.spawned ? "wait" : "stop-idle";
4425
+ }
4426
+
4427
+ // src/lib/review.ts
4428
+ import { execFile as execFile2 } from "child_process";
4429
+ import { promisify } from "util";
4430
+ var exec = promisify(execFile2);
4431
+ var MAX_DIFF_CHARS = 6e4;
4432
+ function isSafeRef(ref) {
4433
+ return /^[A-Za-z0-9][A-Za-z0-9._/~^@{}:-]*$/.test(ref) && ref.length <= 200;
4434
+ }
4435
+ async function git2(cwd, args) {
4436
+ const { stdout } = await exec("git", args, { cwd, maxBuffer: 32 * 1024 * 1024, windowsHide: true });
4437
+ return stdout;
4438
+ }
4439
+ function cut(diff) {
4440
+ if (diff.length <= MAX_DIFF_CHARS) return { text: diff, truncated: false };
4441
+ const head = diff.slice(0, MAX_DIFF_CHARS);
4442
+ return { text: head.slice(0, head.lastIndexOf("\n") + 1 || MAX_DIFF_CHARS), truncated: true };
4443
+ }
4444
+ async function collectReviewTarget(cwd, ref) {
4445
+ try {
4446
+ await git2(cwd, ["rev-parse", "--is-inside-work-tree"]);
4447
+ } catch {
4448
+ throw new Error("This folder is not a git repository, so there is no change to review.");
4449
+ }
4450
+ const wanted = ref?.trim();
4451
+ if (wanted && !isSafeRef(wanted)) {
4452
+ throw new Error(`"${wanted}" is not a branch, tag, commit or range I can use. Try a name like main, HEAD~3 or origin/main..HEAD.`);
4453
+ }
4454
+ const base = ["diff", "--no-color", "--no-ext-diff"];
4455
+ let range;
4456
+ let label;
4457
+ if (wanted) {
4458
+ range = [wanted];
4459
+ label = `changes since ${wanted}`;
4460
+ } else {
4461
+ label = "uncommitted changes";
4462
+ range = await git2(cwd, ["rev-parse", "--verify", "HEAD"]).then(
4463
+ () => ["HEAD"],
4464
+ () => ["--cached"]
4465
+ );
4466
+ }
4467
+ let diff;
4468
+ let names;
4469
+ try {
4470
+ diff = await git2(cwd, [...base, ...range, "--"]);
4471
+ names = await git2(cwd, ["diff", "--name-only", ...range, "--"]);
4472
+ } catch (error) {
4473
+ const detail = error instanceof Error ? error.stderr?.trim() || error.message : String(error);
4474
+ throw new Error(wanted ? `Git could not compare with "${wanted}": ${detail.split("\n")[0]}` : `Git could not read the changes: ${detail.split("\n")[0]}`);
4475
+ }
4476
+ const untracked = wanted ? [] : (await git2(cwd, ["ls-files", "--others", "--exclude-standard"]).catch(() => "")).split("\n").map((l) => l.trim()).filter(Boolean);
4477
+ const files = names.split("\n").map((l) => l.trim()).filter(Boolean);
4478
+ if (!diff.trim() && untracked.length === 0) {
4479
+ throw new Error(wanted ? `Nothing differs from ${wanted}.` : "Nothing to review: there are no uncommitted changes.");
4480
+ }
4481
+ const { text: text2, truncated } = cut(diff);
4482
+ return { label, diff: text2, files, untracked, truncated };
4483
+ }
4484
+ function buildReviewPrompt(target) {
4485
+ const list2 = (items, max = 60) => `${items.slice(0, max).map((f) => `- ${f}`).join("\n")}${items.length > max ? `
4486
+ - \u2026 and ${items.length - max} more` : ""}`;
4487
+ return [
4488
+ `Review this change (${target.label}) for problems that would matter in production. You are read-only: do not modify anything.`,
4489
+ "",
4490
+ "Look for: correctness bugs, security problems, bad error handling, races and data loss, and missing or wrong tests. Ignore style, naming and formatting.",
4491
+ "Read the surrounding code with read_file and grep whenever the diff alone does not show enough to judge. Do not guess: report only what you have verified in the code.",
4492
+ "",
4493
+ "For each finding give: severity (high, medium or low), file:line, what is wrong, a concrete input or state that triggers it, and a suggested fix. Most serious first.",
4494
+ 'If you find nothing, say "No issues found" and list what you checked.',
4495
+ "",
4496
+ target.files.length ? `Files changed (${target.files.length}):
4497
+ ${list2(target.files)}` : "No tracked files changed.",
4498
+ ...target.untracked.length ? ["", `New files git does not track yet (not in the diff below; read them):
4499
+ ${list2(target.untracked)}`] : [],
4500
+ ...target.truncated ? ["", `NOTE: the diff below was cut at ${MAX_DIFF_CHARS.toLocaleString()} characters. Read the files above for the rest.`] : [],
4501
+ "",
4502
+ "```diff",
4503
+ target.diff.trimEnd(),
4504
+ "```"
4505
+ ].join("\n");
4506
+ }
4507
+
4508
+ // src/agent/orchestrator.ts
4509
+ var DEFAULT_MAX_RUNNING_WORKERS = 3;
4510
+ var DEFAULT_MAX_ACTIVE_WORKERS = 8;
4511
+ var DEFAULT_MAX_TEAM_MESSAGES = 200;
4512
+ var MAX_MESSAGE_CHARS = 8e3;
4513
+ var MAX_INBOX = 50;
4514
+ var LEAD_NAMES = /* @__PURE__ */ new Set(["main", "lead", "you", "user"]);
4515
+ var isLive = (w) => w.status === "queued" || w.status === "running";
4516
+ var Orchestrator = class {
4517
+ constructor(runner, options = {}) {
4518
+ this.runner = runner;
4519
+ this.slot = concurrencyLimit(Math.max(1, options.maxRunning ?? DEFAULT_MAX_RUNNING_WORKERS));
4520
+ this.maxActive = Math.max(1, options.maxActive ?? DEFAULT_MAX_ACTIVE_WORKERS);
4521
+ this.maxMessages = Math.max(1, options.maxMessages ?? DEFAULT_MAX_TEAM_MESSAGES);
4522
+ }
4523
+ runner;
4524
+ workers = /* @__PURE__ */ new Map();
4525
+ controllers = /* @__PURE__ */ new Map();
4526
+ listeners = /* @__PURE__ */ new Set();
4527
+ mainListeners = /* @__PURE__ */ new Set();
4528
+ slot;
4529
+ maxActive;
4530
+ maxMessages;
4531
+ seq = 0;
4532
+ sent = 0;
4533
+ /** Mail for the lead agent (the one the user talks to), waiting for its next turn. */
4534
+ mainInbox = [];
4535
+ // ---- watching ------------------------------------------------------------------------------
4536
+ /** Called after any change to any worker. Returns the unsubscribe. */
4537
+ subscribe(listener) {
4538
+ this.listeners.add(listener);
4539
+ return () => this.listeners.delete(listener);
4540
+ }
4541
+ /** Called when a worker writes to the lead. */
4542
+ onMainMessage(listener) {
4543
+ this.mainListeners.add(listener);
4544
+ return () => this.mainListeners.delete(listener);
4545
+ }
4546
+ emit() {
4547
+ for (const listener of [...this.listeners]) {
4548
+ try {
4549
+ listener();
4550
+ } catch {
4551
+ }
4552
+ }
4553
+ }
4554
+ // ---- reading -------------------------------------------------------------------------------
4555
+ list() {
4556
+ return [...this.workers.values()];
4557
+ }
4558
+ /** Queued or running. */
4559
+ activeCount() {
4560
+ return this.list().filter(isLive).length;
4561
+ }
4562
+ /** By id, then name, then a unique prefix of either ("res" finds "researcher"). */
4563
+ find(query) {
4564
+ const q = query.trim().toLowerCase();
4565
+ if (!q) return void 0;
4566
+ const all = this.list();
4567
+ const exact = all.find((w) => w.id === q || w.name === q);
4568
+ if (exact) return exact;
4569
+ const prefixed = all.filter((w) => w.id.startsWith(q) || w.name.startsWith(q));
4570
+ return prefixed.length === 1 ? prefixed[0] : void 0;
4571
+ }
4572
+ /** Mail for the lead since it last looked. Reading it empties the box. */
4573
+ takeMainMail() {
4574
+ const mail = this.mainInbox;
4575
+ this.mainInbox = [];
4576
+ return mail;
4577
+ }
4578
+ /** The team as text, for the model (`list_agents`) and for /workers. */
4579
+ describe(self = "main") {
4580
+ const lines = [`- main (the lead agent the user talks to)${self === "main" ? " \u2190 you" : ""}`];
4581
+ for (const w of this.list()) {
4582
+ const bits = [w.status, w.description];
4583
+ if (w.toolUses) bits.push(`${w.toolUses} tool use${w.toolUses === 1 ? "" : "s"}`);
4584
+ if (isLive(w) && w.activity) bits.push(w.activity);
4585
+ lines.push(`- ${w.name} [${w.id}]${w.name === self ? " \u2190 you" : ""}: ${bits.join(" \xB7 ")}`);
4586
+ }
4587
+ return lines.join("\n");
4588
+ }
4589
+ // ---- acting --------------------------------------------------------------------------------
4590
+ spawn(spec) {
4591
+ if (this.activeCount() >= this.maxActive) {
4592
+ throw new Error(`Already ${this.maxActive} workers are running or waiting. Wait for some to finish, or cancel one, before starting another.`);
4593
+ }
4594
+ const id = `w${++this.seq}`;
4595
+ const worker = {
4596
+ id,
4597
+ name: this.uniqueName(spec.name, id),
4598
+ description: spec.description.trim() || "worker",
4599
+ prompt: spec.prompt,
4600
+ type: spec.type,
4601
+ status: "queued",
4602
+ createdAt: Date.now(),
4603
+ toolUses: 0,
4604
+ tokens: 0,
4605
+ history: [{ role: "user", content: spec.prompt }],
4606
+ inbox: [],
4607
+ log: [],
4608
+ ...spec.interactive ? { interactive: true } : {},
4609
+ ...spec.shown ? { shown: spec.shown } : {}
4610
+ };
4611
+ this.workers.set(id, worker);
4612
+ this.drive(worker);
4613
+ return worker;
4614
+ }
4615
+ /**
4616
+ * Delivers a message. A worker that had finished wakes up to handle it. Throws a plain Error the model
4617
+ * (or the user) can act on.
4618
+ */
4619
+ send(from, to, text2) {
4620
+ const body = text2.trim();
4621
+ if (!body) throw new Error("The message is empty.");
4622
+ if (body.length > MAX_MESSAGE_CHARS) throw new Error(`The message is too long (${body.length} characters, at most ${MAX_MESSAGE_CHARS}). Shorten it.`);
4623
+ if (this.sent >= this.maxMessages) {
4624
+ throw new Error(`The team has used its budget of ${this.maxMessages} messages. Stop messaging and report to the user.`);
4625
+ }
4626
+ const target = to.trim().toLowerCase();
4627
+ const sender = this.find(from);
4628
+ if (LEAD_NAMES.has(target)) {
4629
+ if (from === "main") throw new Error("You are the lead agent: that message would go to yourself.");
4630
+ const message2 = { from, to: "main", text: body, at: Date.now() };
4631
+ this.sent++;
4632
+ this.mainInbox.push(message2);
4633
+ sender?.log.push(message2);
4634
+ for (const listener of [...this.mainListeners]) listener(message2);
4635
+ this.emit();
4636
+ return "Delivered to main.";
4637
+ }
4638
+ const worker = this.find(target);
4639
+ if (!worker) {
4640
+ throw new Error(`No agent named "${to}". Teammates:
4641
+ ${this.describe(from)}`);
4642
+ }
4643
+ if (worker.name === from) throw new Error("That is you. Message a teammate.");
4644
+ if (worker.status === "cancelled") throw new Error(`${worker.name} was cancelled by the user and cannot take messages.`);
4645
+ if (worker.inbox.length >= MAX_INBOX) throw new Error(`${worker.name} has ${MAX_INBOX} unread messages. Wait for it to catch up.`);
4646
+ const message = { from, to: worker.name, text: body, at: Date.now() };
4647
+ this.sent++;
4648
+ worker.inbox.push(message);
4649
+ worker.log.push(message);
4650
+ sender?.log.push(message);
4651
+ const woke = worker.status === "done" || worker.status === "failed";
4652
+ if (woke) this.drive(worker);
4653
+ else this.emit();
4654
+ return woke ? `Delivered to ${worker.name}. It had finished; it is working on this now.` : `Delivered to ${worker.name}.`;
4655
+ }
4656
+ /** Stops a worker. Finished ones are left as they are. */
4657
+ cancel(query) {
4658
+ const worker = this.find(query);
4659
+ if (!worker) throw new Error(`No worker "${query}".`);
4660
+ if (!isLive(worker)) return worker;
4661
+ worker.status = "cancelled";
4662
+ worker.endedAt = Date.now();
4663
+ worker.activity = void 0;
4664
+ this.controllers.get(worker.id)?.abort();
4665
+ this.emit();
4666
+ return worker;
4667
+ }
4668
+ cancelAll() {
4669
+ let n = 0;
4670
+ for (const worker of this.list()) {
4671
+ if (isLive(worker)) {
4672
+ this.cancel(worker.id);
4673
+ n++;
4674
+ }
4675
+ }
4676
+ return n;
4677
+ }
4678
+ /** What a worker (or the lead, as "main") may do to teammates. */
4679
+ bridgeFor(self, canSpawn) {
4680
+ return {
4681
+ self,
4682
+ send: (to, text2) => this.send(self, to, text2),
4683
+ list: () => this.describe(self),
4684
+ ...canSpawn ? {
4685
+ spawn: (spec) => {
4686
+ const worker = this.spawn(spec);
4687
+ return `Started ${worker.name} [${worker.id}] in the background: ${worker.description}.
4688
+ It reports to you with message_agent, and you can check on it with list_agents. Carry on; do not wait for it unless you have nothing else to do.`;
4689
+ }
4690
+ } : {}
4691
+ };
4692
+ }
4693
+ // ---- running -------------------------------------------------------------------------------
4694
+ uniqueName(wanted, id) {
4695
+ const base = (wanted ?? "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
4696
+ let name = !base || LEAD_NAMES.has(base) ? id : base;
4697
+ const stem = name;
4698
+ const taken = (n) => this.list().some((w) => w.name === n || w.id === n);
4699
+ for (let i = 2; taken(name); i++) name = `${stem}-${i}`;
4700
+ return name;
4701
+ }
4702
+ /** Takes a slot, then does the worker's stretch(es) of work. Used for the first run and for a wake-up. */
4703
+ drive(worker) {
4704
+ const ac = new AbortController();
4705
+ this.controllers.set(worker.id, ac);
4706
+ worker.status = "queued";
4707
+ worker.endedAt = void 0;
4708
+ worker.error = void 0;
4709
+ this.emit();
4710
+ void this.slot(async () => {
4711
+ if (ac.signal.aborted) return;
4712
+ worker.status = "running";
4713
+ worker.startedAt ??= Date.now();
4714
+ this.emit();
4715
+ const run2 = {
4716
+ worker,
4717
+ signal: ac.signal,
4718
+ drainInbox: () => this.drain(worker),
4719
+ touch: () => this.emit(),
4720
+ bridge: this.bridgeFor(worker.name, false)
4721
+ };
4722
+ try {
4723
+ do {
4724
+ await this.runner(run2);
4725
+ } while (worker.inbox.length > 0 && !ac.signal.aborted);
4726
+ if (!ac.signal.aborted) worker.status = "done";
4727
+ } catch (error) {
4728
+ if (!ac.signal.aborted) {
4729
+ worker.status = "failed";
4730
+ worker.error = error instanceof Error ? error.message : String(error);
4731
+ }
4732
+ } finally {
4733
+ worker.activity = void 0;
4734
+ worker.endedAt ??= Date.now();
4735
+ this.emit();
4736
+ }
4737
+ });
4738
+ }
4739
+ drain(worker) {
4740
+ const mail = worker.inbox.splice(0);
4741
+ return mail.map((m) => ({ role: "user", content: worker.interactive && m.from === "you" ? m.text : `[message from ${m.from}] ${m.text}` }));
4742
+ }
4743
+ };
4744
+ function trackWorkerEvent(worker, event, describe) {
4745
+ switch (event.type) {
4746
+ case "tool_start": {
4747
+ worker.toolUses++;
4748
+ const name = event.call.function.name;
4749
+ worker.activity = `${name}(${describe(name, event.call.function.arguments)})`;
4750
+ return true;
4751
+ }
4752
+ case "usage":
4753
+ worker.tokens += event.usage.total_tokens;
4754
+ return true;
4755
+ case "assistant":
4756
+ if (event.content.trim()) worker.result = event.content;
4757
+ return false;
4758
+ default:
4759
+ return false;
4760
+ }
4761
+ }
4762
+
4763
+ // src/agent/worker-runner.ts
4764
+ var WORKER_NOTE = (worker) => `
4765
+
4766
+ You are "${worker.name}", a worker agent on a team. The lead agent ("main") started you for this job and the user can watch your progress.
4767
+ - Teammates and the lead can message you; their messages appear in the conversation as "[message from <name>]". Read them and adjust.
4768
+ - Use message_agent to hand over results, ask a question or ask for a review, and list_agents to see who is on the team.
4769
+ - Do not ask the user questions: ask the lead with message_agent instead.
4770
+ - When your job is done, reply with a short final report (what you did, what you found, anything the lead must follow up on) and stop. Do not wait for replies in a loop; if you are waiting on a teammate, finish with what you have and say what is pending.`;
4771
+ var SIDE_NOTE = (worker) => `
4772
+
4773
+ You are "${worker.name}", a SIDE assistant. The user opened you next to their main conversation to ask something on the side, and chats with you directly in a panel while the main agent keeps working on its own task.
4774
+ - Their messages arrive as ordinary user messages. Answer them directly and concisely: this is a conversation, so there is no final report to write.
4775
+ - The first message includes the main conversation so far, for context only. Do not repeat or take over the main agent's work.${worker.type === "explore" ? " You can read files and search but you cannot change anything." : " Only change files if the user clearly asks you to."}
4776
+ - The main agent is "main". Message it with message_agent only when the user asks you to pass something on.`;
4777
+ var TEAM_NOTE = (worker) => worker.interactive ? SIDE_NOTE(worker) : WORKER_NOTE(worker);
4778
+ function createWorkerRunner(base) {
4779
+ return async ({ worker, signal, drainInbox, touch, bridge }) => {
4780
+ const b = base();
4781
+ const allowed = SUBAGENT_TOOLS[worker.type];
4782
+ const system = await buildSystemPrompt(b.cwd, {
4783
+ tools: true,
4784
+ subagent: worker.type,
4785
+ agentName: worker.name,
4786
+ artifactTool: allowed("create_artifact") && b.toolFilter("create_artifact")
4787
+ }) + TEAM_NOTE(worker);
4788
+ await runAgentTurn({
4789
+ config: b.config,
4790
+ model: await b.model(),
4791
+ system,
4792
+ history: worker.history,
4793
+ cwd: b.cwd,
4794
+ mode: b.mode,
4795
+ setMode: b.setMode,
4796
+ rules: b.rules,
4797
+ readFiles: b.readFiles,
4798
+ ask: b.askFor(worker, signal),
4799
+ signal,
4800
+ ...b.checkpoint ? { checkpoint: b.checkpoint } : {},
4801
+ ...b.shellTracker ? { shellTracker: b.shellTracker } : {},
4802
+ sessionId: b.sessionId,
4803
+ // Depth 1: no `task`, no verify nudge, and the loop treats it as a helper rather than the main agent.
4804
+ depth: 1,
4805
+ maxSteps: b.maxSteps ?? DEFAULT_SUBAGENT_MAX_STEPS * 2,
4806
+ toolFilter: (name) => allowed(name) && b.toolFilter(name),
4807
+ team: bridge,
4808
+ drainInbox,
4809
+ onEvent: (event) => {
4810
+ if (event.type === "usage") b.onUsage?.(event.usage);
4811
+ if (trackWorkerEvent(worker, event, describeCall)) touch();
4812
+ }
4813
+ });
4814
+ };
4815
+ }
4816
+
4817
+ // src/lib/session-bus.ts
4818
+ import { randomUUID as randomUUID4 } from "crypto";
4819
+ import { readFile as readFile12, readdir as readdir3, rm as rm5 } from "fs/promises";
4820
+ import path16 from "path";
4821
+ var MAX_SESSION_MESSAGE_CHARS = 8e3;
4822
+ var MAX_INBOX_MESSAGES = 50;
4823
+ var SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
4824
+ function isPidAlive(pid) {
4825
+ if (!Number.isInteger(pid) || pid <= 0) return false;
4826
+ try {
4827
+ process.kill(pid, 0);
4828
+ return true;
4829
+ } catch (error) {
4830
+ return error.code === "EPERM";
4831
+ }
4832
+ }
4833
+ function parsePresence(raw, fileId) {
4834
+ try {
4835
+ const p = JSON.parse(raw);
4836
+ if (p.id !== fileId || !SAFE_ID.test(p.id)) return void 0;
4837
+ if (typeof p.pid !== "number" || typeof p.seenAt !== "number" || typeof p.cwd !== "string") return void 0;
4838
+ return {
4839
+ id: p.id,
4840
+ pid: p.pid,
4841
+ cwd: p.cwd,
4842
+ title: typeof p.title === "string" ? p.title : "",
4843
+ agent: typeof p.agent === "string" ? p.agent : "",
4844
+ startedAt: typeof p.startedAt === "number" ? p.startedAt : p.seenAt,
4845
+ seenAt: p.seenAt
4846
+ };
4847
+ } catch {
4848
+ return void 0;
4849
+ }
4850
+ }
4851
+ var SessionBus = class {
4852
+ dir;
4853
+ pollMs;
4854
+ heartbeatMs;
4855
+ staleMs;
4856
+ now;
4857
+ alive;
4858
+ identity;
4859
+ onMessage;
4860
+ lastId;
4861
+ startedAt = 0;
4862
+ timers = [];
4863
+ polling = false;
4864
+ /** Set by stop(): a heartbeat already in flight must not bring the presence file back. */
4865
+ stopped = false;
4866
+ constructor(options = {}) {
4867
+ this.dir = options.dir ?? path16.join(getConfigDir(), "bus");
4868
+ this.pollMs = options.pollMs ?? 1e3;
4869
+ this.heartbeatMs = options.heartbeatMs ?? 5e3;
4870
+ this.staleMs = options.staleMs ?? 2e4;
4871
+ this.now = options.now ?? Date.now;
4872
+ this.alive = options.pidAlive ?? isPidAlive;
4873
+ }
4874
+ presenceFile(id) {
4875
+ return path16.join(this.dir, "presence", `${id}.json`);
4876
+ }
4877
+ inboxDir(id) {
4878
+ return path16.join(this.dir, "inbox", id);
4879
+ }
4880
+ // ---- being here ----------------------------------------------------------------------------
4881
+ /** Registers this session and starts reading its mail. `identity` is read each beat: the id changes on /clear and /resume. */
4882
+ async start(identity, onMessage) {
4883
+ this.identity = identity;
4884
+ this.onMessage = onMessage;
4885
+ this.stopped = false;
4886
+ this.startedAt = this.now();
4887
+ await this.beat().catch(() => void 0);
4888
+ this.timers.push(setInterval(() => void this.beat().catch(() => void 0), this.heartbeatMs));
4889
+ this.timers.push(setInterval(() => void this.poll().catch(() => void 0), this.pollMs));
4890
+ for (const timer of this.timers) timer.unref?.();
4891
+ }
4892
+ /** Leaves: stops timers and removes this session's presence and any unread mail. */
4893
+ async stop() {
4894
+ this.stopped = true;
4895
+ for (const timer of this.timers) clearInterval(timer);
4896
+ this.timers = [];
4897
+ const id = this.lastId;
4898
+ this.lastId = void 0;
4899
+ if (!id) return;
4900
+ await rm5(this.presenceFile(id), { force: true }).catch(() => void 0);
4901
+ await rm5(this.inboxDir(id), { recursive: true, force: true }).catch(() => void 0);
4902
+ }
4903
+ async beat() {
4904
+ if (this.stopped) return;
4905
+ const me = this.identity?.();
4906
+ if (!me || !SAFE_ID.test(me.id)) return;
4907
+ if (this.lastId && this.lastId !== me.id) {
4908
+ await rm5(this.presenceFile(this.lastId), { force: true }).catch(() => void 0);
4909
+ await rm5(this.inboxDir(this.lastId), { recursive: true, force: true }).catch(() => void 0);
4910
+ }
4911
+ this.lastId = me.id;
4912
+ const presence = { ...me, pid: process.pid, startedAt: this.startedAt, seenAt: this.now() };
4913
+ await writePrivateFile(this.presenceFile(me.id), JSON.stringify(presence));
4914
+ if (this.stopped) await rm5(this.presenceFile(me.id), { force: true }).catch(() => void 0);
4915
+ }
4916
+ // ---- seeing others -------------------------------------------------------------------------
4917
+ /** Sessions that are really running, newest first. Prunes the dead and the silent as it goes. */
4918
+ async list() {
4919
+ let names;
4920
+ try {
4921
+ names = await readdir3(path16.join(this.dir, "presence"));
4922
+ } catch {
4923
+ return [];
4924
+ }
4925
+ const live = [];
4926
+ for (const name of names.filter((n) => n.endsWith(".json"))) {
4927
+ const id = name.slice(0, -".json".length);
4928
+ let session;
4929
+ try {
4930
+ session = parsePresence(await readFile12(path16.join(this.dir, "presence", name), "utf8"), id);
4931
+ } catch {
4932
+ continue;
4933
+ }
4934
+ if (session && this.alive(session.pid) && this.now() - session.seenAt <= this.staleMs) live.push(session);
4935
+ else if (SAFE_ID.test(id)) await rm5(path16.join(this.dir, "presence", name), { force: true }).catch(() => void 0);
4936
+ }
4937
+ return live.sort((a, b) => b.startedAt - a.startedAt);
4938
+ }
4939
+ /** The one other session `query` means: by id, a unique id prefix, or a unique title. */
4940
+ async resolve(query, selfId) {
4941
+ const others = (await this.list()).filter((s) => s.id !== selfId);
4942
+ if (others.length === 0) throw new Error("There is no other session running right now. Open another terminal and start `ad` there.");
4943
+ const q = query.trim().toLowerCase();
4944
+ if (!q) throw new Error("Say which session: an id (see /sessions) or part of its title.");
4945
+ const byId = others.filter((s) => s.id.toLowerCase() === q);
4946
+ const byPrefix = others.filter((s) => s.id.toLowerCase().startsWith(q));
4947
+ const byTitle = others.filter((s) => s.title.toLowerCase() === q);
4948
+ const byPart = others.filter((s) => s.title.toLowerCase().includes(q));
4949
+ for (const found of [byId, byPrefix, byTitle, byPart]) {
4950
+ if (found.length === 1) return found[0];
4951
+ if (found.length > 1) {
4952
+ throw new Error(`"${query}" matches ${found.length} sessions. Use more of the id:
4953
+ ${found.map((s) => ` ${s.id.slice(0, 8)} ${s.title || "(untitled)"} ${s.cwd}`).join("\n")}`);
4954
+ }
4955
+ }
4956
+ throw new Error(`No other session matches "${query}". See /sessions.`);
4957
+ }
4958
+ // ---- talking -------------------------------------------------------------------------------
4959
+ /** Delivers `text` to another session. Throws a message the user or the model can act on. */
4960
+ async send(from, query, text2) {
4961
+ const body = text2.trim();
4962
+ if (!body) throw new Error("The message is empty.");
4963
+ if (body.length > MAX_SESSION_MESSAGE_CHARS) {
4964
+ throw new Error(`The message is too long (${body.length} characters, at most ${MAX_SESSION_MESSAGE_CHARS}). Shorten it.`);
4965
+ }
4966
+ if (!SAFE_ID.test(from.id)) throw new Error("This session has no usable id yet.");
4967
+ const target = await this.resolve(query, from.id);
4968
+ const dir = this.inboxDir(target.id);
4969
+ const waiting = (await readdir3(dir).catch(() => [])).filter((n) => n.endsWith(".json")).length;
4970
+ if (waiting >= MAX_INBOX_MESSAGES) {
4971
+ throw new Error(`"${target.title || target.id.slice(0, 8)}" already has ${MAX_INBOX_MESSAGES} unread messages. It is probably not being used; try again later.`);
4972
+ }
4973
+ const at = this.now();
4974
+ const message = { id: randomUUID4(), from: { id: from.id, title: from.title, cwd: from.cwd }, text: body, at };
4975
+ await writePrivateFile(path16.join(dir, `${String(at).padStart(15, "0")}-${message.id}.json`), JSON.stringify(message));
4976
+ return target;
4977
+ }
4978
+ /** Reads and removes this session's mail, oldest first. Anything unreadable is dropped. */
4979
+ async receive() {
4980
+ const me = this.identity?.();
4981
+ if (!me || !SAFE_ID.test(me.id)) return [];
4982
+ const dir = this.inboxDir(me.id);
4983
+ let names;
4984
+ try {
4985
+ names = (await readdir3(dir)).filter((n) => n.endsWith(".json")).sort();
4986
+ } catch {
4987
+ return [];
4988
+ }
4989
+ const messages = [];
4990
+ for (const name of names) {
4991
+ const file = path16.join(dir, name);
4992
+ try {
4993
+ const m = JSON.parse(await readFile12(file, "utf8"));
4994
+ if (typeof m.text === "string" && m.text && typeof m.from?.id === "string") {
4995
+ messages.push({
4996
+ id: String(m.id ?? name),
4997
+ from: { id: m.from.id, title: String(m.from.title ?? ""), cwd: String(m.from.cwd ?? "") },
4998
+ text: m.text.slice(0, MAX_SESSION_MESSAGE_CHARS),
4999
+ at: typeof m.at === "number" ? m.at : 0
5000
+ });
5001
+ }
5002
+ } catch {
5003
+ }
5004
+ await rm5(file, { force: true }).catch(() => void 0);
5005
+ }
5006
+ return messages;
5007
+ }
5008
+ async poll() {
5009
+ if (this.polling) return;
5010
+ this.polling = true;
5011
+ try {
5012
+ for (const message of await this.receive()) this.onMessage?.(message);
5013
+ } finally {
5014
+ this.polling = false;
5015
+ }
5016
+ }
5017
+ };
5018
+ var clip = (text2, max) => {
5019
+ const flat = text2.replace(/\s+/g, " ").trim();
5020
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
5021
+ };
5022
+ function age(ms) {
5023
+ const s = Math.max(0, Math.floor(ms / 1e3));
5024
+ if (s < 60) return `${s}s`;
5025
+ const m = Math.floor(s / 60);
5026
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`;
5027
+ }
5028
+ function describeSessions(sessions, selfId, now = Date.now()) {
5029
+ if (sessions.length === 0) return ["No sessions are running."];
5030
+ const lines = sessions.map((s) => {
5031
+ const me = s.id === selfId ? " (this one)" : "";
5032
+ return `${s.id.slice(0, 8)} ${clip(s.title || "(untitled)", 50)}${me}
5033
+ ${s.cwd} \xB7 ${s.agent || "agent"} \xB7 up ${age(now - s.startedAt)}`;
5034
+ });
5035
+ if (sessions.every((s) => s.id === selfId)) lines.push("No other session is running. Open another terminal and start `ad` there.");
5036
+ return lines;
5037
+ }
5038
+
5039
+ // src/tui/ask.ts
5040
+ function askWithSignal(open, signal) {
5041
+ return new Promise((resolve) => {
5042
+ if (signal?.aborted) return resolve("deny");
5043
+ let slot;
5044
+ const onAbort = () => {
5045
+ slot?.close();
5046
+ resolve("deny");
5047
+ };
5048
+ signal?.addEventListener("abort", onAbort, { once: true });
5049
+ slot = open((decision) => {
5050
+ signal?.removeEventListener("abort", onAbort);
5051
+ resolve(decision);
5052
+ });
5053
+ });
5054
+ }
5055
+
5056
+ // src/lib/diagnostics.ts
5057
+ async function probeServer(config2, fetchImpl = fetch) {
5058
+ if (!config2.baseUrl) return {};
5059
+ const started = Date.now();
5060
+ try {
5061
+ const res = await fetchImpl(new URL("/v1/models", config2.baseUrl), {
5062
+ headers: { accept: "application/json", ...clientHeaders(), ...config2.apiKey ? { authorization: `Bearer ${config2.apiKey}` } : {} },
5063
+ signal: AbortSignal.timeout(1e4)
5064
+ });
5065
+ await res.body?.cancel().catch(() => void 0);
5066
+ const served = servedBy(res.headers);
5067
+ return { status: res.status, ms: Date.now() - started, ...served ? { served } : {} };
5068
+ } catch {
5069
+ return {};
5070
+ }
5071
+ }
5072
+
5073
+ // src/tui/commands.ts
5074
+ import { readdir as readdir4, readFile as readFile13 } from "fs/promises";
5075
+ import path17 from "path";
5076
+
5077
+ // src/tui/workers.ts
5078
+ var STATUS_ICON = {
5079
+ queued: "\u25CC",
5080
+ running: "\u25CF",
5081
+ done: "\u2713",
5082
+ failed: "\u2717",
5083
+ cancelled: "\u2298"
5084
+ };
5085
+ function elapsed(ms) {
5086
+ const s = Math.max(0, Math.floor(ms / 1e3));
5087
+ if (s < 60) return `${s}s`;
5088
+ const m = Math.floor(s / 60);
5089
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
5090
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
5091
+ }
5092
+ function ranFor(w, now) {
5093
+ if (!w.startedAt) return "waiting for a slot";
5094
+ return elapsed((w.endedAt ?? now) - w.startedAt);
5095
+ }
5096
+ var tokens = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
5097
+ var clip2 = (text2, max) => {
5098
+ const flat = text2.replace(/\s+/g, " ").trim();
5099
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
5100
+ };
5101
+ function workerRow(w, now = Date.now()) {
5102
+ const bits = [`${w.status} ${ranFor(w, now)}`];
5103
+ if (w.toolUses) bits.push(`${w.toolUses} tool use${w.toolUses === 1 ? "" : "s"}`);
5104
+ if (w.tokens) bits.push(`${tokens(w.tokens)} tokens`);
5105
+ if (w.status === "running" && w.activity) bits.push(w.activity);
5106
+ if (w.status === "failed" && w.error) bits.push(clip2(w.error, 60));
5107
+ if (w.inbox.length) bits.push(`${w.inbox.length} unread`);
5108
+ return { label: `${STATUS_ICON[w.status]} ${w.name} \xB7 ${clip2(w.description, 40)}`, description: bits.join(" \xB7 ") };
5109
+ }
5110
+ function describeWorker(w, now = Date.now()) {
5111
+ const lines = [
5112
+ `${STATUS_ICON[w.status]} ${w.name} [${w.id}] \u2014 ${w.description}`,
5113
+ `Status: ${w.status} \xB7 ${ranFor(w, now)} \xB7 ${w.toolUses} tool use${w.toolUses === 1 ? "" : "s"} \xB7 ${tokens(w.tokens)} tokens \xB7 ${w.type === "explore" ? "read-only" : "can edit and run commands"}`,
5114
+ `Task: ${clip2(w.prompt, 300)}`
5115
+ ];
5116
+ if (w.status === "running" && w.activity) lines.push(`Now: ${w.activity}`);
5117
+ const calls = w.history.flatMap((m) => m.role === "assistant" ? m.tool_calls ?? [] : []).slice(-5).map((c) => `${c.function.name}(${describeCall(c.function.name, c.function.arguments)})`);
5118
+ if (calls.length) lines.push("Recent steps:", ...calls.map((c) => ` \xB7 ${c}`));
5119
+ if (w.log.length) {
5120
+ lines.push("Messages:");
5121
+ for (const m of w.log.slice(-6)) lines.push(` ${m.from} \u2192 ${m.to}: ${clip2(m.text, 160)}`);
5122
+ }
5123
+ if (w.inbox.length) lines.push(`${w.inbox.length} unread message${w.inbox.length === 1 ? "" : "s"} waiting for its next step.`);
5124
+ if (w.status === "failed" && w.error) lines.push(`Failed: ${w.error}`);
5125
+ if (w.status === "cancelled") lines.push("Cancelled by you.");
5126
+ if (w.result && w.status !== "running") lines.push("Report:", ...w.result.trim().slice(0, 1500).split("\n").map((l) => ` ${l}`));
5127
+ return lines;
5128
+ }
5129
+ function boardSummary(workers) {
5130
+ if (workers.length === 0) return void 0;
5131
+ const count = (status) => workers.filter((w) => w.status === status).length;
5132
+ const parts = [];
5133
+ const running = count("running");
5134
+ const queued = count("queued");
5135
+ const done = count("done");
5136
+ const failed = count("failed");
5137
+ if (running) parts.push(`${running} running`);
5138
+ if (queued) parts.push(`${queued} queued`);
5139
+ if (done) parts.push(`${done} done`);
5140
+ if (failed) parts.push(`${failed} failed`);
5141
+ return parts.length ? parts.join(" \xB7 ") : `${workers.length} stopped`;
5142
+ }
5143
+ function parseSpawnArgs(args) {
5144
+ const text2 = args.trim();
5145
+ const named = /^([A-Za-z0-9_-]{1,24}):\s+([\s\S]+)$/.exec(text2);
5146
+ return named ? { name: named[1], task: named[2].trim() } : { task: text2 };
5147
+ }
5148
+
5149
+ // src/tui/commands.ts
5150
+ var INIT_PROMPT = `Analyze this codebase and create an AD.md file in the project root. It is read at the start of every session, so keep it short and useful. Include:
5151
+ - the commands to build, run, lint and test (check package.json, Makefile, etc.),
5152
+ - the high-level architecture and the directories that matter,
5153
+ - conventions that differ from the defaults.
5154
+ Do not invent anything: read the code first. If AD.md already exists, improve it instead of replacing it.`;
5155
+ function builtinCommands() {
5156
+ return [
5157
+ {
5158
+ name: "help",
5159
+ description: "Show commands and shortcuts",
5160
+ run: (ctx) => {
5161
+ const lines = ["Commands:"];
5162
+ for (const c of ctx.allCommands()) lines.push(` /${(c.name + (c.args ? ` ${c.args}` : "")).padEnd(22)}${c.description}`);
5163
+ lines.push("", "Shortcuts:", " @file mention a file (or @app: one of your connected connectors) \xB7 !cmd run a shell command \xB7 #note save a note to AD.md", " shift+tab cycle permission mode \xB7 esc interrupt \xB7 ctrl+j new line");
5164
+ ctx.info(lines.join("\n"));
5165
+ }
5166
+ },
5167
+ { name: "agents", description: "Switch the agent", aliases: ["agent"], run: (ctx) => ctx.pickAgent() },
5168
+ {
5169
+ name: "workers",
5170
+ description: "Background agents: status board, open one, stop it",
5171
+ args: "[name]",
5172
+ aliases: ["bg"],
5173
+ run: (ctx, args) => args.trim() ? ctx.workers.show(args.trim()) : ctx.workers.board()
5174
+ },
5175
+ {
5176
+ name: "spawn",
5177
+ description: "Start a background agent on a task",
5178
+ args: "[name:] <task>",
5179
+ run: (ctx, args) => {
5180
+ const { name, task } = parseSpawnArgs(args);
5181
+ if (!task) return ctx.info("Usage: /spawn [name:] <task> e.g. /spawn reviewer: review the changes in src/agent", "warn");
5182
+ ctx.workers.spawn(task, name);
5183
+ }
5184
+ },
5185
+ {
5186
+ name: "stop",
5187
+ description: 'Stop a background agent (or "all")',
5188
+ args: "<name|all>",
5189
+ run: (ctx, args) => args.trim() ? ctx.workers.stop(args.trim()) : ctx.info("Usage: /stop <name|all>", "warn")
5190
+ },
5191
+ {
5192
+ name: "msg",
5193
+ description: "Send a message to a background agent",
5194
+ args: "<name> <message>",
5195
+ run: (ctx, args) => {
5196
+ const [to = "", ...rest] = args.trim().split(/\s+/);
5197
+ const text2 = rest.join(" ").trim();
5198
+ if (!to || !text2) return ctx.info("Usage: /msg <name> <message>", "warn");
5199
+ ctx.workers.message(to, text2);
5200
+ }
5201
+ },
5202
+ {
5203
+ name: "code-review",
5204
+ description: "Review your uncommitted changes (or what differs from a branch) in the background",
5205
+ args: "[branch|commit|range]",
5206
+ aliases: ["review"],
5207
+ run: (ctx, args) => ctx.review(args)
5208
+ },
5209
+ {
5210
+ name: "coordinate",
5211
+ description: "Split a big goal into parallel background agents, then merge their results",
5212
+ args: "[--eager] <goal> | off",
5213
+ run: (ctx, args) => ctx.coordinate(args)
5214
+ },
5215
+ {
5216
+ name: "model",
5217
+ description: "Use a specific model instead of an agent",
5218
+ args: "<model>",
5219
+ run: (ctx, args) => args.trim() ? ctx.setModel(args.trim()) : ctx.pickAgent()
5220
+ },
5221
+ { name: "clear", description: "Start a new conversation", aliases: ["new", "reset"], run: (ctx) => ctx.clear() },
5222
+ {
5223
+ name: "compact",
5224
+ description: "Summarize the conversation to free up context",
5225
+ args: "[focus]",
5226
+ run: (ctx, args) => ctx.compact(args.trim() || void 0)
5227
+ },
5228
+ { name: "resume", description: "Resume an earlier conversation", aliases: ["continue"], run: (ctx) => ctx.pickSession() },
5229
+ {
5230
+ name: "rewind",
5231
+ description: "Undo: go back to before an earlier prompt (code, conversation or both)",
5232
+ aliases: ["undo", "checkpoint"],
5233
+ run: (ctx) => ctx.rewind()
5234
+ },
5235
+ {
5236
+ name: "side",
5237
+ description: "Ask on the side, in a split view, while the main agent keeps working",
5238
+ args: "[--edit] <question> | main | close | show | send",
5239
+ aliases: ["btw"],
5240
+ run: (ctx, args) => {
5241
+ const text2 = args.trim();
5242
+ const word = text2.toLowerCase();
5243
+ if (!text2) return ctx.side.toggle();
5244
+ if (word === "main") return ctx.side.focus("main");
5245
+ if (word === "close") return ctx.side.close();
5246
+ if (word === "show") return ctx.side.show();
5247
+ if (word === "send") return ctx.side.sendToMain();
5248
+ ctx.side.start(text2.replace(/^--edit\s*/, "").trim(), /^--edit(\s|$)/.test(text2));
5249
+ }
5250
+ },
5251
+ { name: "sessions", description: "List the other sessions running on this machine", aliases: ["who"], run: (ctx) => ctx.sessions.list() },
5252
+ {
5253
+ name: "send",
5254
+ description: "Send a message to another running session",
5255
+ args: "<session> <message>",
5256
+ run: (ctx, args) => {
5257
+ const [to = "", ...rest] = args.trim().split(/\s+/);
5258
+ const text2 = rest.join(" ").trim();
5259
+ if (!to || !text2) return ctx.info("Usage: /send <session> <message> (see /sessions for ids and titles)", "warn");
5260
+ ctx.sessions.send(to, text2);
5261
+ }
5262
+ },
5263
+ { name: "plan", description: "Plan first: research without changing anything", run: (ctx) => ctx.setMode("plan") },
5264
+ {
5265
+ name: "open",
5266
+ description: "Open the last artifact (or a .html file) in your browser",
5267
+ args: "[file]",
5268
+ run: (ctx, args) => ctx.openFile(args.trim() || void 0)
5269
+ },
5270
+ { name: "init", description: "Create an AD.md with project instructions", run: (ctx) => ctx.send(INIT_PROMPT, "/init") },
5271
+ { name: "status", description: "Show account, agent and session details", run: (ctx) => ctx.info(ctx.describeStatus().join("\n")) },
5272
+ { name: "usage", description: "Show token usage for this session", aliases: ["cost"], run: (ctx) => ctx.info(ctx.describeUsage().join("\n")) },
5273
+ {
5274
+ name: "permissions",
5275
+ description: "Show the permission mode and what is allowed",
5276
+ run: (ctx) => ctx.info(ctx.describePermissions().join("\n"))
5277
+ },
5278
+ {
5279
+ name: "doctor",
5280
+ description: "Check the connection and your setup",
5281
+ run: async (ctx) => ctx.info((await ctx.doctor()).join("\n"))
5282
+ },
5283
+ { name: "login", description: "Sign in again or switch account", run: (ctx) => ctx.exit("login") },
5284
+ { name: "logout", description: "Sign out", run: (ctx) => ctx.exit("logout") },
5285
+ { name: "exit", description: "Quit", aliases: ["quit"], run: (ctx) => ctx.exit("exit") }
5286
+ ];
5287
+ }
5288
+ function parseFrontmatter(raw) {
5289
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
5290
+ if (!match) return { body: raw };
5291
+ const description = /^description:\s*(.+)$/m.exec(match[1])?.[1]?.trim();
5292
+ return { description, body: match[2] };
5293
+ }
5294
+ async function readCommandDir(dir, source) {
5295
+ let names;
5296
+ try {
5297
+ names = await readdir4(dir);
5298
+ } catch {
5299
+ return [];
5300
+ }
5301
+ const out = [];
5302
+ for (const file of names.filter((n) => n.endsWith(".md"))) {
5303
+ const name = file.slice(0, -3).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
5304
+ try {
5305
+ const { description, body } = parseFrontmatter(await readFile13(path17.join(dir, file), "utf8"));
5306
+ if (!body.trim()) continue;
5307
+ out.push({
5308
+ name,
5309
+ source,
5310
+ args: /\$ARGUMENTS/.test(body) ? "[arguments]" : void 0,
5311
+ description: description ?? body.trim().split("\n")[0].slice(0, 70),
5312
+ run: (ctx, args) => {
5313
+ const prompt = /\$ARGUMENTS/.test(body) ? body.replace(/\$ARGUMENTS/g, args) : args ? `${body}
5314
+
5315
+ ${args}` : body;
5316
+ ctx.send(prompt.trim(), `/${name}${args ? ` ${args}` : ""}`);
5317
+ }
5318
+ });
5319
+ } catch {
5320
+ }
5321
+ }
5322
+ return out;
5323
+ }
5324
+ async function loadCustomCommands(cwd) {
5325
+ const [project, user] = await Promise.all([
5326
+ readCommandDir(path17.join(cwd, ".ad", "commands"), "project"),
5327
+ readCommandDir(path17.join(getConfigDir(), "commands"), "user")
5328
+ ]);
5329
+ const taken = new Set(project.map((c) => c.name));
5330
+ return [...project, ...user.filter((c) => !taken.has(c.name))];
5331
+ }
5332
+ function findCommand(commands, name) {
5333
+ const n = name.toLowerCase();
5334
+ return commands.find((c) => c.name === n) ?? commands.find((c) => c.aliases?.includes(n));
3687
5335
  }
3688
5336
 
3689
5337
  // src/tui/dialogs.tsx
@@ -4178,8 +5826,8 @@ function ShellView({ item }) {
4178
5826
  ] })
4179
5827
  ] });
4180
5828
  }
4181
- function InfoLine({ text: text2, tone }) {
4182
- return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: tone === "warn" ? theme.warn : tone === "success" ? theme.success : void 0, dimColor: tone === void 0 || tone === "dim", children: text2 }) });
5829
+ function InfoLine({ text: text2, tone: tone2 }) {
5830
+ return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: tone2 === "warn" ? theme.warn : tone2 === "success" ? theme.success : void 0, dimColor: tone2 === void 0 || tone2 === "dim", children: text2 }) });
4183
5831
  }
4184
5832
  function ErrorLine({ text: text2 }) {
4185
5833
  return /* @__PURE__ */ jsxs2(Box3, { marginTop: 1, children: [
@@ -4199,7 +5847,7 @@ function TodoList({ todos }) {
4199
5847
  ] });
4200
5848
  }
4201
5849
  function WorkingIndicator({
4202
- tokens,
5850
+ tokens: tokens2,
4203
5851
  label,
4204
5852
  waiting,
4205
5853
  fallback
@@ -4223,7 +5871,7 @@ function WorkingIndicator({
4223
5871
  " "
4224
5872
  ] }),
4225
5873
  /* @__PURE__ */ jsx3(Text3, { color: theme.amber, children: label ?? waitLabel ?? fallback ?? `${verb}\u2026` }),
4226
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: ` (esc to interrupt \xB7 ${seconds}s${tokens > 0 ? ` \xB7 \u2193 ${formatTokens(tokens)} tokens` : ""})` })
5874
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: ` (esc to interrupt \xB7 ${seconds}s${tokens2 > 0 ? ` \xB7 \u2193 ${formatTokens(tokens2)} tokens` : ""})` })
4227
5875
  ] });
4228
5876
  }
4229
5877
  function formatTokens(n) {
@@ -4248,7 +5896,9 @@ function StatusLine(props) {
4248
5896
  /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " (shift+tab to cycle)" })
4249
5897
  ] }) : /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: props.showHelpHint ? "? for shortcuts \xB7 shift+tab: permissions" : "shift+tab: permissions" }),
4250
5898
  props.running ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " \xB7 esc to interrupt" }) : null,
4251
- props.queued > 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: ` \xB7 ${props.queued} queued` }) : null
5899
+ props.queued > 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: ` \xB7 ${props.queued} queued` }) : null,
5900
+ props.typingTo ? /* @__PURE__ */ jsx3(Text3, { color: "yellow", bold: true, children: ` \xB7 \u2192 typing to ${props.typingTo} (ctrl+o: main)` }) : null,
5901
+ props.workers ? /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: ` \xB7 \u2699 ${props.workers} (/workers)` }) : null
4252
5902
  ] }) }),
4253
5903
  /* @__PURE__ */ jsxs2(Text3, { dimColor: true, children: [
4254
5904
  props.agentName,
@@ -4263,6 +5913,7 @@ var SHORTCUTS = [
4263
5913
  ["#", "add a note to AD.md"],
4264
5914
  ["shift+tab", "cycle permission mode"],
4265
5915
  ["esc", "interrupt the agent"],
5916
+ ["ctrl+o", "switch between the main agent and the side session"],
4266
5917
  ["\u2191 / \u2193", "input history"],
4267
5918
  ["ctrl+j or \\ enter", "new line"],
4268
5919
  ["ctrl+c", "cancel \xB7 twice to exit"]
@@ -4385,6 +6036,14 @@ function optionsFor(request) {
4385
6036
  { answer: "deny", label: "No, and tell AI Draftsman what to do differently (esc)" }
4386
6037
  ];
4387
6038
  }
6039
+ if (request.kind === "browser") {
6040
+ const rule = request.key.replace(/^browser:/, "").replace("_", " ");
6041
+ return [
6042
+ { answer: "allow", label: "Yes" },
6043
+ { answer: "allow_always", label: `Yes, and don't ask again for "${rule}" this session` },
6044
+ { answer: "deny", label: "No, and tell AI Draftsman what to do differently (esc)" }
6045
+ ];
6046
+ }
4388
6047
  if (request.kind === "exec") {
4389
6048
  const rule = request.key.replace(/^bash:/, "");
4390
6049
  return [
@@ -4415,8 +6074,8 @@ function AskDialog({ request, onAnswer }) {
4415
6074
  const isPlan = request.toolName === "exit_plan_mode";
4416
6075
  const isArtifact = request.toolName === "create_artifact";
4417
6076
  const isContinue = request.toolName === "continue";
4418
- const title = isContinue ? "Step limit reached" : isPlan ? "Ready to code?" : isArtifact ? "Create artifact" : request.kind === "net" ? "Fetch web content" : request.kind === "external" ? "Use a connector" : request.kind === "exec" ? "Bash command" : request.kind === "write" ? isCreate ? "Create file" : "Edit file" : request.outside ? "Read outside the project" : toolTitle(request.toolName);
4419
- const question = isContinue ? "Continue?" : isPlan ? "Would you like to proceed?" : isArtifact ? `Do you want to create ${request.label.replace(/ \(\d+ sections\)$/, "")}?` : request.kind === "net" ? "Do you want to allow AI Draftsman to fetch this content?" : request.kind === "external" ? `Do you want AI Draftsman to run ${request.label} as you?` : request.kind === "exec" ? "Do you want to proceed?" : request.kind === "write" ? `Do you want to ${isCreate ? "create" : "make this edit to"} ${request.label}?` : `Allow ${toolTitle(request.toolName).toLowerCase()} of ${request.label}?`;
6077
+ const title = isContinue ? "Step limit reached" : isPlan ? "Ready to code?" : isArtifact ? "Create artifact" : request.kind === "net" ? "Fetch web content" : request.kind === "browser" ? "Use your browser" : request.kind === "external" ? "Use a connector" : request.kind === "exec" ? "Bash command" : request.kind === "write" ? isCreate ? "Create file" : "Edit file" : request.outside ? "Read outside the project" : toolTitle(request.toolName);
6078
+ const question = isContinue ? "Continue?" : isPlan ? "Would you like to proceed?" : isArtifact ? `Do you want to create ${request.label.replace(/ \(\d+ sections\)$/, "")}?` : request.kind === "net" ? "Do you want to allow AI Draftsman to fetch this content?" : request.kind === "browser" ? `Do you want AI Draftsman to ${request.label} in your Chrome?` : request.kind === "external" ? `Do you want AI Draftsman to run ${request.label} as you?` : request.kind === "exec" ? "Do you want to proceed?" : request.kind === "write" ? `Do you want to ${isCreate ? "create" : "make this edit to"} ${request.label}?` : `Allow ${toolTitle(request.toolName).toLowerCase()} of ${request.label}?`;
4420
6079
  return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", borderStyle: "round", borderColor: theme.amber, paddingX: 1, marginTop: 1, children: [
4421
6080
  /* @__PURE__ */ jsx4(Text4, { bold: true, color: theme.amber, children: title }),
4422
6081
  /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: display?.kind === "diff" ? /* @__PURE__ */ jsxs3(Fragment2, { children: [
@@ -4436,9 +6095,9 @@ function AskDialog({ request, onAnswer }) {
4436
6095
  }
4437
6096
 
4438
6097
  // src/lib/util/images.ts
4439
- import { readFile as readFile10, stat as stat4 } from "fs/promises";
4440
- import os4 from "os";
4441
- import path13 from "path";
6098
+ import { readFile as readFile14, stat as stat6 } from "fs/promises";
6099
+ import os5 from "os";
6100
+ import path18 from "path";
4442
6101
  var MIME = {
4443
6102
  ".png": "image/png",
4444
6103
  ".jpg": "image/jpeg",
@@ -4449,16 +6108,16 @@ var MIME = {
4449
6108
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
4450
6109
  var IMAGE_MENTION = /(?:^|\s)@(\S+?\.(?:png|jpe?g|gif|webp))(?=$|[\s,;:!?)\]])/gi;
4451
6110
  function resolveImagePath(cwd, file) {
4452
- const real = file === "~" || /^~[/\\]/.test(file) ? path13.join(os4.homedir(), file.slice(1)) : file;
4453
- return path13.resolve(cwd, real);
6111
+ const real = file === "~" || /^~[/\\]/.test(file) ? path18.join(os5.homedir(), file.slice(1)) : file;
6112
+ return path18.resolve(cwd, real);
4454
6113
  }
4455
6114
  async function loadImage(cwd, file) {
4456
6115
  const full = resolveImagePath(cwd, file);
4457
- const mime = MIME[path13.extname(full).toLowerCase()];
6116
+ const mime = MIME[path18.extname(full).toLowerCase()];
4458
6117
  if (!mime) throw new CliError(`${file} is not a supported image (png, jpg, gif or webp).`, 3);
4459
6118
  let size;
4460
6119
  try {
4461
- const info = await stat4(full);
6120
+ const info = await stat6(full);
4462
6121
  if (!info.isFile()) throw new Error("not a file");
4463
6122
  size = info.size;
4464
6123
  } catch {
@@ -4467,7 +6126,7 @@ async function loadImage(cwd, file) {
4467
6126
  if (size > MAX_IMAGE_BYTES) {
4468
6127
  throw new CliError(`${file} is ${(size / 1024 / 1024).toFixed(1)} MB; images must be under ${MAX_IMAGE_BYTES / 1024 / 1024} MB.`, 3);
4469
6128
  }
4470
- const data = await readFile10(full);
6129
+ const data = await readFile14(full);
4471
6130
  return { type: "image_url", image_url: { url: `data:${mime};base64,${data.toString("base64")}` } };
4472
6131
  }
4473
6132
  function imageMentions(text2) {
@@ -4498,9 +6157,9 @@ import { Box as Box6, Text as Text6 } from "ink";
4498
6157
  import { useEffect as useEffect2, useMemo as useMemo3, useRef, useState as useState3 } from "react";
4499
6158
 
4500
6159
  // src/tui/path-complete.ts
4501
- import { readdir as readdir4 } from "fs/promises";
4502
- import os5 from "os";
4503
- import path14 from "path";
6160
+ import { readdir as readdir5 } from "fs/promises";
6161
+ import os6 from "os";
6162
+ import path19 from "path";
4504
6163
  function isPathToken(token) {
4505
6164
  return /^(\.{1,2}([/\\]|$)|~([/\\]|$)|[/\\]|[A-Za-z]:)/.test(token) || /[/\\]/.test(token);
4506
6165
  }
@@ -4513,11 +6172,11 @@ async function completePath(cwd, token, limit = 50) {
4513
6172
  }
4514
6173
  const dirPart = typed.slice(0, slash + 1);
4515
6174
  const prefix = typed.slice(slash + 1).toLowerCase();
4516
- const real = dirPart.startsWith("~") ? path14.join(os5.homedir(), dirPart.slice(1)) : dirPart;
4517
- const dir = path14.resolve(cwd, real);
6175
+ const real = dirPart.startsWith("~") ? path19.join(os6.homedir(), dirPart.slice(1)) : dirPart;
6176
+ const dir = path19.resolve(cwd, real);
4518
6177
  let entries;
4519
6178
  try {
4520
- entries = await readdir4(dir, { withFileTypes: true });
6179
+ entries = await readdir5(dir, { withFileTypes: true });
4521
6180
  } catch {
4522
6181
  return [];
4523
6182
  }
@@ -4820,22 +6479,215 @@ function PromptBox({ focus, cwd = process.cwd(), mode, commands, files, connecto
4820
6479
  }
4821
6480
  ) })
4822
6481
  ] }),
4823
- visible.length > 0 ? /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: 2, children: [
4824
- visible.map((s, i) => {
4825
- const active = windowStart + i === sel;
4826
- return /* @__PURE__ */ jsxs5(Text6, { color: active ? theme.accent : void 0, dimColor: !active, children: [
4827
- active ? "\u276F " : " ",
4828
- s.label.padEnd(labelWidth),
4829
- s.description ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: s.description }) : null
4830
- ] }, s.value);
4831
- }),
4832
- suggestions.length > MAX_SUGGESTIONS ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: ` \u2026 ${suggestions.length - MAX_SUGGESTIONS} more` }) : null
4833
- ] }) : null
6482
+ visible.length > 0 ? /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: 2, children: [
6483
+ visible.map((s, i) => {
6484
+ const active = windowStart + i === sel;
6485
+ return /* @__PURE__ */ jsxs5(Text6, { color: active ? theme.accent : void 0, dimColor: !active, children: [
6486
+ active ? "\u276F " : " ",
6487
+ s.label.padEnd(labelWidth),
6488
+ s.description ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: s.description }) : null
6489
+ ] }, s.value);
6490
+ }),
6491
+ suggestions.length > MAX_SUGGESTIONS ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: ` \u2026 ${suggestions.length - MAX_SUGGESTIONS} more` }) : null
6492
+ ] }) : null
6493
+ ] });
6494
+ }
6495
+
6496
+ // src/tui/SidePane.tsx
6497
+ import { Box as Box7, Text as Text7 } from "ink";
6498
+
6499
+ // src/tui/side.ts
6500
+ var MAX_SIDE_CONTEXT_CHARS = 12e3;
6501
+ var PER_MESSAGE_CHARS = 1500;
6502
+ var clip3 = (text2, max) => {
6503
+ const flat = text2.trim();
6504
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
6505
+ };
6506
+ function snapshotConversation(history, max = MAX_SIDE_CONTEXT_CHARS) {
6507
+ const lines = [];
6508
+ let total = 0;
6509
+ let cut2 = false;
6510
+ for (let i = history.length - 1; i >= 0; i--) {
6511
+ const m = history[i];
6512
+ let line;
6513
+ if (m.role === "user") {
6514
+ const text2 = contentText(m.content).trim();
6515
+ if (text2) line = `User: ${clip3(text2, PER_MESSAGE_CHARS)}`;
6516
+ } else if (m.role === "assistant" && m.content?.trim()) {
6517
+ line = `Agent: ${clip3(m.content, PER_MESSAGE_CHARS)}`;
6518
+ }
6519
+ if (!line) continue;
6520
+ if (total + line.length > max && lines.length > 0) {
6521
+ cut2 = true;
6522
+ break;
6523
+ }
6524
+ lines.push(line);
6525
+ total += line.length + 1;
6526
+ }
6527
+ lines.reverse();
6528
+ return (cut2 ? ["(earlier messages left out)", ...lines] : lines).join("\n");
6529
+ }
6530
+ function buildSideBrief(snapshot, question) {
6531
+ return [snapshot ? `Main conversation so far (for context only):
6532
+ ${snapshot}` : "The main conversation has not started yet.", "", "The user's question:", question.trim()].join("\n");
6533
+ }
6534
+ function wrapText(text2, width) {
6535
+ const w = Math.max(8, width);
6536
+ const out = [];
6537
+ for (const raw of text2.replace(/\r\n?/g, "\n").split("\n")) {
6538
+ let line = "";
6539
+ for (let word of raw.split(/\s+/).filter(Boolean)) {
6540
+ while (word.length > w) {
6541
+ if (line) {
6542
+ out.push(line);
6543
+ line = "";
6544
+ }
6545
+ out.push(word.slice(0, w));
6546
+ word = word.slice(w);
6547
+ }
6548
+ if (!line) line = word;
6549
+ else if (line.length + 1 + word.length <= w) line += ` ${word}`;
6550
+ else {
6551
+ out.push(line);
6552
+ line = word;
6553
+ }
6554
+ }
6555
+ out.push(line);
6556
+ }
6557
+ return out;
6558
+ }
6559
+ function sideTranscript(w, width) {
6560
+ const lines = [];
6561
+ w.history.forEach((m, index) => {
6562
+ if (m.role === "user") {
6563
+ const text2 = index === 0 && w.shown ? w.shown : contentText(m.content);
6564
+ wrapText(text2, width - 2).forEach((l, i) => lines.push({ text: `${i === 0 ? "> " : " "}${l}`, tone: "user" }));
6565
+ } else if (m.role === "assistant") {
6566
+ if (m.content?.trim()) for (const l of wrapText(m.content, width)) lines.push({ text: l, tone: "agent" });
6567
+ for (const call2 of m.tool_calls ?? []) {
6568
+ lines.push({ text: `\xB7 ${call2.function.name}(${describeCall(call2.function.name, call2.function.arguments)})`.slice(0, width), tone: "step" });
6569
+ }
6570
+ }
6571
+ });
6572
+ if (w.status === "running") lines.push({ text: `\u25CF ${w.activity ?? "thinking\u2026"}`.slice(0, width), tone: "status" });
6573
+ else if (w.status === "queued") lines.push({ text: "\u25CC waiting for a slot", tone: "status" });
6574
+ else if (w.status === "failed") lines.push({ text: `\u2717 ${w.error ?? "failed"}`.slice(0, width), tone: "status" });
6575
+ else if (w.status === "cancelled") lines.push({ text: "\u2298 stopped", tone: "status" });
6576
+ return lines;
6577
+ }
6578
+ function sideLines(w, width, maxLines) {
6579
+ const all = sideTranscript(w, width);
6580
+ if (all.length <= maxLines) return all;
6581
+ const hidden = all.length - (maxLines - 1);
6582
+ return [{ text: `\u2191 ${hidden} earlier line${hidden === 1 ? "" : "s"} \xB7 /side show`, tone: "dim" }, ...all.slice(all.length - (maxLines - 1))];
6583
+ }
6584
+ var SPLIT_MIN_COLUMNS = 110;
6585
+ var SPLIT_MAX_PANE = 56;
6586
+ function sideLayout(cols, rows) {
6587
+ const split = cols >= SPLIT_MIN_COLUMNS;
6588
+ return {
6589
+ split,
6590
+ paneWidth: split ? Math.min(SPLIT_MAX_PANE, Math.floor(cols * 0.42)) : Math.max(30, cols - 2),
6591
+ paneLines: Math.max(6, Math.min(14, rows - 16))
6592
+ };
6593
+ }
6594
+ function sideText(w) {
6595
+ return sideTranscript(w, 100).map((l) => l.text).join("\n");
6596
+ }
6597
+
6598
+ // src/tui/SidePane.tsx
6599
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
6600
+ var STATE = {
6601
+ queued: "waiting",
6602
+ running: "working",
6603
+ done: "ready",
6604
+ failed: "failed",
6605
+ cancelled: "stopped"
6606
+ };
6607
+ function tone(line) {
6608
+ switch (line.tone) {
6609
+ case "user":
6610
+ return { color: theme.accent, bold: true, dim: false };
6611
+ case "step":
6612
+ case "dim":
6613
+ return { color: void 0, bold: false, dim: true };
6614
+ case "status":
6615
+ return { color: theme.amber, bold: false, dim: false };
6616
+ default:
6617
+ return { color: void 0, bold: false, dim: false };
6618
+ }
6619
+ }
6620
+ function SidePane({ worker, width, maxLines, focused }) {
6621
+ const lines = sideLines(worker, Math.max(12, width - 4), maxLines);
6622
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", width, borderStyle: "round", borderColor: focused ? theme.amber : theme.dim, paddingX: 1, children: [
6623
+ /* @__PURE__ */ jsxs6(Text7, { children: [
6624
+ /* @__PURE__ */ jsx7(Text7, { bold: true, color: focused ? theme.amber : void 0, children: `side \xB7 ${STATE[worker.status]}` }),
6625
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: focused ? " \u2014 you are typing here" : " \u2014 ctrl+o to type here" })
6626
+ ] }),
6627
+ lines.length === 0 ? /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Asking\u2026" }) : null,
6628
+ lines.map((line, i) => {
6629
+ const t = tone(line);
6630
+ return /* @__PURE__ */ jsx7(Text7, { color: t.color, bold: t.bold, dimColor: t.dim, children: line.text || " " }, i);
6631
+ }),
6632
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "/side close \xB7 /side send \xB7 /side show" })
4834
6633
  ] });
4835
6634
  }
4836
6635
 
6636
+ // src/tui/rewind.ts
6637
+ import path20 from "path";
6638
+ var clip4 = (text2, max) => {
6639
+ const flat = text2.replace(/\s+/g, " ").trim();
6640
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
6641
+ };
6642
+ function ago(at, now = Date.now()) {
6643
+ const s = Math.max(0, Math.floor((now - at) / 1e3));
6644
+ if (s < 45) return "just now";
6645
+ const m = Math.round(s / 60);
6646
+ if (m < 60) return `${m} min ago`;
6647
+ const h = Math.round(m / 60);
6648
+ return h < 24 ? `${h} h ago` : new Date(at).toLocaleDateString();
6649
+ }
6650
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
6651
+ function rewindRows(store, now = Date.now()) {
6652
+ return store.list().reverse().map((c) => {
6653
+ const changed = store.changedSince(c.id).length;
6654
+ const untracked = store.untrackedShellSince(c.id);
6655
+ return {
6656
+ value: String(c.id),
6657
+ label: clip4(c.prompt, 70) || "(empty prompt)",
6658
+ description: `${ago(c.at, now)} \xB7 ${changed ? `${plural(changed, "file")} changed since` : "no file changes since"}` + (untracked ? ` \xB7 ${plural(untracked, "shell command")} not tracked` : "")
6659
+ };
6660
+ });
6661
+ }
6662
+ function rewindModeRows(store, id) {
6663
+ const files = store.changedSince(id).length;
6664
+ const code = files ? `puts ${plural(files, "file")} back (files the agent created are deleted)` : "no files were changed";
6665
+ return [
6666
+ // Short labels: the picker clips them to a fixed column, and the description carries the detail.
6667
+ { value: "both", label: "Code + conversation", description: `Go back to before this prompt; ${code}.` },
6668
+ { value: "conversation", label: "Conversation only", description: "Forget everything after this prompt; keep the files as they are now." },
6669
+ { value: "code", label: "Code only", description: `Keep the conversation; ${code}.` },
6670
+ { value: "cancel", label: "Never mind", description: "Change nothing." }
6671
+ ];
6672
+ }
6673
+ function describeRestore(report, cwd) {
6674
+ const shown = (file) => {
6675
+ const rel = path20.relative(cwd, file);
6676
+ return rel && !rel.startsWith("..") ? rel : file;
6677
+ };
6678
+ const restored = report.restored.length;
6679
+ const deleted = report.deleted.length;
6680
+ const parts = [`${plural(restored, "file")} restored`];
6681
+ if (deleted) parts.push(`${plural(deleted, "created file")} removed`);
6682
+ const problems = [
6683
+ ...report.failed.map((f) => `Could not restore ${shown(f.file)}: ${f.reason}`),
6684
+ ...report.skipped.map((f) => `${shown(f)} is too large to have been saved, so it was not restored.`)
6685
+ ];
6686
+ return { summary: parts.join(", "), problems };
6687
+ }
6688
+
4837
6689
  // src/tui/App.tsx
4838
- import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
6690
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
4839
6691
  var CLEAR_SCREEN = "\x1B[2J\x1B[3J\x1B[H";
4840
6692
  function describeTtft(samples) {
4841
6693
  if (samples.length === 0) return "no requests yet";
@@ -4906,6 +6758,23 @@ function App(props) {
4906
6758
  const sessionRef = useRef2(props.resume ?? newSession(cwd, agent));
4907
6759
  const historyRef = useRef2(sessionRef.current.messages);
4908
6760
  const rulesRef = useRef2(new AllowRules());
6761
+ const checkpointsRef = useRef2(new CheckpointStore());
6762
+ const savedRevRef = useRef2({ id: "", rev: -1 });
6763
+ const persistCheckpoints = useCallback(() => {
6764
+ const store = checkpointsRef.current;
6765
+ const id = sessionRef.current.id;
6766
+ if (savedRevRef.current.id === id && savedRevRef.current.rev === store.revision) return;
6767
+ savedRevRef.current = { id, rev: store.revision };
6768
+ void saveCheckpoints(id, store).catch(() => void 0);
6769
+ }, []);
6770
+ const adoptSavedCheckpoints = useCallback((id, messageCount) => {
6771
+ void loadCheckpoints(id, messageCount).then((store) => {
6772
+ if (store && sessionRef.current.id === id && checkpointsRef.current.list().length === 0) {
6773
+ checkpointsRef.current = store;
6774
+ savedRevRef.current = { id, rev: store.revision };
6775
+ }
6776
+ });
6777
+ }, []);
4909
6778
  const readFilesRef = useRef2(/* @__PURE__ */ new Set());
4910
6779
  const abortRef = useRef2(null);
4911
6780
  const queueRef = useRef2([]);
@@ -4936,7 +6805,7 @@ function App(props) {
4936
6805
  return props.resume ? [first, { id: nextId(), kind: "info", text: `Resumed: ${props.resume.title}`, tone: "dim" }, ...itemsFromMessages(props.resume.messages, nextId)] : [first];
4937
6806
  });
4938
6807
  const push = useCallback((item) => setItems((prev) => [...prev, { ...item, id: nextId() }]), [nextId]);
4939
- const info = useCallback((text2, tone) => push({ kind: "info", text: text2, tone }), [push]);
6808
+ const info = useCallback((text2, tone2) => push({ kind: "info", text: text2, tone: tone2 }), [push]);
4940
6809
  const showError = useCallback((text2) => push({ kind: "error", text: text2 }), [push]);
4941
6810
  const setMode = useCallback((next) => {
4942
6811
  modeRef.current = next;
@@ -4953,6 +6822,9 @@ function App(props) {
4953
6822
  live = false;
4954
6823
  };
4955
6824
  }, [props.refresh]);
6825
+ useEffect3(() => {
6826
+ if (props.resume) adoptSavedCheckpoints(props.resume.id, props.resume.messages.length);
6827
+ }, []);
4956
6828
  useEffect3(() => {
4957
6829
  void loadInputHistory().then(setInputHistory);
4958
6830
  void loadCustomCommands(cwd).then(setCustomCommands);
@@ -4969,6 +6841,38 @@ function App(props) {
4969
6841
  };
4970
6842
  }, [cwd]);
4971
6843
  const commands = useMemo4(() => [...builtinCommands(), ...customCommands], [customCommands]);
6844
+ const bus = useMemo4(() => props.bus ?? new SessionBus(), [props.bus]);
6845
+ const sessionMailRef = useRef2([]);
6846
+ const selfIdentity = useCallback(
6847
+ () => ({ id: sessionRef.current.id, cwd, title: sessionRef.current.title, agent: agentRef.current.name }),
6848
+ [cwd]
6849
+ );
6850
+ const sessionLink = useMemo4(
6851
+ () => ({
6852
+ list: async () => describeSessions(await bus.list(), selfIdentity().id).join("\n"),
6853
+ send: async (to, text2) => {
6854
+ const target = await bus.send(selfIdentity(), to, text2);
6855
+ return `Delivered to session "${target.title || target.id.slice(0, 8)}" (${target.id.slice(0, 8)}). Its user will see it, and its agent gets it with their next prompt.`;
6856
+ }
6857
+ }),
6858
+ [bus, selfIdentity]
6859
+ );
6860
+ useEffect3(() => {
6861
+ void bus.start(selfIdentity, (m) => {
6862
+ const who = `"${m.from.title || "untitled"}" (${m.from.id.slice(0, 8)})`;
6863
+ const text2 = `[message from another session ${who}, working in ${m.from.cwd}]
6864
+ ${m.text}`;
6865
+ const shown = m.text.replace(/\s+/g, " ").slice(0, 200);
6866
+ if (runningRef.current) {
6867
+ sessionMailRef.current.push(text2);
6868
+ info(`\u2709 message from session ${who}: ${shown} (the agent gets it now)`, "dim");
6869
+ } else {
6870
+ pendingContext.current += `${pendingContext.current ? "\n" : ""}${text2}`;
6871
+ info(`\u2709 message from session ${who}: ${shown}`, "dim");
6872
+ }
6873
+ });
6874
+ return () => void bus.stop();
6875
+ }, [bus, info, selfIdentity]);
4972
6876
  const flushStream = useCallback(() => {
4973
6877
  flushTimer.current = null;
4974
6878
  setLiveText(streamRef.current);
@@ -5052,8 +6956,16 @@ function App(props) {
5052
6956
  [debug, flushStream, push]
5053
6957
  );
5054
6958
  const askChain = useRef2(Promise.resolve());
5055
- const askUser = useCallback((request) => {
5056
- const next = askChain.current.then(() => new Promise((resolve) => setDialog({ type: "ask", request, resolve })));
6959
+ const askUser = useCallback((request, signal) => {
6960
+ const next = askChain.current.then(
6961
+ () => askWithSignal(
6962
+ (answer) => {
6963
+ setDialog({ type: "ask", request, resolve: answer });
6964
+ return { close: () => setDialog((d) => d?.type === "ask" && d.resolve === answer ? null : d) };
6965
+ },
6966
+ signal
6967
+ )
6968
+ );
5057
6969
  askChain.current = next.catch(() => void 0);
5058
6970
  return next;
5059
6971
  }, []);
@@ -5072,6 +6984,126 @@ function App(props) {
5072
6984
  if (answer === "allow_always" && request.kind === "write") setMode("acceptEdits");
5073
6985
  resolve(answer);
5074
6986
  };
6987
+ const [, setBoardTick] = useState4(0);
6988
+ const coordRef = useRef2(null);
6989
+ const runPromptRef = useRef2(() => void 0);
6990
+ const maybeWakeRef = useRef2(() => void 0);
6991
+ const workerBase = useRef2(void 0);
6992
+ workerBase.current = {
6993
+ config: config2,
6994
+ cwd,
6995
+ mode: () => modeRef.current,
6996
+ setMode,
6997
+ rules: rulesRef.current,
6998
+ readFiles: readFilesRef.current,
6999
+ sessionId: sessionRef.current.id,
7000
+ model: () => pickSubagentModel(config2, agentRef.current.model),
7001
+ toolFilter: sessionToolFilter({ artifacts: artifactsRef.current, orchestration: true }),
7002
+ askFor: (worker, signal) => (request) => askUser({ ...request, label: `[${worker.name}] ${request.label}` }, signal),
7003
+ checkpoint: (file) => checkpointsRef.current.recordBefore(file),
7004
+ shellTracker: (cwd2, run2) => checkpointsRef.current.trackShell(cwd2, run2),
7005
+ onUsage: (usage) => addUsage(sessionRef.current.usage, usage)
7006
+ };
7007
+ const orchestrator = useMemo4(
7008
+ () => new Orchestrator(createWorkerRunner(() => workerBase.current), { maxRunning: maxParallelSubagents(config2, props.maxParallel) }),
7009
+ // eslint-disable-next-line react-hooks/exhaustive-deps
7010
+ []
7011
+ );
7012
+ const [sideId, setSideId] = useState4(null);
7013
+ const sideIdRef = useRef2(null);
7014
+ const [focus, setFocusState] = useState4("main");
7015
+ const focusRef = useRef2("main");
7016
+ const setFocus = useCallback((next) => {
7017
+ focusRef.current = next;
7018
+ setFocusState(next);
7019
+ }, []);
7020
+ const sideWorker = () => {
7021
+ const id = sideIdRef.current;
7022
+ const w = id ? orchestrator.list().find((x) => x.id === id) : void 0;
7023
+ return w && w.status !== "cancelled" ? w : void 0;
7024
+ };
7025
+ const closeSide = useCallback(() => {
7026
+ const id = sideIdRef.current;
7027
+ if (id) {
7028
+ try {
7029
+ orchestrator.cancel(id);
7030
+ } catch {
7031
+ }
7032
+ }
7033
+ sideIdRef.current = null;
7034
+ setSideId(null);
7035
+ setFocus("main");
7036
+ }, [orchestrator, setFocus]);
7037
+ const runsImmediately = (text2) => {
7038
+ const t = text2.trim();
7039
+ if (!t.startsWith("/") || t.startsWith("//")) return false;
7040
+ const command = findCommand(commands, t.slice(1).split(/\s+/)[0] ?? "");
7041
+ return !!command && ["side", "workers", "spawn", "stop", "msg", "code-review", "sessions", "send", "status", "usage", "permissions", "help"].includes(command.name);
7042
+ };
7043
+ const routesToSide = (text2) => focusRef.current === "side" && !!sideWorker() && !/^[/!#]/.test(text2.trim());
7044
+ const sendToSide = (text2) => {
7045
+ const w = sideWorker();
7046
+ if (!w) return;
7047
+ try {
7048
+ orchestrator.send("you", w.name, text2);
7049
+ } catch (error) {
7050
+ showError(error instanceof Error ? error.message : String(error));
7051
+ }
7052
+ };
7053
+ maybeWakeRef.current = () => {
7054
+ const c = coordRef.current;
7055
+ if (!c) return;
7056
+ const workers = orchestrator.list().filter((w) => !w.interactive);
7057
+ const ended = workers.filter((w) => (w.status === "done" || w.status === "failed" || w.status === "cancelled") && !c.reported.has(`${w.id}:${w.endedAt}`));
7058
+ const live = workers.filter((w) => w.status === "queued" || w.status === "running").length;
7059
+ const max = c.eager ? MAX_EAGER_ROUNDS : MAX_COORDINATOR_ROUNDS;
7060
+ const decision = shouldWake({ running: runningRef.current, live, finished: ended.length, round: c.round, maxRounds: max, spawned: workers.length > 0, eager: c.eager });
7061
+ if (decision === "wait") return;
7062
+ if (decision !== "wake") {
7063
+ coordRef.current = null;
7064
+ return decision === "stop-idle" ? info("Coordinator off: the agent did not start any workers, so that ran as a normal prompt.", "dim") : info(`Coordinator stopped after ${max} rounds. Tell me what to do next.`, "warn");
7065
+ }
7066
+ for (const w of ended) c.reported.add(`${w.id}:${w.endedAt}`);
7067
+ c.round++;
7068
+ runPromptRef.current(wakePrompt(c.goal, c.round, max, live), `[coordinator] workers finished (round ${c.round})`);
7069
+ };
7070
+ useEffect3(() => {
7071
+ if (phase === "idle") maybeWakeRef.current();
7072
+ }, [phase]);
7073
+ useEffect3(() => {
7074
+ const seen = /* @__PURE__ */ new Map();
7075
+ const off = orchestrator.subscribe(() => {
7076
+ setBoardTick((t) => t + 1);
7077
+ for (const w of orchestrator.list()) {
7078
+ const before = seen.get(w.id);
7079
+ seen.set(w.id, w.status);
7080
+ if (!before || before === w.status) continue;
7081
+ if (w.interactive) {
7082
+ if (w.status === "failed") showError(`side session failed: ${w.error ?? "unknown error"}`);
7083
+ if (w.status === "cancelled" && sideIdRef.current === w.id) setFocus("main");
7084
+ continue;
7085
+ }
7086
+ if (w.status === "done") {
7087
+ info(`\u2713 ${w.name} finished \xB7 /workers ${w.name} to read its report`, "success");
7088
+ if (w.description.startsWith("Code review") && w.result) push({ kind: "assistant", text: w.result });
7089
+ if (w.result) pendingContext.current += `${pendingContext.current ? "\n" : ""}Background agent "${w.name}" (${w.description}) finished. Its report:
7090
+ ${w.result.slice(0, 3e3)}`;
7091
+ } else if (w.status === "failed") {
7092
+ showError(`${w.name} failed: ${w.error ?? "unknown error"}`);
7093
+ pendingContext.current += `${pendingContext.current ? "\n" : ""}Background agent "${w.name}" (${w.description}) failed: ${w.error ?? "unknown error"}`;
7094
+ } else if (w.status === "cancelled") {
7095
+ pendingContext.current += `${pendingContext.current ? "\n" : ""}Background agent "${w.name}" (${w.description}) was stopped by the user before it finished.`;
7096
+ }
7097
+ }
7098
+ maybeWakeRef.current();
7099
+ });
7100
+ const offMail = orchestrator.onMainMessage((m) => info(`\u2709 ${m.from} \u2192 you: ${m.text.replace(/\s+/g, " ").slice(0, 200)}`, "dim"));
7101
+ return () => {
7102
+ off();
7103
+ offMail();
7104
+ orchestrator.cancelAll();
7105
+ };
7106
+ }, [info, orchestrator, push, showError]);
5075
7107
  const finishTurn = useCallback(() => {
5076
7108
  runningRef.current = false;
5077
7109
  abortRef.current = null;
@@ -5082,6 +7114,7 @@ function App(props) {
5082
7114
  setWaiting(void 0);
5083
7115
  setTodos((prev) => prev.length && prev.every((t) => t.status === "completed") ? [] : prev);
5084
7116
  void saveSession(sessionRef.current).catch(() => void 0);
7117
+ persistCheckpoints();
5085
7118
  const next = queueRef.current.shift();
5086
7119
  setQueued(queueRef.current.length);
5087
7120
  if (next) setTimeout(() => void processSubmission(next), 0);
@@ -5117,13 +7150,23 @@ function App(props) {
5117
7150
  setMode,
5118
7151
  rules: rulesRef.current,
5119
7152
  readFiles: readFilesRef.current,
7153
+ checkpoint: (file) => checkpointsRef.current.recordBefore(file),
7154
+ shellTracker: (cwd2, run2) => checkpointsRef.current.trackShell(cwd2, run2),
5120
7155
  ask: askUser,
5121
7156
  onEvent: handleEvent,
5122
7157
  signal: ac.signal,
5123
7158
  sessionId: sessionRef.current.id,
5124
7159
  tools: props.toolCalls,
5125
7160
  // The same list for every request of the session (plan mode is a permission, not a different list).
5126
- toolFilter: sessionToolFilter({ artifacts }),
7161
+ toolFilter: sessionToolFilter({ artifacts, orchestration: true, sessions: true }),
7162
+ // The lead can start workers, message them, and reads what they send it before each step.
7163
+ team: orchestrator.bridgeFor("main", true),
7164
+ drainInbox: () => [
7165
+ ...orchestrator.takeMainMail().map((m) => ({ role: "user", content: `[message from ${m.from}] ${m.text}` })),
7166
+ ...sessionMailRef.current.splice(0).map((content) => ({ role: "user", content }))
7167
+ ],
7168
+ // ...and can pass findings to other sessions on this machine.
7169
+ sessions: sessionLink,
5127
7170
  ...config2.maxSteps ? { maxSteps: config2.maxSteps } : {},
5128
7171
  ...config2.subagentMaxSteps ? { subagentMaxSteps: config2.subagentMaxSteps } : {},
5129
7172
  maxParallel: maxParallelSubagents(config2, props.maxParallel),
@@ -5149,19 +7192,19 @@ function App(props) {
5149
7192
  } finally {
5150
7193
  finishTurn();
5151
7194
  }
5152
- }, [askUser, config2, cwd, finishTurn, handleEvent, info, props.maxParallel, props.toolCalls, push, setMode, showError]);
7195
+ }, [askUser, config2, cwd, finishTurn, handleEvent, info, orchestrator, props.maxParallel, props.toolCalls, push, sessionLink, setMode, showError]);
5153
7196
  const runPrompt = useCallback(
5154
7197
  async (text2, display, images) => {
5155
7198
  const session = sessionRef.current;
5156
7199
  if (historyRef.current.length === 0) session.title = titleFrom(display ?? text2);
7200
+ checkpointsRef.current.begin(titleFrom(display ?? text2), historyRef.current.length);
5157
7201
  push({ kind: "user", text: display ?? text2 });
5158
7202
  const note = connectorSelectionNote(text2, connectorsRef.current);
5159
7203
  const picked = note ? `${text2}
5160
7204
 
5161
7205
  ${note}` : text2;
5162
- const withContext = pendingContext.current ? `${pendingContext.current}
5163
-
5164
- ${picked}` : picked;
7206
+ const mail = orchestrator.takeMainMail().map((m) => `[message from ${m.from}] ${m.text}`).join("\n");
7207
+ const withContext = [pendingContext.current, mail, picked].filter(Boolean).join("\n\n");
5165
7208
  pendingContext.current = "";
5166
7209
  let content;
5167
7210
  try {
@@ -5172,8 +7215,9 @@ ${picked}` : picked;
5172
7215
  historyRef.current.push({ role: "user", content });
5173
7216
  void startTurn();
5174
7217
  },
5175
- [cwd, push, showError, startTurn]
7218
+ [cwd, orchestrator, push, showError, startTurn]
5176
7219
  );
7220
+ runPromptRef.current = (text2, display) => void runPrompt(text2, display);
5177
7221
  const runShell = useCallback(
5178
7222
  async (command) => {
5179
7223
  const ac = new AbortController();
@@ -5210,6 +7254,8 @@ ${output.slice(0, 8e3)}`;
5210
7254
  );
5211
7255
  const clearConversation = useCallback(() => {
5212
7256
  readFilesRef.current.clear();
7257
+ checkpointsRef.current.clear();
7258
+ closeSide();
5213
7259
  sessionRef.current = newSession(cwd, agentRef.current);
5214
7260
  historyRef.current = sessionRef.current.messages;
5215
7261
  artifactsRef.current = false;
@@ -5221,7 +7267,7 @@ ${output.slice(0, 8e3)}`;
5221
7267
  stdout.write(CLEAR_SCREEN);
5222
7268
  setItems([headerItem(agentRef.current)]);
5223
7269
  setEpoch((e) => e + 1);
5224
- }, [cwd, headerItem, pastes, stdout]);
7270
+ }, [closeSide, cwd, headerItem, pastes, stdout]);
5225
7271
  const compact = useCallback(
5226
7272
  async (instructions) => {
5227
7273
  if (historyRef.current.length < 2) return info("Nothing to compact yet.", "dim");
@@ -5254,6 +7300,7 @@ ${output.slice(0, 8e3)}`;
5254
7300
  { role: "assistant", content: summary.content }
5255
7301
  );
5256
7302
  warnedContext.current = false;
7303
+ checkpointsRef.current.clear();
5257
7304
  setContextTokens(0);
5258
7305
  info("Conversation compacted.", "success");
5259
7306
  } catch (error) {
@@ -5264,6 +7311,45 @@ ${output.slice(0, 8e3)}`;
5264
7311
  },
5265
7312
  [config2, cwd, finishTurn, info, showError]
5266
7313
  );
7314
+ const applyRewind = useCallback(
7315
+ async (id, mode2) => {
7316
+ const store = checkpointsRef.current;
7317
+ const point = store.get(id);
7318
+ if (!point) return;
7319
+ if (mode2 !== "conversation") {
7320
+ const report = await store.restoreFiles(id);
7321
+ const { summary, problems } = describeRestore(report, cwd);
7322
+ for (const problem of problems) showError(problem);
7323
+ const untracked = store.untrackedShellSince(id);
7324
+ info(
7325
+ `Code rewound: ${summary}.${untracked ? ` ${untracked} shell command${untracked === 1 ? "" : "s"} ran where changes could not be tracked (not a git repository, or git was too slow), so those were not undone.` : ""}`,
7326
+ report.failed.length || untracked ? "warn" : "success"
7327
+ );
7328
+ readFilesRef.current.clear();
7329
+ }
7330
+ if (mode2 === "code") {
7331
+ store.forgetFilesFrom(id);
7332
+ persistCheckpoints();
7333
+ return;
7334
+ }
7335
+ historyRef.current.splice(point.historyLength);
7336
+ store.dropFrom(id);
7337
+ pendingContext.current = "";
7338
+ warnedContext.current = false;
7339
+ setTodos([]);
7340
+ setContextTokens(0);
7341
+ stdout.write(CLEAR_SCREEN);
7342
+ setItems([
7343
+ headerItem(agentRef.current),
7344
+ { id: nextId(), kind: "info", text: `Rewound to before: ${point.prompt}`, tone: "dim" },
7345
+ ...itemsFromMessages(historyRef.current, nextId)
7346
+ ]);
7347
+ setEpoch((e) => e + 1);
7348
+ void saveSession(sessionRef.current).catch(() => void 0);
7349
+ persistCheckpoints();
7350
+ },
7351
+ [cwd, headerItem, info, nextId, persistCheckpoints, showError, stdout]
7352
+ );
5267
7353
  const ctx = {
5268
7354
  info,
5269
7355
  error: showError,
@@ -5298,6 +7384,132 @@ ${output.slice(0, 8e3)}`;
5298
7384
  },
5299
7385
  compact: (instructions) => void compact(instructions),
5300
7386
  send: (prompt, display) => runPrompt(prompt, display),
7387
+ review: (ref) => {
7388
+ collectReviewTarget(cwd, ref.trim() || void 0).then(
7389
+ (target) => {
7390
+ try {
7391
+ const w = orchestrator.spawn({ description: `Code review: ${target.label}`.slice(0, 50), prompt: buildReviewPrompt(target), type: "explore", name: "reviewer" });
7392
+ const count = target.files.length + target.untracked.length;
7393
+ info(`Reviewing ${target.label} (${count} file${count === 1 ? "" : "s"}) in the background as ${w.name} \xB7 /workers ${w.name} to follow it`, "success");
7394
+ } catch (error) {
7395
+ showError(error instanceof Error ? error.message : String(error));
7396
+ }
7397
+ },
7398
+ (error) => showError(error instanceof Error ? error.message : String(error))
7399
+ );
7400
+ },
7401
+ coordinate: (args) => {
7402
+ const eager = /^--eager(\s|$)/.test(args.trim());
7403
+ const goal = args.trim().replace(/^--eager\s*/, "").trim();
7404
+ if (!goal) return info("Usage: /coordinate [--eager] <goal> (or /coordinate off)", "warn");
7405
+ if (goal.toLowerCase() === "off") {
7406
+ coordRef.current = null;
7407
+ return info("Coordinator off.", "dim");
7408
+ }
7409
+ coordRef.current = { goal, round: 0, reported: /* @__PURE__ */ new Set(), eager };
7410
+ void runPrompt(coordinatorPrompt(goal, { eager }), `/coordinate ${eager ? "--eager " : ""}${goal}`);
7411
+ },
7412
+ workers: {
7413
+ board: () => orchestrator.list().length ? setDialog({ type: "workers" }) : info("No background agents yet. Start one with /spawn <task>, or ask me to run several in parallel.", "dim"),
7414
+ show: (query) => {
7415
+ const w = orchestrator.find(query);
7416
+ if (!w) return showError(`No background agent "${query}". See /workers.`);
7417
+ info(describeWorker(w).join("\n"));
7418
+ },
7419
+ spawn: (task, name) => {
7420
+ try {
7421
+ const w = orchestrator.spawn({ description: task.split("\n")[0].slice(0, 50), prompt: task, type: "general", name });
7422
+ info(`Started ${w.name} [${w.id}] in the background \xB7 /workers to watch it`, "success");
7423
+ } catch (error) {
7424
+ showError(error instanceof Error ? error.message : String(error));
7425
+ }
7426
+ },
7427
+ stop: (query) => {
7428
+ if (query.toLowerCase() === "all") {
7429
+ const n = orchestrator.cancelAll();
7430
+ return info(n ? `Stopped ${n} background agent${n === 1 ? "" : "s"}.` : "Nothing is running.", n ? "success" : "dim");
7431
+ }
7432
+ try {
7433
+ const w = orchestrator.cancel(query);
7434
+ info(`${w.name} is ${w.status}.`, "dim");
7435
+ } catch (error) {
7436
+ showError(error instanceof Error ? error.message : String(error));
7437
+ }
7438
+ },
7439
+ message: (query, text2) => {
7440
+ try {
7441
+ info(orchestrator.send("main", query, text2), "success");
7442
+ } catch (error) {
7443
+ showError(error instanceof Error ? error.message : String(error));
7444
+ }
7445
+ }
7446
+ },
7447
+ rewind: () => {
7448
+ if (runningRef.current) return info("Wait for the current turn to finish (or press esc to interrupt it) before rewinding.", "warn");
7449
+ if (checkpointsRef.current.list().length === 0) return info("Nothing to rewind to yet: rewind points are made as you send prompts.", "dim");
7450
+ setDialog({ type: "rewind" });
7451
+ },
7452
+ sessions: {
7453
+ list: () => void bus.list().then(
7454
+ (sessions) => info(describeSessions(sessions, selfIdentity().id).join("\n")),
7455
+ (error) => showError(error instanceof Error ? error.message : String(error))
7456
+ ),
7457
+ send: (to, text2) => void bus.send(selfIdentity(), to, text2).then(
7458
+ (target) => info(`Sent to session "${target.title || target.id.slice(0, 8)}" (${target.id.slice(0, 8)}).`, "success"),
7459
+ (error) => showError(error instanceof Error ? error.message : String(error))
7460
+ )
7461
+ },
7462
+ side: {
7463
+ start: (question, edit) => {
7464
+ if (!question) return info("Usage: /side [--edit] <question>", "warn");
7465
+ const existing2 = sideWorker();
7466
+ if (existing2) {
7467
+ setFocus("side");
7468
+ return sendToSide(question);
7469
+ }
7470
+ try {
7471
+ const w = orchestrator.spawn({
7472
+ name: "side",
7473
+ description: `Side: ${question.replace(/\s+/g, " ").slice(0, 40)}`,
7474
+ prompt: buildSideBrief(snapshotConversation(historyRef.current), question),
7475
+ type: edit ? "general" : "explore",
7476
+ interactive: true,
7477
+ shown: question
7478
+ });
7479
+ sideIdRef.current = w.id;
7480
+ setSideId(w.id);
7481
+ setFocus("side");
7482
+ } catch (error) {
7483
+ showError(error instanceof Error ? error.message : String(error));
7484
+ }
7485
+ },
7486
+ toggle: () => {
7487
+ if (!sideWorker()) return info("No side session open. Start one with /side <question>.", "dim");
7488
+ setFocus(focusRef.current === "side" ? "main" : "side");
7489
+ },
7490
+ focus: (target) => {
7491
+ if (target === "side" && !sideWorker()) return info("No side session open. Start one with /side <question>.", "dim");
7492
+ setFocus(target);
7493
+ },
7494
+ close: () => {
7495
+ if (!sideIdRef.current) return info("No side session open.", "dim");
7496
+ closeSide();
7497
+ info("Side session closed.", "dim");
7498
+ },
7499
+ show: () => {
7500
+ const id = sideIdRef.current;
7501
+ const w = id ? orchestrator.list().find((x) => x.id === id) : void 0;
7502
+ if (!w) return info("No side session open.", "dim");
7503
+ info(sideText(w));
7504
+ },
7505
+ sendToMain: () => {
7506
+ const w = sideWorker();
7507
+ if (!w?.result) return info("The side session has not answered yet.", "dim");
7508
+ pendingContext.current += `${pendingContext.current ? "\n" : ""}[from the user's side session]
7509
+ ${w.result.slice(0, 3e3)}`;
7510
+ info("The side session's last answer goes to the main agent with your next prompt.", "success");
7511
+ }
7512
+ },
5301
7513
  allCommands: () => commands,
5302
7514
  describeStatus: () => [
5303
7515
  `AI Draftsman v${VERSION}`,
@@ -5382,10 +7594,10 @@ ${output.slice(0, 8e3)}`;
5382
7594
  if (!note) return;
5383
7595
  push({ kind: "user", text: sub.display });
5384
7596
  try {
5385
- const file = path15.join(cwd, "AD.md");
7597
+ const file = path21.join(cwd, "AD.md");
5386
7598
  let prefix = "";
5387
7599
  try {
5388
- prefix = (await readFile11(file, "utf8")).endsWith("\n") ? "" : "\n";
7600
+ prefix = (await readFile15(file, "utf8")).endsWith("\n") ? "" : "\n";
5389
7601
  } catch {
5390
7602
  }
5391
7603
  await appendFile(file, `${prefix}- ${note}
@@ -5396,9 +7608,15 @@ ${output.slice(0, 8e3)}`;
5396
7608
  }
5397
7609
  return;
5398
7610
  }
7611
+ if (routesToSide(text2)) return sendToSide(text2);
7612
+ if (focusRef.current === "side") setFocus("main");
5399
7613
  void runPrompt(text2, sub.display);
5400
7614
  };
5401
7615
  const handleSubmit = (sub) => {
7616
+ if (routesToSide(sub.text) || runsImmediately(sub.text)) {
7617
+ void processSubmission(sub);
7618
+ return;
7619
+ }
5402
7620
  if (runningRef.current) {
5403
7621
  queueRef.current.push(sub);
5404
7622
  setQueued(queueRef.current.length);
@@ -5431,6 +7649,10 @@ ${output.slice(0, 8e3)}`;
5431
7649
  exitTimer.current = setTimeout(() => setExitHint(false), 2e3);
5432
7650
  return;
5433
7651
  }
7652
+ if (key.ctrl && input === "o") {
7653
+ if (sideWorker()) setFocus(focusRef.current === "side" ? "main" : "side");
7654
+ return;
7655
+ }
5434
7656
  if (key.ctrl && input === "d" && !runningRef.current) {
5435
7657
  result.action = "exit";
5436
7658
  exit();
@@ -5449,6 +7671,11 @@ ${output.slice(0, 8e3)}`;
5449
7671
  { isActive: dialog === null || dialog.type !== "ask" }
5450
7672
  );
5451
7673
  const rows = stdout.rows ?? 30;
7674
+ const cols = stdout.columns ?? 100;
7675
+ const sideShown = sideId ? orchestrator.list().find((w) => w.id === sideId) : void 0;
7676
+ const layout = sideLayout(cols, rows);
7677
+ const split = !!sideShown && layout.split;
7678
+ const { paneWidth, paneLines } = layout;
5452
7679
  const agentItems = [
5453
7680
  { value: "__auto__", label: AUTO_AGENT.name, description: "let the gateway choose the model", hint: agent.model === AUTO_AGENT.model ? "(current)" : void 0 },
5454
7681
  ...agents.map((a) => ({
@@ -5461,47 +7688,52 @@ ${output.slice(0, 8e3)}`;
5461
7688
  const renderItem = (item) => {
5462
7689
  switch (item.kind) {
5463
7690
  case "header":
5464
- return /* @__PURE__ */ jsx7(Header, { agent: item.agent, account: item.account, cwd: item.cwd, notes: item.notes });
7691
+ return /* @__PURE__ */ jsx8(Header, { agent: item.agent, account: item.account, cwd: item.cwd, notes: item.notes });
5465
7692
  case "user":
5466
- return /* @__PURE__ */ jsx7(UserMessage, { text: item.text });
7693
+ return /* @__PURE__ */ jsx8(UserMessage, { text: item.text });
5467
7694
  case "assistant":
5468
- return /* @__PURE__ */ jsx7(AssistantMessage, { text: item.text });
7695
+ return /* @__PURE__ */ jsx8(AssistantMessage, { text: item.text });
5469
7696
  case "tool":
5470
- return /* @__PURE__ */ jsx7(ToolView, { item });
7697
+ return /* @__PURE__ */ jsx8(ToolView, { item });
5471
7698
  case "shell":
5472
- return /* @__PURE__ */ jsx7(ShellView, { item });
7699
+ return /* @__PURE__ */ jsx8(ShellView, { item });
5473
7700
  case "info":
5474
- return /* @__PURE__ */ jsx7(InfoLine, { text: item.text, tone: item.tone });
7701
+ return /* @__PURE__ */ jsx8(InfoLine, { text: item.text, tone: item.tone });
5475
7702
  case "error":
5476
- return /* @__PURE__ */ jsx7(ErrorLine, { text: item.text });
7703
+ return /* @__PURE__ */ jsx8(ErrorLine, { text: item.text });
5477
7704
  }
5478
7705
  };
5479
- return /* @__PURE__ */ jsxs6(Fragment3, { children: [
5480
- /* @__PURE__ */ jsx7(Static, { items, children: (item) => /* @__PURE__ */ jsx7(Box7, { children: renderItem(item) }, item.id) }, epoch),
5481
- liveText ? /* @__PURE__ */ jsx7(AssistantMessage, { text: tailLines(liveText, Math.max(rows - 14, 6)) }) : null,
5482
- runningTools.map((t) => /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexDirection: "column", children: [
5483
- /* @__PURE__ */ jsxs6(Text7, { children: [
5484
- /* @__PURE__ */ jsx7(Text7, { color: theme.amber, children: "\u25CF " }),
5485
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: toolTitle(t.name) }),
5486
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: `(${t.label})` })
7706
+ return /* @__PURE__ */ jsxs7(Fragment3, { children: [
7707
+ /* @__PURE__ */ jsx8(Static, { items, children: (item) => /* @__PURE__ */ jsx8(Box8, { children: renderItem(item) }, item.id) }, epoch),
7708
+ /* @__PURE__ */ jsxs7(Box8, { flexDirection: split ? "row" : "column", children: [
7709
+ /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", flexGrow: 1, children: [
7710
+ liveText ? /* @__PURE__ */ jsx8(AssistantMessage, { text: tailLines(liveText, Math.max(rows - 14, 6)) }) : null,
7711
+ runningTools.map((t) => /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, flexDirection: "column", children: [
7712
+ /* @__PURE__ */ jsxs7(Text8, { children: [
7713
+ /* @__PURE__ */ jsx8(Text8, { color: theme.amber, children: "\u25CF " }),
7714
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: toolTitle(t.name) }),
7715
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: `(${t.label})` })
7716
+ ] }),
7717
+ t.name === "task" ? /* @__PURE__ */ jsxs7(Text8, { dimColor: true, children: [
7718
+ " \u23BF ",
7719
+ t.progress ? `${t.progress.activity} \xB7 ${t.progress.count} tool use${t.progress.count === 1 ? "" : "s"}` : "Starting\u2026"
7720
+ ] }) : null
7721
+ ] }, t.id)),
7722
+ /* @__PURE__ */ jsx8(TodoList, { todos }),
7723
+ phase === "running" && dialog === null ? /* @__PURE__ */ jsx8(
7724
+ WorkingIndicator,
7725
+ {
7726
+ tokens: turnTokens,
7727
+ label: workingLabel ?? statusText,
7728
+ waiting: liveText || runningTools.length ? void 0 : waiting,
7729
+ fallback: activityLabel(runningTools, liveText.length > 0)
7730
+ }
7731
+ ) : null
5487
7732
  ] }),
5488
- t.name === "task" ? /* @__PURE__ */ jsxs6(Text7, { dimColor: true, children: [
5489
- " \u23BF ",
5490
- t.progress ? `${t.progress.activity} \xB7 ${t.progress.count} tool use${t.progress.count === 1 ? "" : "s"}` : "Starting\u2026"
5491
- ] }) : null
5492
- ] }, t.id)),
5493
- /* @__PURE__ */ jsx7(TodoList, { todos }),
5494
- phase === "running" && dialog === null ? /* @__PURE__ */ jsx7(
5495
- WorkingIndicator,
5496
- {
5497
- tokens: turnTokens,
5498
- label: workingLabel ?? statusText,
5499
- waiting: liveText || runningTools.length ? void 0 : waiting,
5500
- fallback: activityLabel(runningTools, liveText.length > 0)
5501
- }
5502
- ) : null,
5503
- dialog?.type === "ask" ? /* @__PURE__ */ jsx7(AskDialog, { request: dialog.request, onAnswer: answerAsk }) : null,
5504
- dialog?.type === "agents" ? /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
7733
+ sideShown ? /* @__PURE__ */ jsx8(Box8, { marginTop: split ? 1 : 0, marginLeft: split ? 1 : 0, children: /* @__PURE__ */ jsx8(SidePane, { worker: sideShown, width: paneWidth, maxLines: paneLines, focused: focus === "side" }) }) : null
7734
+ ] }),
7735
+ dialog?.type === "ask" ? /* @__PURE__ */ jsx8(AskDialog, { request: dialog.request, onAnswer: answerAsk }) : null,
7736
+ dialog?.type === "agents" ? /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
5505
7737
  Picker,
5506
7738
  {
5507
7739
  title: dialog.first ? "Choose an agent to work with" : "Switch agent",
@@ -5530,7 +7762,64 @@ ${output.slice(0, 8e3)}`;
5530
7762
  }
5531
7763
  }
5532
7764
  ) }) : null,
5533
- dialog?.type === "sessions" ? /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
7765
+ dialog?.type === "workers" ? /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
7766
+ Picker,
7767
+ {
7768
+ title: "Background agents",
7769
+ subtitle: "Enter to open one \xB7 esc to close \xB7 /spawn to start another",
7770
+ items: orchestrator.list().map((w) => ({ value: w.id, ...workerRow(w) })),
7771
+ onCancel: () => setDialog(null),
7772
+ onSelect: (id) => setDialog({ type: "worker", id })
7773
+ }
7774
+ ) }) : null,
7775
+ dialog?.type === "rewind" ? /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
7776
+ Picker,
7777
+ {
7778
+ title: "Rewind to before\u2026",
7779
+ subtitle: "Pick the prompt to go back to \xB7 esc to cancel",
7780
+ items: rewindRows(checkpointsRef.current),
7781
+ onCancel: () => setDialog(null),
7782
+ onSelect: (value) => setDialog({ type: "rewind-mode", id: Number(value) })
7783
+ }
7784
+ ) }) : null,
7785
+ dialog?.type === "worker" ? /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: (() => {
7786
+ const w = orchestrator.list().find((x) => x.id === dialog.id);
7787
+ if (!w) return /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "That agent is gone." });
7788
+ return /* @__PURE__ */ jsx8(
7789
+ Picker,
7790
+ {
7791
+ title: `${w.name} \xB7 ${w.description}`,
7792
+ subtitle: describeWorker(w).join("\n"),
7793
+ items: [
7794
+ { value: "back", label: "\u2190 Back to the list" },
7795
+ ...w.status === "running" || w.status === "queued" ? [{ value: "stop", label: "Stop this agent" }] : [],
7796
+ { value: "close", label: "Close" }
7797
+ ],
7798
+ onCancel: () => setDialog({ type: "workers" }),
7799
+ onSelect: (choice2) => {
7800
+ if (choice2 === "stop") {
7801
+ orchestrator.cancel(w.id);
7802
+ setDialog({ type: "workers" });
7803
+ } else setDialog(choice2 === "back" ? { type: "workers" } : null);
7804
+ }
7805
+ }
7806
+ );
7807
+ })() }) : null,
7808
+ dialog?.type === "rewind-mode" ? /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
7809
+ Picker,
7810
+ {
7811
+ title: `Rewind to before: ${checkpointsRef.current.get(dialog.id)?.prompt ?? ""}`,
7812
+ subtitle: "What should be undone? Shell commands are tracked inside git repositories only.",
7813
+ items: rewindModeRows(checkpointsRef.current, dialog.id),
7814
+ onCancel: () => setDialog({ type: "rewind" }),
7815
+ onSelect: (choice2) => {
7816
+ const id = dialog.id;
7817
+ setDialog(null);
7818
+ if (choice2 !== "cancel") void applyRewind(id, choice2);
7819
+ }
7820
+ }
7821
+ ) }) : null,
7822
+ dialog?.type === "sessions" ? /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
5534
7823
  Picker,
5535
7824
  {
5536
7825
  title: "Resume a conversation",
@@ -5551,6 +7840,8 @@ ${output.slice(0, 8e3)}`;
5551
7840
  agentRef.current = chosen.agent;
5552
7841
  setAgent(chosen.agent);
5553
7842
  readFilesRef.current.clear();
7843
+ checkpointsRef.current.clear();
7844
+ adoptSavedCheckpoints(chosen.id, chosen.messages.length);
5554
7845
  stdout.write(CLEAR_SCREEN);
5555
7846
  setItems([
5556
7847
  headerItem(chosen.agent),
@@ -5561,8 +7852,8 @@ ${output.slice(0, 8e3)}`;
5561
7852
  }
5562
7853
  }
5563
7854
  ) }) : null,
5564
- dialog === null ? /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", marginTop: 1, children: [
5565
- /* @__PURE__ */ jsx7(
7855
+ dialog === null ? /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", marginTop: 1, children: [
7856
+ /* @__PURE__ */ jsx8(
5566
7857
  PromptBox,
5567
7858
  {
5568
7859
  focus: true,
@@ -5577,8 +7868,8 @@ ${output.slice(0, 8e3)}`;
5577
7868
  onToggleHelp: () => setShowHelp((v) => !v)
5578
7869
  }
5579
7870
  ),
5580
- showHelp ? /* @__PURE__ */ jsx7(ShortcutsPanel, {}) : null,
5581
- /* @__PURE__ */ jsx7(
7871
+ showHelp ? /* @__PURE__ */ jsx8(ShortcutsPanel, {}) : null,
7872
+ /* @__PURE__ */ jsx8(
5582
7873
  StatusLine,
5583
7874
  {
5584
7875
  mode,
@@ -5587,7 +7878,9 @@ ${output.slice(0, 8e3)}`;
5587
7878
  contextTokens,
5588
7879
  exitHint,
5589
7880
  queued,
5590
- showHelpHint: !showHelp
7881
+ showHelpHint: !showHelp,
7882
+ workers: boardSummary(orchestrator.list()),
7883
+ typingTo: sideShown && focus === "side" ? "side" : void 0
5591
7884
  }
5592
7885
  )
5593
7886
  ] }) : null
@@ -5595,7 +7888,7 @@ ${output.slice(0, 8e3)}`;
5595
7888
  }
5596
7889
 
5597
7890
  // src/tui/run.tsx
5598
- import { jsx as jsx8 } from "react/jsx-runtime";
7891
+ import { jsx as jsx9 } from "react/jsx-runtime";
5599
7892
  async function runTui(options) {
5600
7893
  const { config: config2 } = options;
5601
7894
  const cwd = options.cwd ?? process.cwd();
@@ -5604,7 +7897,7 @@ async function runTui(options) {
5604
7897
  const agent = options.resume && !options.agent ? options.resume.agent : wanted ? findAgent(gateway.agents, wanted) : void 0;
5605
7898
  const result = { action: "exit" };
5606
7899
  const app = render(
5607
- /* @__PURE__ */ jsx8(
7900
+ /* @__PURE__ */ jsx9(
5608
7901
  App,
5609
7902
  {
5610
7903
  config: config2,
@@ -5628,13 +7921,13 @@ async function runTui(options) {
5628
7921
  }
5629
7922
 
5630
7923
  // src/lib/update-check.ts
5631
- import { mkdir as mkdir6, readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
5632
- import path16 from "path";
7924
+ import { mkdir as mkdir7, readFile as readFile16, writeFile as writeFile8 } from "fs/promises";
7925
+ import path22 from "path";
5633
7926
  var PACKAGE = "@consultancy-outfit/ai-draftsman-cli";
5634
7927
  var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE.replace("/", "%2f")}/latest`;
5635
7928
  var CHECK_EVERY_MS = 24 * 60 * 60 * 1e3;
5636
7929
  var TIMEOUT_MS2 = 3e3;
5637
- var cachePath2 = () => path16.join(getConfigDir(), "update-check.json");
7930
+ var cachePath2 = () => path22.join(getConfigDir(), "update-check.json");
5638
7931
  function compareVersions(a, b) {
5639
7932
  const parse = (v) => {
5640
7933
  const [core = "", pre] = v.trim().replace(/^v/, "").split("-", 2);
@@ -5652,7 +7945,7 @@ function compareVersions(a, b) {
5652
7945
  }
5653
7946
  async function readUpdateCache() {
5654
7947
  try {
5655
- const file = JSON.parse(await readFile12(cachePath2(), "utf8"));
7948
+ const file = JSON.parse(await readFile16(cachePath2(), "utf8"));
5656
7949
  return Number.isFinite(file.checkedAt) ? file : void 0;
5657
7950
  } catch {
5658
7951
  return void 0;
@@ -5669,8 +7962,8 @@ async function refreshUpdateCache(fetchImpl = fetch) {
5669
7962
  } catch {
5670
7963
  }
5671
7964
  try {
5672
- await mkdir6(getConfigDir(), { recursive: true });
5673
- await writeFile7(cachePath2(), JSON.stringify(entry), "utf8");
7965
+ await mkdir7(getConfigDir(), { recursive: true });
7966
+ await writeFile8(cachePath2(), JSON.stringify(entry), "utf8");
5674
7967
  } catch {
5675
7968
  }
5676
7969
  return entry;
@@ -5789,8 +8082,8 @@ async function requireAuth() {
5789
8082
 
5790
8083
  // src/lib/app/headless.ts
5791
8084
  import { spawn as spawn3 } from "child_process";
5792
- import { readFile as readFile13 } from "fs/promises";
5793
- import path17 from "path";
8085
+ import { readFile as readFile17 } from "fs/promises";
8086
+ import path23 from "path";
5794
8087
  var CLAUDE_NAME = {
5795
8088
  bash: "Bash",
5796
8089
  read_file: "Read",
@@ -5818,6 +8111,7 @@ var GRANTS = {
5818
8111
  create_artifact: ["create_artifact"],
5819
8112
  web_fetch: ["web_fetch"],
5820
8113
  call_connector: ["call_connector"],
8114
+ browser: ["browser"],
5821
8115
  // Claude Code's spellings for the connector server: the whole server, any tool on it, or the one tool.
5822
8116
  mcp__aidraftsman: ["call_connector"],
5823
8117
  "mcp__aidraftsman__*": ["call_connector"],
@@ -5849,7 +8143,7 @@ function claudeToolInput(name, rawArguments, cwd) {
5849
8143
  }
5850
8144
  const fileTool = name === "read_file" || name === "write_file" || name === "edit_file" || name === "create_artifact";
5851
8145
  if (fileTool && typeof args.path === "string") {
5852
- args.file_path = path17.resolve(cwd, args.path);
8146
+ args.file_path = path23.resolve(cwd, args.path);
5853
8147
  delete args.path;
5854
8148
  }
5855
8149
  return args;
@@ -5878,6 +8172,10 @@ function applyAllowedTools(rules, values) {
5878
8172
  } else if (name === "webfetch" || name === "web_fetch") {
5879
8173
  const host = scope.replace(/^domain:/, "").trim();
5880
8174
  if (host) rules.add(`web:${host}`);
8175
+ } else if (name === "browser") {
8176
+ const actions = scope === "read" ? ["list_tabs", "read_page"] : [scope];
8177
+ if (actions.every((a) => BROWSER_ACTIONS.includes(a))) for (const a of actions) rules.add(`browser:${a}`);
8178
+ else unknown.push(spec);
5881
8179
  } else if (GRANTS[name]) {
5882
8180
  for (const tool of GRANTS[name]) rules.grantTool(tool);
5883
8181
  } else if (!NO_GRANT_NEEDED.has(name) && !name.startsWith("mcp__")) unknown.push(spec);
@@ -5893,7 +8191,7 @@ async function loadPreToolUseHooks(value) {
5893
8191
  let text2 = value.trim();
5894
8192
  if (!text2.startsWith("{")) {
5895
8193
  try {
5896
- text2 = await readFile13(value, "utf8");
8194
+ text2 = await readFile17(value, "utf8");
5897
8195
  } catch (error) {
5898
8196
  throw new CliError(`--settings: cannot read ${value} (${error.message}).`, 3);
5899
8197
  }
@@ -6309,14 +8607,14 @@ function extractText(value) {
6309
8607
  }
6310
8608
 
6311
8609
  // src/lib/util/input.ts
6312
- import { readFile as readFile14 } from "fs/promises";
8610
+ import { readFile as readFile18 } from "fs/promises";
6313
8611
  import process2 from "process";
6314
8612
  async function resolveTextInput(options) {
6315
8613
  if (options.message) {
6316
8614
  return options.message;
6317
8615
  }
6318
8616
  if (options.file) {
6319
- return readFile14(options.file, "utf8");
8617
+ return readFile18(options.file, "utf8");
6320
8618
  }
6321
8619
  if (!process2.stdin.isTTY) {
6322
8620
  const chunks = [];
@@ -6428,6 +8726,399 @@ function registerAuthCommands(program) {
6428
8726
  });
6429
8727
  }
6430
8728
 
8729
+ // src/lib/browser/host.ts
8730
+ import { randomBytes, randomUUID as randomUUID5, timingSafeEqual } from "crypto";
8731
+ import { mkdir as mkdir9, rm as rm6 } from "fs/promises";
8732
+ import net2 from "net";
8733
+ import path25 from "path";
8734
+
8735
+ // src/lib/browser/chat.ts
8736
+ import { spawn as spawn4 } from "child_process";
8737
+ import { mkdir as mkdir8 } from "fs/promises";
8738
+ import path24 from "path";
8739
+ var CHAT_PREAMBLE = `You are AI Draftsman inside the user's Chrome side panel. The user is looking at their browser right now. Use the browser tool to see and act on their pages: read_page first, then click or type by ref. Answer briefly. If a browser action is refused, tell the user to switch the panel to "Read and act".
8740
+
8741
+ User: `;
8742
+ function allowedToolsFor(mode) {
8743
+ return mode === "act" ? "browser" : "browser(read)";
8744
+ }
8745
+ function parseStreamLine(line) {
8746
+ let event;
8747
+ try {
8748
+ event = JSON.parse(line);
8749
+ } catch {
8750
+ return [];
8751
+ }
8752
+ const out = [];
8753
+ if (event.type === "system" && typeof event.session_id === "string") out.push({ kind: "session", sessionId: event.session_id });
8754
+ const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
8755
+ if (event.type === "assistant") {
8756
+ for (const b of blocks) {
8757
+ if (b.type === "text" && b.text?.trim()) out.push({ kind: "text", text: b.text });
8758
+ if (b.type === "tool_use" && typeof b.id === "string") out.push({ kind: "tool", id: b.id, label: describeTool(b.name ?? "", b.input ?? {}) });
8759
+ }
8760
+ } else if (event.type === "user") {
8761
+ for (const b of blocks) if (b.type === "tool_result" && typeof b.tool_use_id === "string") out.push({ kind: "tool_result", id: b.tool_use_id, error: b.is_error === true });
8762
+ } else if (event.type === "result") {
8763
+ if (typeof event.session_id === "string") out.push({ kind: "session", sessionId: event.session_id });
8764
+ out.push(event.is_error ? { kind: "error", text: event.result || "The agent stopped with an error." } : { kind: "done", text: event.result ?? "" });
8765
+ }
8766
+ return out;
8767
+ }
8768
+ function describeTool(name, input) {
8769
+ if (name !== "browser") return name;
8770
+ const action = String(input.action ?? "");
8771
+ if (action === "navigate") return `Open ${String(input.url ?? "")}`;
8772
+ if (action === "read_page") return "Read the page";
8773
+ if (action === "list_tabs") return "List tabs";
8774
+ if (action === "click") return `Click [${String(input.ref ?? "")}]`;
8775
+ if (action === "type") return `Type into [${String(input.ref ?? "")}]`;
8776
+ return `Browser ${action}`;
8777
+ }
8778
+ async function chatDir() {
8779
+ const dir = path24.join(getConfigDir(), "browser", "chat");
8780
+ await mkdir8(dir, { recursive: true, mode: 448 });
8781
+ return dir;
8782
+ }
8783
+ async function startChat(options) {
8784
+ const node = options.node ?? process.execPath;
8785
+ const entry = options.entry ?? process.argv[1];
8786
+ const args = [
8787
+ entry,
8788
+ "-p",
8789
+ "--output-format",
8790
+ "stream-json",
8791
+ "--allowedTools",
8792
+ allowedToolsFor(options.mode),
8793
+ ...options.resume ? ["--resume", options.resume] : [],
8794
+ ...options.extraArgs ?? [],
8795
+ CHAT_PREAMBLE + options.text
8796
+ ];
8797
+ const child = spawn4(node, args, { cwd: options.cwd ?? await chatDir(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
8798
+ let buffer = "";
8799
+ let stderr = "";
8800
+ let sawResult = false;
8801
+ child.stdout.setEncoding("utf8");
8802
+ child.stdout.on("data", (chunk) => {
8803
+ buffer += chunk;
8804
+ let nl;
8805
+ while ((nl = buffer.indexOf("\n")) >= 0) {
8806
+ const line = buffer.slice(0, nl).trim();
8807
+ buffer = buffer.slice(nl + 1);
8808
+ if (!line) continue;
8809
+ for (const e of parseStreamLine(line)) {
8810
+ if (e.kind === "done" || e.kind === "error") sawResult = true;
8811
+ options.onEvent(e);
8812
+ }
8813
+ }
8814
+ });
8815
+ child.stderr.setEncoding("utf8");
8816
+ child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-2e3));
8817
+ const finished = new Promise((resolve) => {
8818
+ child.on("error", (error) => {
8819
+ if (!sawResult) options.onEvent({ kind: "error", text: `Could not start the agent: ${error.message}` });
8820
+ sawResult = true;
8821
+ resolve();
8822
+ });
8823
+ child.on("close", (code) => {
8824
+ if (!sawResult) options.onEvent({ kind: "error", text: stderr.trim().split("\n").slice(-3).join(" ") || `The agent exited with code ${code}.` });
8825
+ resolve();
8826
+ });
8827
+ });
8828
+ return { stop: () => child.kill(), finished };
8829
+ }
8830
+
8831
+ // src/lib/browser/host.ts
8832
+ function safeEqual(a, b) {
8833
+ const x = Buffer.from(a);
8834
+ const y = Buffer.from(b);
8835
+ return x.length === y.length && timingSafeEqual(x, y);
8836
+ }
8837
+ async function startHost(options) {
8838
+ const endpoint = options.endpoint ?? defaultEndpoint();
8839
+ const infoFile = options.infoPath ?? hostInfoPath();
8840
+ const timeoutMs = options.requestTimeoutMs ?? 3e4;
8841
+ const log = options.log ?? ((line) => process.stderr.write(`[browser-host] ${line}
8842
+ `));
8843
+ const token = randomBytes(24).toString("hex");
8844
+ const info = { endpoint, token, pid: process.pid, startedAt: Date.now() };
8845
+ const pending = /* @__PURE__ */ new Map();
8846
+ let extensionReady = false;
8847
+ let extensionVersion = "";
8848
+ let stopped = false;
8849
+ const toExtension = (message) => options.stdout.write(encodeFrame(message));
8850
+ const runs = /* @__PURE__ */ new Map();
8851
+ const launch = options.startChat ?? startChat;
8852
+ function handleChat(m) {
8853
+ if (typeof m.id !== "string") return;
8854
+ if (m.type === "chat_stop") {
8855
+ runs.get(m.id)?.stop();
8856
+ return;
8857
+ }
8858
+ const text2 = typeof m.text === "string" ? m.text.slice(0, 8e3) : "";
8859
+ if (!text2.trim() || runs.has(m.id)) return;
8860
+ const send = (event) => {
8861
+ if (!stopped) toExtension({ type: "chat_event", id: m.id, event });
8862
+ };
8863
+ const mode = m.mode === "act" ? "act" : "read";
8864
+ launch({ text: text2, mode, ...typeof m.resume === "string" && m.resume ? { resume: m.resume } : {}, onEvent: send }).then(
8865
+ (run2) => {
8866
+ runs.set(m.id, run2);
8867
+ void run2.finished.then(() => runs.delete(m.id));
8868
+ },
8869
+ (error) => send({ kind: "error", text: error.message })
8870
+ );
8871
+ }
8872
+ const decoder = new FrameDecoder();
8873
+ options.stdin.on("data", (chunk) => {
8874
+ let messages;
8875
+ try {
8876
+ messages = decoder.push(chunk);
8877
+ } catch (error) {
8878
+ log(`bad frame from the extension: ${error.message}`);
8879
+ void stop();
8880
+ return;
8881
+ }
8882
+ for (const message of messages) {
8883
+ const m = message;
8884
+ if (m.type === "hello") {
8885
+ extensionReady = true;
8886
+ extensionVersion = typeof m.version === "string" ? m.version : "";
8887
+ continue;
8888
+ }
8889
+ if (m.type === "chat" || m.type === "chat_stop") {
8890
+ handleChat(message);
8891
+ continue;
8892
+ }
8893
+ if (typeof m.id !== "string") continue;
8894
+ const entry = pending.get(m.id);
8895
+ if (!entry) continue;
8896
+ pending.delete(m.id);
8897
+ clearTimeout(entry.timer);
8898
+ entry.reply(m);
8899
+ }
8900
+ });
8901
+ const failAll = (error) => {
8902
+ for (const [id, entry] of pending) {
8903
+ clearTimeout(entry.timer);
8904
+ entry.reply({ id, ok: false, error });
8905
+ }
8906
+ pending.clear();
8907
+ };
8908
+ const server = net2.createServer((socket) => {
8909
+ let authed = false;
8910
+ let buffer = "";
8911
+ const send = (message) => {
8912
+ if (!socket.destroyed) socket.write(`${JSON.stringify(message)}
8913
+ `);
8914
+ };
8915
+ const handle = (line) => {
8916
+ let message;
8917
+ try {
8918
+ message = JSON.parse(line);
8919
+ } catch {
8920
+ return send({ id: "", ok: false, error: "Not valid JSON." });
8921
+ }
8922
+ const id = typeof message.id === "string" ? message.id : randomUUID5();
8923
+ if (!authed) {
8924
+ if (typeof message.token !== "string" || !safeEqual(message.token, token)) {
8925
+ send({ id, ok: false, error: "Bad token." });
8926
+ return socket.destroy();
8927
+ }
8928
+ authed = true;
8929
+ return send({ id, ok: true, result: { connected: extensionReady, extensionVersion, pid: process.pid } });
8930
+ }
8931
+ if (message.action === "status") return send({ id, ok: true, result: { connected: extensionReady, extensionVersion, pending: pending.size } });
8932
+ if (!isBrowserAction(message.action)) return send({ id, ok: false, error: `Unknown browser action "${String(message.action)}".` });
8933
+ if (!extensionReady) return send({ id, ok: false, error: "The AI Draftsman extension is not connected. Open Chrome with the extension installed." });
8934
+ const args = message.args && typeof message.args === "object" ? message.args : {};
8935
+ const timer = setTimeout(() => {
8936
+ pending.delete(id);
8937
+ send({ id, ok: false, error: `The browser did not answer within ${Math.round(timeoutMs / 1e3)}s.` });
8938
+ }, timeoutMs);
8939
+ pending.set(id, { timer, reply: (response) => send({ ...response, id }) });
8940
+ try {
8941
+ toExtension({ id, action: message.action, args });
8942
+ } catch (error) {
8943
+ pending.delete(id);
8944
+ clearTimeout(timer);
8945
+ send({ id, ok: false, error: error.message });
8946
+ }
8947
+ };
8948
+ socket.setEncoding("utf8");
8949
+ socket.on("data", (chunk) => {
8950
+ buffer += chunk;
8951
+ if (Buffer.byteLength(buffer) > MAX_CLIENT_LINE_BYTES) return socket.destroy();
8952
+ let nl;
8953
+ while ((nl = buffer.indexOf("\n")) >= 0) {
8954
+ const line = buffer.slice(0, nl).trim();
8955
+ buffer = buffer.slice(nl + 1);
8956
+ if (line) handle(line);
8957
+ }
8958
+ });
8959
+ socket.on("error", () => void 0);
8960
+ });
8961
+ if (process.platform !== "win32") await rm6(endpoint, { force: true });
8962
+ await mkdir9(path25.dirname(infoFile), { recursive: true, mode: 448 });
8963
+ await new Promise((resolve, reject) => {
8964
+ server.once("error", reject);
8965
+ server.listen(endpoint, () => resolve());
8966
+ });
8967
+ await writePrivateFile(infoFile, JSON.stringify(info));
8968
+ let closeResolve;
8969
+ const closed = new Promise((resolve) => closeResolve = resolve);
8970
+ async function stop() {
8971
+ if (stopped) return;
8972
+ stopped = true;
8973
+ for (const run2 of runs.values()) run2.stop();
8974
+ failAll("The browser connection closed.");
8975
+ await new Promise((resolve) => server.close(() => resolve()));
8976
+ await rm6(infoFile, { force: true });
8977
+ if (process.platform !== "win32") await rm6(endpoint, { force: true });
8978
+ closeResolve();
8979
+ }
8980
+ options.stdin.on("end", () => void stop());
8981
+ options.stdin.on("close", () => void stop());
8982
+ return { info, closed, stop };
8983
+ }
8984
+
8985
+ // src/lib/browser/install.ts
8986
+ import { execFile as execFile3 } from "child_process";
8987
+ import { existsSync as existsSync2 } from "fs";
8988
+ import { chmod as chmod3, mkdir as mkdir10, readFile as readFile19, rm as rm7, writeFile as writeFile9 } from "fs/promises";
8989
+ import os7 from "os";
8990
+ import path26 from "path";
8991
+ import { fileURLToPath } from "url";
8992
+ import { promisify as promisify2 } from "util";
8993
+ var run = promisify2(execFile3);
8994
+ function buildHostFiles(input) {
8995
+ const { platform, nodePath, entry, extensionId, launcherPath } = input;
8996
+ const manifest = {
8997
+ name: HOST_NAME,
8998
+ description: "AI Draftsman browser bridge",
8999
+ path: launcherPath,
9000
+ type: "stdio",
9001
+ allowed_origins: [`chrome-extension://${extensionId}/`]
9002
+ };
9003
+ const launcher = platform === "win32" ? `@echo off\r
9004
+ "${nodePath}" "${entry}" browser host %*\r
9005
+ ` : `#!/bin/sh
9006
+ exec "${nodePath}" "${entry}" browser host "$@"
9007
+ `;
9008
+ return { manifest, launcher };
9009
+ }
9010
+ function findExtensionDir(from = path26.dirname(fileURLToPath(import.meta.url))) {
9011
+ let dir = from;
9012
+ for (let i = 0; i < 6; i++) {
9013
+ const candidate = path26.join(dir, "extension", "chrome");
9014
+ if (existsSync2(path26.join(candidate, "manifest.json"))) return candidate;
9015
+ dir = path26.dirname(dir);
9016
+ }
9017
+ throw new Error("Could not find the extension folder (extension/chrome).");
9018
+ }
9019
+ async function readExtensionId(extensionDir) {
9020
+ return (await readFile19(path26.join(extensionDir, "EXTENSION_ID"), "utf8")).trim();
9021
+ }
9022
+ var REGISTRY_ROOTS = ["HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts", "HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts"];
9023
+ function manifestDirs(platform) {
9024
+ const home = os7.homedir();
9025
+ if (platform === "darwin") {
9026
+ return [
9027
+ path26.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts"),
9028
+ path26.join(home, "Library", "Application Support", "Microsoft Edge", "NativeMessagingHosts")
9029
+ ];
9030
+ }
9031
+ return [path26.join(home, ".config", "google-chrome", "NativeMessagingHosts"), path26.join(home, ".config", "microsoft-edge", "NativeMessagingHosts")];
9032
+ }
9033
+ async function installHost(options = {}) {
9034
+ const platform = process.platform;
9035
+ const extensionDir = findExtensionDir();
9036
+ const extensionId = await readExtensionId(extensionDir);
9037
+ const entry = options.entry ?? process.argv[1];
9038
+ if (!entry || !/\.(m?js|cjs)$/.test(entry)) {
9039
+ throw new Error("Run `ad browser install` from the installed CLI (or `npm run build` first); the launcher needs a built entry file.");
9040
+ }
9041
+ const dir = path26.join(getConfigDir(), "browser");
9042
+ await mkdir10(dir, { recursive: true, mode: 448 });
9043
+ const launcherPath = path26.join(dir, platform === "win32" ? "host.cmd" : "host.sh");
9044
+ const manifestPath = path26.join(dir, `${HOST_NAME}.json`);
9045
+ const { manifest, launcher } = buildHostFiles({ platform, nodePath: process.execPath, entry: path26.resolve(entry), extensionId, launcherPath });
9046
+ await writeFile9(launcherPath, launcher, "utf8");
9047
+ if (platform !== "win32") await chmod3(launcherPath, 493);
9048
+ await writeFile9(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
9049
+ if (platform === "win32") {
9050
+ for (const root of REGISTRY_ROOTS) {
9051
+ await run("reg", ["add", `${root}\\${HOST_NAME}`, "/ve", "/t", "REG_SZ", "/d", manifestPath, "/f"]);
9052
+ }
9053
+ } else {
9054
+ for (const target of manifestDirs(platform)) {
9055
+ await mkdir10(target, { recursive: true });
9056
+ await writeFile9(path26.join(target, `${HOST_NAME}.json`), JSON.stringify(manifest, null, 2), "utf8");
9057
+ }
9058
+ }
9059
+ return { manifestPath, launcherPath, extensionDir, extensionId };
9060
+ }
9061
+ async function uninstallHost() {
9062
+ if (process.platform === "win32") {
9063
+ for (const root of REGISTRY_ROOTS) await run("reg", ["delete", `${root}\\${HOST_NAME}`, "/f"]).catch(() => void 0);
9064
+ } else {
9065
+ for (const target of manifestDirs(process.platform)) await rm7(path26.join(target, `${HOST_NAME}.json`), { force: true });
9066
+ }
9067
+ await rm7(path26.join(getConfigDir(), "browser"), { recursive: true, force: true });
9068
+ }
9069
+
9070
+ // src/commands/browser/index.ts
9071
+ function registerBrowserCommands(program) {
9072
+ const browser = program.command("browser").description("AI Draftsman for Chrome: let the CLI and desktop app use your browser");
9073
+ browser.command("install").description("Register the browser bridge with Chrome and Edge, and show how to load the extension").action(async () => {
9074
+ const result = await installHost();
9075
+ process.stdout.write(
9076
+ [
9077
+ "Browser bridge registered.",
9078
+ ` host manifest: ${result.manifestPath}`,
9079
+ ` extension id: ${result.extensionId}`,
9080
+ "",
9081
+ "Now load the extension (once):",
9082
+ " 1. Open chrome://extensions and turn on Developer mode.",
9083
+ ' 2. Click "Load unpacked" and choose:',
9084
+ ` ${result.extensionDir}`,
9085
+ ' 3. Click the AI Draftsman icon to open the side panel; it should say "Connected".',
9086
+ "",
9087
+ "Check with: ad browser status",
9088
+ ""
9089
+ ].join("\n")
9090
+ );
9091
+ });
9092
+ browser.command("uninstall").description("Remove the browser bridge registration").action(async () => {
9093
+ await uninstallHost();
9094
+ process.stdout.write("Browser bridge removed. Remove the extension in chrome://extensions if you no longer want it.\n");
9095
+ });
9096
+ browser.command("status").description("Show whether Chrome and the extension are connected").action(async () => {
9097
+ try {
9098
+ const status = await browserCall("status");
9099
+ process.stdout.write(
9100
+ status.connected ? `Connected: AI Draftsman for Chrome ${status.extensionVersion ?? ""}
9101
+ ` : "The bridge is running but the extension has not connected. Open Chrome with the extension loaded.\n"
9102
+ );
9103
+ } catch (error) {
9104
+ process.stdout.write(`${error.message}
9105
+ `);
9106
+ process.exitCode = 1;
9107
+ }
9108
+ });
9109
+ browser.command("call").description("Run one browser action by hand (for testing), e.g. ad browser call read_page").argument("<action>", BROWSER_ACTIONS.join(" | ")).argument("[json]", "Arguments as a JSON object", "{}").action(async (action, json) => {
9110
+ if (!isBrowserAction(action)) throw new Error(`Unknown action "${action}". Use one of: ${BROWSER_ACTIONS.join(", ")}.`);
9111
+ const result = await browserCall(action, JSON.parse(json));
9112
+ process.stdout.write(`${typeof result === "string" ? result : JSON.stringify(result, null, 2)}
9113
+ `);
9114
+ });
9115
+ browser.command("host", { hidden: true }).allowUnknownOption().allowExcessArguments().action(async () => {
9116
+ const host = await startHost({ stdin: process.stdin, stdout: process.stdout });
9117
+ await host.closed;
9118
+ process.exit(0);
9119
+ });
9120
+ }
9121
+
6431
9122
  // src/lib/output/stream.ts
6432
9123
  function printStreamText(text2) {
6433
9124
  process.stdout.write(text2);
@@ -6816,6 +9507,7 @@ function buildCli() {
6816
9507
  registerConnectorsCommands(program);
6817
9508
  registerMcpCommands(program);
6818
9509
  registerSessionCommands(program);
9510
+ registerBrowserCommands(program);
6819
9511
  return program;
6820
9512
  }
6821
9513