@consultancy-outfit/ai-draftsman-cli 2.0.0 → 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/README.md +75 -1
- package/dist/index.js +2945 -251
- package/extension/chrome/EXTENSION_ID +1 -0
- package/extension/chrome/background.js +315 -0
- package/extension/chrome/icons/128.png +0 -0
- package/extension/chrome/icons/16.png +0 -0
- package/extension/chrome/icons/48.png +0 -0
- package/extension/chrome/logo.svg +13 -0
- package/extension/chrome/manifest.json +34 -0
- package/extension/chrome/sidepanel.html +104 -0
- package/extension/chrome/sidepanel.js +157 -0
- package/package.json +2 -1
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.
|
|
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
|
|
176
|
-
if (
|
|
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(
|
|
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 +
|
|
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
|
|
776
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
1278
|
-
current = path6.resolve(path6.dirname(current),
|
|
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
|
-
|
|
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
|
|
1860
|
-
if (!["info", "success", "warning", "danger"].includes(
|
|
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
|
|
1994
|
-
return `<aside class="callout ${
|
|
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/
|
|
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,
|
|
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
|
|
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
|
|
2686
|
-
import
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) ||
|
|
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,
|
|
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(
|
|
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,
|
|
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) =>
|
|
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 ??
|
|
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
|
|
3492
|
-
history.push(...await Promise.all(batch.map((call2) => call2.function.name === "task" ? subagentSlot(() =>
|
|
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
|
-
|
|
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/
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
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
|
|
3569
|
-
|
|
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
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
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
|
|
3641
|
-
const
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
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
|
|
3647
|
-
|
|
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
|
-
|
|
4034
|
+
return await realpath(abs);
|
|
3650
4035
|
} catch {
|
|
3651
|
-
return [];
|
|
3652
4036
|
}
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3685
|
-
|
|
3686
|
-
return
|
|
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:
|
|
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${
|
|
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
|
|
4440
|
-
import
|
|
4441
|
-
import
|
|
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) ?
|
|
4453
|
-
return
|
|
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[
|
|
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
|
|
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
|
|
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
|
|
4502
|
-
import
|
|
4503
|
-
import
|
|
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("~") ?
|
|
4517
|
-
const dir =
|
|
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
|
|
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
|
|
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";
|
|
@@ -4903,9 +6755,26 @@ function App(props) {
|
|
|
4903
6755
|
const connectorsRef = useRef2([]);
|
|
4904
6756
|
const [inputHistory, setInputHistory] = useState4([]);
|
|
4905
6757
|
const [customCommands, setCustomCommands] = useState4([]);
|
|
4906
|
-
const historyRef = useRef2(props.resume?.messages ?? []);
|
|
4907
6758
|
const sessionRef = useRef2(props.resume ?? newSession(cwd, agent));
|
|
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,
|
|
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(
|
|
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
|
|
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();
|
|
@@ -5209,9 +7253,11 @@ ${output.slice(0, 8e3)}`;
|
|
|
5209
7253
|
[info]
|
|
5210
7254
|
);
|
|
5211
7255
|
const clearConversation = useCallback(() => {
|
|
5212
|
-
historyRef.current = [];
|
|
5213
7256
|
readFilesRef.current.clear();
|
|
7257
|
+
checkpointsRef.current.clear();
|
|
7258
|
+
closeSide();
|
|
5214
7259
|
sessionRef.current = newSession(cwd, agentRef.current);
|
|
7260
|
+
historyRef.current = sessionRef.current.messages;
|
|
5215
7261
|
artifactsRef.current = false;
|
|
5216
7262
|
pendingContext.current = "";
|
|
5217
7263
|
warnedContext.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");
|
|
@@ -5247,11 +7293,14 @@ ${output.slice(0, 8e3)}`;
|
|
|
5247
7293
|
},
|
|
5248
7294
|
{ signal: ac.signal }
|
|
5249
7295
|
);
|
|
5250
|
-
historyRef.current
|
|
7296
|
+
historyRef.current.splice(
|
|
7297
|
+
0,
|
|
7298
|
+
historyRef.current.length,
|
|
5251
7299
|
{ role: "user", content: "Our conversation so far was compacted into this summary." },
|
|
5252
7300
|
{ role: "assistant", content: summary.content }
|
|
5253
|
-
|
|
7301
|
+
);
|
|
5254
7302
|
warnedContext.current = false;
|
|
7303
|
+
checkpointsRef.current.clear();
|
|
5255
7304
|
setContextTokens(0);
|
|
5256
7305
|
info("Conversation compacted.", "success");
|
|
5257
7306
|
} catch (error) {
|
|
@@ -5262,6 +7311,45 @@ ${output.slice(0, 8e3)}`;
|
|
|
5262
7311
|
},
|
|
5263
7312
|
[config2, cwd, finishTurn, info, showError]
|
|
5264
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
|
+
);
|
|
5265
7353
|
const ctx = {
|
|
5266
7354
|
info,
|
|
5267
7355
|
error: showError,
|
|
@@ -5296,6 +7384,132 @@ ${output.slice(0, 8e3)}`;
|
|
|
5296
7384
|
},
|
|
5297
7385
|
compact: (instructions) => void compact(instructions),
|
|
5298
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
|
+
},
|
|
5299
7513
|
allCommands: () => commands,
|
|
5300
7514
|
describeStatus: () => [
|
|
5301
7515
|
`AI Draftsman v${VERSION}`,
|
|
@@ -5380,10 +7594,10 @@ ${output.slice(0, 8e3)}`;
|
|
|
5380
7594
|
if (!note) return;
|
|
5381
7595
|
push({ kind: "user", text: sub.display });
|
|
5382
7596
|
try {
|
|
5383
|
-
const file =
|
|
7597
|
+
const file = path21.join(cwd, "AD.md");
|
|
5384
7598
|
let prefix = "";
|
|
5385
7599
|
try {
|
|
5386
|
-
prefix = (await
|
|
7600
|
+
prefix = (await readFile15(file, "utf8")).endsWith("\n") ? "" : "\n";
|
|
5387
7601
|
} catch {
|
|
5388
7602
|
}
|
|
5389
7603
|
await appendFile(file, `${prefix}- ${note}
|
|
@@ -5394,9 +7608,15 @@ ${output.slice(0, 8e3)}`;
|
|
|
5394
7608
|
}
|
|
5395
7609
|
return;
|
|
5396
7610
|
}
|
|
7611
|
+
if (routesToSide(text2)) return sendToSide(text2);
|
|
7612
|
+
if (focusRef.current === "side") setFocus("main");
|
|
5397
7613
|
void runPrompt(text2, sub.display);
|
|
5398
7614
|
};
|
|
5399
7615
|
const handleSubmit = (sub) => {
|
|
7616
|
+
if (routesToSide(sub.text) || runsImmediately(sub.text)) {
|
|
7617
|
+
void processSubmission(sub);
|
|
7618
|
+
return;
|
|
7619
|
+
}
|
|
5400
7620
|
if (runningRef.current) {
|
|
5401
7621
|
queueRef.current.push(sub);
|
|
5402
7622
|
setQueued(queueRef.current.length);
|
|
@@ -5429,6 +7649,10 @@ ${output.slice(0, 8e3)}`;
|
|
|
5429
7649
|
exitTimer.current = setTimeout(() => setExitHint(false), 2e3);
|
|
5430
7650
|
return;
|
|
5431
7651
|
}
|
|
7652
|
+
if (key.ctrl && input === "o") {
|
|
7653
|
+
if (sideWorker()) setFocus(focusRef.current === "side" ? "main" : "side");
|
|
7654
|
+
return;
|
|
7655
|
+
}
|
|
5432
7656
|
if (key.ctrl && input === "d" && !runningRef.current) {
|
|
5433
7657
|
result.action = "exit";
|
|
5434
7658
|
exit();
|
|
@@ -5447,6 +7671,11 @@ ${output.slice(0, 8e3)}`;
|
|
|
5447
7671
|
{ isActive: dialog === null || dialog.type !== "ask" }
|
|
5448
7672
|
);
|
|
5449
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;
|
|
5450
7679
|
const agentItems = [
|
|
5451
7680
|
{ value: "__auto__", label: AUTO_AGENT.name, description: "let the gateway choose the model", hint: agent.model === AUTO_AGENT.model ? "(current)" : void 0 },
|
|
5452
7681
|
...agents.map((a) => ({
|
|
@@ -5459,47 +7688,52 @@ ${output.slice(0, 8e3)}`;
|
|
|
5459
7688
|
const renderItem = (item) => {
|
|
5460
7689
|
switch (item.kind) {
|
|
5461
7690
|
case "header":
|
|
5462
|
-
return /* @__PURE__ */
|
|
7691
|
+
return /* @__PURE__ */ jsx8(Header, { agent: item.agent, account: item.account, cwd: item.cwd, notes: item.notes });
|
|
5463
7692
|
case "user":
|
|
5464
|
-
return /* @__PURE__ */
|
|
7693
|
+
return /* @__PURE__ */ jsx8(UserMessage, { text: item.text });
|
|
5465
7694
|
case "assistant":
|
|
5466
|
-
return /* @__PURE__ */
|
|
7695
|
+
return /* @__PURE__ */ jsx8(AssistantMessage, { text: item.text });
|
|
5467
7696
|
case "tool":
|
|
5468
|
-
return /* @__PURE__ */
|
|
7697
|
+
return /* @__PURE__ */ jsx8(ToolView, { item });
|
|
5469
7698
|
case "shell":
|
|
5470
|
-
return /* @__PURE__ */
|
|
7699
|
+
return /* @__PURE__ */ jsx8(ShellView, { item });
|
|
5471
7700
|
case "info":
|
|
5472
|
-
return /* @__PURE__ */
|
|
7701
|
+
return /* @__PURE__ */ jsx8(InfoLine, { text: item.text, tone: item.tone });
|
|
5473
7702
|
case "error":
|
|
5474
|
-
return /* @__PURE__ */
|
|
7703
|
+
return /* @__PURE__ */ jsx8(ErrorLine, { text: item.text });
|
|
5475
7704
|
}
|
|
5476
7705
|
};
|
|
5477
|
-
return /* @__PURE__ */
|
|
5478
|
-
/* @__PURE__ */
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
/* @__PURE__ */
|
|
5483
|
-
|
|
5484
|
-
|
|
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
|
|
5485
7732
|
] }),
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
] }, t.id)),
|
|
5491
|
-
/* @__PURE__ */ jsx7(TodoList, { todos }),
|
|
5492
|
-
phase === "running" && dialog === null ? /* @__PURE__ */ jsx7(
|
|
5493
|
-
WorkingIndicator,
|
|
5494
|
-
{
|
|
5495
|
-
tokens: turnTokens,
|
|
5496
|
-
label: workingLabel ?? statusText,
|
|
5497
|
-
waiting: liveText || runningTools.length ? void 0 : waiting,
|
|
5498
|
-
fallback: activityLabel(runningTools, liveText.length > 0)
|
|
5499
|
-
}
|
|
5500
|
-
) : null,
|
|
5501
|
-
dialog?.type === "ask" ? /* @__PURE__ */ jsx7(AskDialog, { request: dialog.request, onAnswer: answerAsk }) : null,
|
|
5502
|
-
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(
|
|
5503
7737
|
Picker,
|
|
5504
7738
|
{
|
|
5505
7739
|
title: dialog.first ? "Choose an agent to work with" : "Switch agent",
|
|
@@ -5528,7 +7762,64 @@ ${output.slice(0, 8e3)}`;
|
|
|
5528
7762
|
}
|
|
5529
7763
|
}
|
|
5530
7764
|
) }) : null,
|
|
5531
|
-
dialog?.type === "
|
|
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(
|
|
5532
7823
|
Picker,
|
|
5533
7824
|
{
|
|
5534
7825
|
title: "Resume a conversation",
|
|
@@ -5549,6 +7840,8 @@ ${output.slice(0, 8e3)}`;
|
|
|
5549
7840
|
agentRef.current = chosen.agent;
|
|
5550
7841
|
setAgent(chosen.agent);
|
|
5551
7842
|
readFilesRef.current.clear();
|
|
7843
|
+
checkpointsRef.current.clear();
|
|
7844
|
+
adoptSavedCheckpoints(chosen.id, chosen.messages.length);
|
|
5552
7845
|
stdout.write(CLEAR_SCREEN);
|
|
5553
7846
|
setItems([
|
|
5554
7847
|
headerItem(chosen.agent),
|
|
@@ -5559,8 +7852,8 @@ ${output.slice(0, 8e3)}`;
|
|
|
5559
7852
|
}
|
|
5560
7853
|
}
|
|
5561
7854
|
) }) : null,
|
|
5562
|
-
dialog === null ? /* @__PURE__ */
|
|
5563
|
-
/* @__PURE__ */
|
|
7855
|
+
dialog === null ? /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", marginTop: 1, children: [
|
|
7856
|
+
/* @__PURE__ */ jsx8(
|
|
5564
7857
|
PromptBox,
|
|
5565
7858
|
{
|
|
5566
7859
|
focus: true,
|
|
@@ -5575,8 +7868,8 @@ ${output.slice(0, 8e3)}`;
|
|
|
5575
7868
|
onToggleHelp: () => setShowHelp((v) => !v)
|
|
5576
7869
|
}
|
|
5577
7870
|
),
|
|
5578
|
-
showHelp ? /* @__PURE__ */
|
|
5579
|
-
/* @__PURE__ */
|
|
7871
|
+
showHelp ? /* @__PURE__ */ jsx8(ShortcutsPanel, {}) : null,
|
|
7872
|
+
/* @__PURE__ */ jsx8(
|
|
5580
7873
|
StatusLine,
|
|
5581
7874
|
{
|
|
5582
7875
|
mode,
|
|
@@ -5585,7 +7878,9 @@ ${output.slice(0, 8e3)}`;
|
|
|
5585
7878
|
contextTokens,
|
|
5586
7879
|
exitHint,
|
|
5587
7880
|
queued,
|
|
5588
|
-
showHelpHint: !showHelp
|
|
7881
|
+
showHelpHint: !showHelp,
|
|
7882
|
+
workers: boardSummary(orchestrator.list()),
|
|
7883
|
+
typingTo: sideShown && focus === "side" ? "side" : void 0
|
|
5589
7884
|
}
|
|
5590
7885
|
)
|
|
5591
7886
|
] }) : null
|
|
@@ -5593,7 +7888,7 @@ ${output.slice(0, 8e3)}`;
|
|
|
5593
7888
|
}
|
|
5594
7889
|
|
|
5595
7890
|
// src/tui/run.tsx
|
|
5596
|
-
import { jsx as
|
|
7891
|
+
import { jsx as jsx9 } from "react/jsx-runtime";
|
|
5597
7892
|
async function runTui(options) {
|
|
5598
7893
|
const { config: config2 } = options;
|
|
5599
7894
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -5602,7 +7897,7 @@ async function runTui(options) {
|
|
|
5602
7897
|
const agent = options.resume && !options.agent ? options.resume.agent : wanted ? findAgent(gateway.agents, wanted) : void 0;
|
|
5603
7898
|
const result = { action: "exit" };
|
|
5604
7899
|
const app = render(
|
|
5605
|
-
/* @__PURE__ */
|
|
7900
|
+
/* @__PURE__ */ jsx9(
|
|
5606
7901
|
App,
|
|
5607
7902
|
{
|
|
5608
7903
|
config: config2,
|
|
@@ -5626,13 +7921,13 @@ async function runTui(options) {
|
|
|
5626
7921
|
}
|
|
5627
7922
|
|
|
5628
7923
|
// src/lib/update-check.ts
|
|
5629
|
-
import { mkdir as
|
|
5630
|
-
import
|
|
7924
|
+
import { mkdir as mkdir7, readFile as readFile16, writeFile as writeFile8 } from "fs/promises";
|
|
7925
|
+
import path22 from "path";
|
|
5631
7926
|
var PACKAGE = "@consultancy-outfit/ai-draftsman-cli";
|
|
5632
7927
|
var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE.replace("/", "%2f")}/latest`;
|
|
5633
7928
|
var CHECK_EVERY_MS = 24 * 60 * 60 * 1e3;
|
|
5634
7929
|
var TIMEOUT_MS2 = 3e3;
|
|
5635
|
-
var cachePath2 = () =>
|
|
7930
|
+
var cachePath2 = () => path22.join(getConfigDir(), "update-check.json");
|
|
5636
7931
|
function compareVersions(a, b) {
|
|
5637
7932
|
const parse = (v) => {
|
|
5638
7933
|
const [core = "", pre] = v.trim().replace(/^v/, "").split("-", 2);
|
|
@@ -5650,7 +7945,7 @@ function compareVersions(a, b) {
|
|
|
5650
7945
|
}
|
|
5651
7946
|
async function readUpdateCache() {
|
|
5652
7947
|
try {
|
|
5653
|
-
const file = JSON.parse(await
|
|
7948
|
+
const file = JSON.parse(await readFile16(cachePath2(), "utf8"));
|
|
5654
7949
|
return Number.isFinite(file.checkedAt) ? file : void 0;
|
|
5655
7950
|
} catch {
|
|
5656
7951
|
return void 0;
|
|
@@ -5667,8 +7962,8 @@ async function refreshUpdateCache(fetchImpl = fetch) {
|
|
|
5667
7962
|
} catch {
|
|
5668
7963
|
}
|
|
5669
7964
|
try {
|
|
5670
|
-
await
|
|
5671
|
-
await
|
|
7965
|
+
await mkdir7(getConfigDir(), { recursive: true });
|
|
7966
|
+
await writeFile8(cachePath2(), JSON.stringify(entry), "utf8");
|
|
5672
7967
|
} catch {
|
|
5673
7968
|
}
|
|
5674
7969
|
return entry;
|
|
@@ -5787,8 +8082,8 @@ async function requireAuth() {
|
|
|
5787
8082
|
|
|
5788
8083
|
// src/lib/app/headless.ts
|
|
5789
8084
|
import { spawn as spawn3 } from "child_process";
|
|
5790
|
-
import { readFile as
|
|
5791
|
-
import
|
|
8085
|
+
import { readFile as readFile17 } from "fs/promises";
|
|
8086
|
+
import path23 from "path";
|
|
5792
8087
|
var CLAUDE_NAME = {
|
|
5793
8088
|
bash: "Bash",
|
|
5794
8089
|
read_file: "Read",
|
|
@@ -5816,6 +8111,7 @@ var GRANTS = {
|
|
|
5816
8111
|
create_artifact: ["create_artifact"],
|
|
5817
8112
|
web_fetch: ["web_fetch"],
|
|
5818
8113
|
call_connector: ["call_connector"],
|
|
8114
|
+
browser: ["browser"],
|
|
5819
8115
|
// Claude Code's spellings for the connector server: the whole server, any tool on it, or the one tool.
|
|
5820
8116
|
mcp__aidraftsman: ["call_connector"],
|
|
5821
8117
|
"mcp__aidraftsman__*": ["call_connector"],
|
|
@@ -5847,7 +8143,7 @@ function claudeToolInput(name, rawArguments, cwd) {
|
|
|
5847
8143
|
}
|
|
5848
8144
|
const fileTool = name === "read_file" || name === "write_file" || name === "edit_file" || name === "create_artifact";
|
|
5849
8145
|
if (fileTool && typeof args.path === "string") {
|
|
5850
|
-
args.file_path =
|
|
8146
|
+
args.file_path = path23.resolve(cwd, args.path);
|
|
5851
8147
|
delete args.path;
|
|
5852
8148
|
}
|
|
5853
8149
|
return args;
|
|
@@ -5876,6 +8172,10 @@ function applyAllowedTools(rules, values) {
|
|
|
5876
8172
|
} else if (name === "webfetch" || name === "web_fetch") {
|
|
5877
8173
|
const host = scope.replace(/^domain:/, "").trim();
|
|
5878
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);
|
|
5879
8179
|
} else if (GRANTS[name]) {
|
|
5880
8180
|
for (const tool of GRANTS[name]) rules.grantTool(tool);
|
|
5881
8181
|
} else if (!NO_GRANT_NEEDED.has(name) && !name.startsWith("mcp__")) unknown.push(spec);
|
|
@@ -5891,7 +8191,7 @@ async function loadPreToolUseHooks(value) {
|
|
|
5891
8191
|
let text2 = value.trim();
|
|
5892
8192
|
if (!text2.startsWith("{")) {
|
|
5893
8193
|
try {
|
|
5894
|
-
text2 = await
|
|
8194
|
+
text2 = await readFile17(value, "utf8");
|
|
5895
8195
|
} catch (error) {
|
|
5896
8196
|
throw new CliError(`--settings: cannot read ${value} (${error.message}).`, 3);
|
|
5897
8197
|
}
|
|
@@ -6307,14 +8607,14 @@ function extractText(value) {
|
|
|
6307
8607
|
}
|
|
6308
8608
|
|
|
6309
8609
|
// src/lib/util/input.ts
|
|
6310
|
-
import { readFile as
|
|
8610
|
+
import { readFile as readFile18 } from "fs/promises";
|
|
6311
8611
|
import process2 from "process";
|
|
6312
8612
|
async function resolveTextInput(options) {
|
|
6313
8613
|
if (options.message) {
|
|
6314
8614
|
return options.message;
|
|
6315
8615
|
}
|
|
6316
8616
|
if (options.file) {
|
|
6317
|
-
return
|
|
8617
|
+
return readFile18(options.file, "utf8");
|
|
6318
8618
|
}
|
|
6319
8619
|
if (!process2.stdin.isTTY) {
|
|
6320
8620
|
const chunks = [];
|
|
@@ -6426,6 +8726,399 @@ function registerAuthCommands(program) {
|
|
|
6426
8726
|
});
|
|
6427
8727
|
}
|
|
6428
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
|
+
|
|
6429
9122
|
// src/lib/output/stream.ts
|
|
6430
9123
|
function printStreamText(text2) {
|
|
6431
9124
|
process.stdout.write(text2);
|
|
@@ -6814,6 +9507,7 @@ function buildCli() {
|
|
|
6814
9507
|
registerConnectorsCommands(program);
|
|
6815
9508
|
registerMcpCommands(program);
|
|
6816
9509
|
registerSessionCommands(program);
|
|
9510
|
+
registerBrowserCommands(program);
|
|
6817
9511
|
return program;
|
|
6818
9512
|
}
|
|
6819
9513
|
|