basis-design 0.0.0-stage → 0.1.1
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/LICENSE +177 -0
- package/NOTICE +7 -0
- package/README.md +136 -3
- package/bin/basis.js +6 -0
- package/index.html +16 -0
- package/package.json +68 -4
- package/preview.html +12 -0
- package/server/agent.ts +866 -0
- package/server/brief.ts +373 -0
- package/server/chatlog.ts +57 -0
- package/server/claude.ts +72 -0
- package/server/exec.ts +77 -0
- package/server/export.ts +180 -0
- package/server/guidelines.md +379 -0
- package/server/history.ts +107 -0
- package/server/index.ts +434 -0
- package/server/paths.ts +57 -0
- package/server/previews.ts +71 -0
- package/server/project.ts +153 -0
- package/server/projects.ts +213 -0
- package/server/reach.ts +192 -0
- package/server/rebase.ts +46 -0
- package/server/reviews.ts +156 -0
- package/server/setup.ts +201 -0
- package/server/snapshots.ts +67 -0
- package/server/template.ts +168 -0
- package/server/workspace.ts +441 -0
- package/shared/annotations.ts +43 -0
- package/shared/contracts.ts +190 -0
- package/shared/contrast.ts +77 -0
- package/shared/design.ts +556 -0
- package/shared/diff.ts +26 -0
- package/shared/fixed-colors.ts +91 -0
- package/shared/frame.ts +223 -0
- package/shared/pattern-contracts.ts +229 -0
- package/shared/protocol.ts +241 -0
- package/shared/review.ts +145 -0
- package/shared/tokens.ts +102 -0
- package/src/App.tsx +22 -0
- package/src/Workbench.tsx +343 -0
- package/src/app.css +103 -0
- package/src/bridge.ts +163 -0
- package/src/chat/ChatPanel.module.css +759 -0
- package/src/chat/ChatPanel.tsx +335 -0
- package/src/chat/Composer.tsx +290 -0
- package/src/chat/Markdown.tsx +67 -0
- package/src/chat/images.ts +46 -0
- package/src/icon.svg +1 -0
- package/src/main.tsx +10 -0
- package/src/preview/Foundations.module.css +346 -0
- package/src/preview/Foundations.tsx +389 -0
- package/src/preview/FrameBar.tsx +74 -0
- package/src/preview/Gallery.module.css +232 -0
- package/src/preview/Gallery.tsx +163 -0
- package/src/preview/Impact.tsx +217 -0
- package/src/preview/Preview.module.css +103 -0
- package/src/preview/Preview.tsx +167 -0
- package/src/preview/Review.module.css +192 -0
- package/src/preview/Review.tsx +456 -0
- package/src/preview/SectionBoundary.tsx +70 -0
- package/src/preview/a11y.ts +123 -0
- package/src/preview/color.ts +53 -0
- package/src/preview/ds-module.d.ts +2 -0
- package/src/preview/ds.ts +33 -0
- package/src/preview/errors.ts +83 -0
- package/src/preview/main.tsx +283 -0
- package/src/preview/narrow.ts +95 -0
- package/src/preview/picker/PickLayer.module.css +90 -0
- package/src/preview/picker/PickLayer.tsx +151 -0
- package/src/preview/picker/annotate.ts +93 -0
- package/src/preview/preview.css +102 -0
- package/src/preview/progressive.ts +62 -0
- package/src/preview/specimens.tsx +379 -0
- package/src/shell/ChatResizer.tsx +69 -0
- package/src/shell/CreateDialog.tsx +132 -0
- package/src/shell/ExportDialog.tsx +232 -0
- package/src/shell/HistoryMenu.tsx +95 -0
- package/src/shell/InspectBanner.tsx +147 -0
- package/src/shell/PreviewFrame.tsx +101 -0
- package/src/shell/ProjectMenu.tsx +399 -0
- package/src/shell/ReviewBar.tsx +224 -0
- package/src/shell/Shell.module.css +1076 -0
- package/src/shell/Sidebar.module.css +211 -0
- package/src/shell/Sidebar.tsx +80 -0
- package/src/shell/TopBar.tsx +83 -0
- package/src/shell/history.ts +64 -0
- package/src/shell/navigation.tsx +150 -0
- package/src/shell/prefs.ts +81 -0
- package/src/shell/useReview.ts +114 -0
- package/template/CHANGES.md +25 -0
- package/template/LICENSE +16 -0
- package/template/components/Button.module.css +151 -0
- package/template/components/Button.specimen.tsx +74 -0
- package/template/components/Button.tsx +41 -0
- package/template/components/Controls.module.css +174 -0
- package/template/components/Controls.specimen.tsx +54 -0
- package/template/components/Controls.tsx +58 -0
- package/template/components/Display.module.css +251 -0
- package/template/components/Display.specimen.tsx +261 -0
- package/template/components/Display.tsx +126 -0
- package/template/components/Field.module.css +119 -0
- package/template/components/Field.specimen.tsx +138 -0
- package/template/components/Field.tsx +100 -0
- package/template/components/Overlay.module.css +237 -0
- package/template/components/Overlay.specimen.tsx +60 -0
- package/template/components/Overlay.tsx +69 -0
- package/template/components/Tabs.module.css +140 -0
- package/template/components/Tabs.specimen.tsx +51 -0
- package/template/components/Tabs.tsx +117 -0
- package/template/components/contracts.ts +24 -0
- package/template/components/index.ts +7 -0
- package/template/components/specimen.ts +88 -0
- package/template/components/util.ts +6 -0
- package/template/contract-check.ts +22 -0
- package/template/history.json +122 -0
- package/template/patterns/AppShell.module.css +36 -0
- package/template/patterns/AppShell.specimen.tsx +114 -0
- package/template/patterns/AppShell.tsx +25 -0
- package/template/patterns/Chat.module.css +437 -0
- package/template/patterns/Chat.specimen.tsx +298 -0
- package/template/patterns/Chat.tsx +236 -0
- package/template/patterns/Composer.module.css +184 -0
- package/template/patterns/Composer.specimen.tsx +106 -0
- package/template/patterns/Composer.tsx +183 -0
- package/template/patterns/DataTable.module.css +190 -0
- package/template/patterns/DataTable.specimen.tsx +189 -0
- package/template/patterns/DataTable.tsx +241 -0
- package/template/patterns/EmptyState.module.css +57 -0
- package/template/patterns/EmptyState.specimen.tsx +71 -0
- package/template/patterns/EmptyState.tsx +36 -0
- package/template/patterns/FormSection.module.css +69 -0
- package/template/patterns/FormSection.specimen.tsx +75 -0
- package/template/patterns/FormSection.tsx +40 -0
- package/template/patterns/Navbar.module.css +291 -0
- package/template/patterns/Navbar.specimen.tsx +118 -0
- package/template/patterns/Navbar.tsx +148 -0
- package/template/patterns/PageHeader.module.css +98 -0
- package/template/patterns/PageHeader.specimen.tsx +75 -0
- package/template/patterns/PageHeader.tsx +63 -0
- package/template/patterns/SidebarNav.module.css +186 -0
- package/template/patterns/SidebarNav.specimen.tsx +98 -0
- package/template/patterns/SidebarNav.tsx +81 -0
- package/template/patterns/StatCard.module.css +81 -0
- package/template/patterns/StatCard.specimen.tsx +61 -0
- package/template/patterns/StatCard.tsx +83 -0
- package/template/patterns/contracts.ts +25 -0
- package/template/patterns/icons.tsx +68 -0
- package/template/patterns/index.ts +12 -0
- package/template/patterns/specimen-data.ts +112 -0
- package/template/views/TeamMembers.specimen.tsx +147 -0
- package/vite.config.ts +9 -0
package/server/agent.ts
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { Readable, Writable } from "node:stream";
|
|
7
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
8
|
+
import { annotationLabel, formatAnnotations, type Annotation } from "../shared/annotations.ts";
|
|
9
|
+
import {
|
|
10
|
+
IMAGE_TYPES,
|
|
11
|
+
MAX_IMAGE_BASE64,
|
|
12
|
+
MAX_IMAGES,
|
|
13
|
+
type AgentId,
|
|
14
|
+
type AgentState,
|
|
15
|
+
type ChatItem,
|
|
16
|
+
type PermissionChoice,
|
|
17
|
+
type PromptImage,
|
|
18
|
+
type ToolStatus,
|
|
19
|
+
} from "../shared/protocol.ts";
|
|
20
|
+
import { ALTERNATIVE_LABELS, describeTarget, MAX_ALTERNATIVES } from "../shared/review.ts";
|
|
21
|
+
import { loadChat, saveChat } from "./chatlog.ts";
|
|
22
|
+
import { PRODUCT_FILE, relativeInside } from "./paths.ts";
|
|
23
|
+
import { AGENT_BRIEF, FIRST_PROMPT_PREAMBLE } from "./brief.ts";
|
|
24
|
+
import { CLAUDE_MISSING, findClaude } from "./claude.ts";
|
|
25
|
+
import type { Workspace } from "./workspace.ts";
|
|
26
|
+
|
|
27
|
+
interface LaunchSpec {
|
|
28
|
+
command: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
shell: boolean;
|
|
31
|
+
loginHint: string;
|
|
32
|
+
/** Variables the agent needs on top of Basis' own environment. */
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
/** Why the agent cannot start, shown instead of starting it. */
|
|
35
|
+
missing?: string;
|
|
36
|
+
/** Added when the agent exits on its own, for the failures an update fixes. */
|
|
37
|
+
exitHint?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The adapters Basis was tested with, at fixed versions: an unpinned one would fetch whatever was published last.
|
|
42
|
+
* npx fetches each on first use and reuses it after (--prefer-offline, so Basis also starts offline).
|
|
43
|
+
*/
|
|
44
|
+
const CLAUDE_ACP = "@agentclientprotocol/claude-agent-acp@0.92.0";
|
|
45
|
+
const CODEX_ACP = "@agentclientprotocol/codex-acp@2.2.2";
|
|
46
|
+
|
|
47
|
+
/** How each supported agent is started. Both reuse the user's own CLI login, so no API keys are needed. */
|
|
48
|
+
function launchSpec(id: AgentId): LaunchSpec {
|
|
49
|
+
// The end-to-end tests run a stand-in agent (test/e2e/fake-agent.mjs), since no CLI is logged in on CI.
|
|
50
|
+
if (process.env.BASIS_AGENT_SCRIPT) {
|
|
51
|
+
const script = path.resolve(process.env.BASIS_AGENT_SCRIPT);
|
|
52
|
+
return { command: process.execPath, args: [script], shell: false, loginHint: "" };
|
|
53
|
+
}
|
|
54
|
+
if (id === "claude") {
|
|
55
|
+
// Without --omit=optional the adapter's SDK brings its own copy of Claude Code; Basis runs the user's instead.
|
|
56
|
+
const claude = findClaude();
|
|
57
|
+
return {
|
|
58
|
+
command: "npx",
|
|
59
|
+
args: ["-y", "--prefer-offline", "--omit=optional", CLAUDE_ACP],
|
|
60
|
+
shell: true,
|
|
61
|
+
env: claude ? { CLAUDE_CODE_EXECUTABLE: claude } : undefined,
|
|
62
|
+
missing: claude ? undefined : CLAUDE_MISSING,
|
|
63
|
+
loginHint: "Run `claude` in a terminal and log in, then restart the agent.",
|
|
64
|
+
exitHint: "If it keeps happening, update Claude Code with `claude update`.",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
command: "npx",
|
|
69
|
+
args: ["-y", "--prefer-offline", CODEX_ACP],
|
|
70
|
+
shell: true,
|
|
71
|
+
loginHint: "Run `codex login` in a terminal, then restart the agent.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* When Basis itself is launched from inside a Claude Code session, the host's
|
|
77
|
+
* session variables (auth proxy, session ids) leak into the agent and break its
|
|
78
|
+
* login. Drop them so the agent authenticates like a fresh CLI.
|
|
79
|
+
*/
|
|
80
|
+
function agentEnv(): NodeJS.ProcessEnv {
|
|
81
|
+
const env: NodeJS.ProcessEnv = {};
|
|
82
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
83
|
+
const host = key === "CLAUDECODE" || key === "ANTHROPIC_BASE_URL" || key === "BAGGAGE" || key.startsWith("CLAUDE_");
|
|
84
|
+
if (process.env.CLAUDECODE && host) continue;
|
|
85
|
+
env[key] = value;
|
|
86
|
+
}
|
|
87
|
+
// Claude Code's auto memory lives outside the workspace and loads whatever SESSION_META says; Basis keeps its own.
|
|
88
|
+
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
|
89
|
+
return env;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Tool kinds that are auto-approved when every touched path is inside the workspace. */
|
|
93
|
+
const SAFE_KINDS = new Set(["read", "edit", "search", "think"]);
|
|
94
|
+
/**
|
|
95
|
+
* Claude Code loads the user's own ~/.claude by default: their CLAUDE.md, skills, hooks and permission rules, and
|
|
96
|
+
* the claude.ai connectors of their login. Rules that allow commands there would run them without Basis asking, and
|
|
97
|
+
* the rest is meant for their coding, not for designing here. So it loads none of them: not even the project
|
|
98
|
+
* source, since it reads CLAUDE.md in every folder above the workspace, and ~/.claude/CLAUDE.md is one of those for
|
|
99
|
+
* a workspace under the home folder. The brief arrives as the system prompt instead. Other agents ignore the key.
|
|
100
|
+
*/
|
|
101
|
+
const SESSION_META = {
|
|
102
|
+
claudeCode: { options: { settingSources: [], strictMcpConfig: true, allowDangerouslySkipPermissions: false } },
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Per agent, the session mode in which it asks before every edit, so edits go through onPermission and Basis'
|
|
106
|
+
* rules for what the agent may change. Codex's read-only mode still runs read-only commands in its sandbox unasked.
|
|
107
|
+
*/
|
|
108
|
+
const ASKING_MODES: Record<AgentId, string> = { claude: "default", codex: "read-only" };
|
|
109
|
+
const MAX_AUTO_FIXES = 2;
|
|
110
|
+
const PREVIEW_SETTLE_MS = 1500;
|
|
111
|
+
/** How long the fix loop waits at most for the preview's checks to finish after it settled. */
|
|
112
|
+
const CHECKS_WAIT_MS = 8000;
|
|
113
|
+
|
|
114
|
+
type Events = { agent: [AgentState]; chat: [ChatItem]; "chat-remove": [string]; "chat-reset": [] };
|
|
115
|
+
type UserItem = Extract<ChatItem, { kind: "user" }>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Runs one ACP agent as a subprocess and acts as its client: streams session
|
|
119
|
+
* updates into a chat transcript, serves file reads/writes inside the
|
|
120
|
+
* workspace and routes permission requests to the browser.
|
|
121
|
+
*/
|
|
122
|
+
export class AgentHost extends EventEmitter<Events> {
|
|
123
|
+
private state: AgentState;
|
|
124
|
+
private transcript: ChatItem[] = [];
|
|
125
|
+
private proc?: ChildProcess;
|
|
126
|
+
private connection?: acp.ClientConnection;
|
|
127
|
+
private sessionId?: string;
|
|
128
|
+
private supportsSystemPrompt = false;
|
|
129
|
+
private supportsResume = false;
|
|
130
|
+
/** The saved session the next start tries to resume, from the conversation saved before a restart. */
|
|
131
|
+
private resumable?: { agent: AgentId; sessionId: string };
|
|
132
|
+
private saveTimer?: NodeJS.Timeout;
|
|
133
|
+
private needsPreamble = false;
|
|
134
|
+
private openText?: { id: string; kind: "agent" | "thought" };
|
|
135
|
+
private permissions = new Map<string, (optionId: string | null) => void>();
|
|
136
|
+
private stderrTail: string[] = [];
|
|
137
|
+
private previewErrors: string[] = [];
|
|
138
|
+
/** Findings of the preview's automatic checks, and the ones there were when the current turn started. */
|
|
139
|
+
private checks: string[] = [];
|
|
140
|
+
private checksBefore = new Set<string>();
|
|
141
|
+
private checking = false;
|
|
142
|
+
private seq = 0;
|
|
143
|
+
/** Prompts sent while a turn was running, oldest first. */
|
|
144
|
+
private queued: { id: string; text: string; attachments: Attachments; alternatives: number }[] = [];
|
|
145
|
+
/** Set by Stop; ends the current set of alternatives and the fix loop, not just the running turn. */
|
|
146
|
+
private stopping = false;
|
|
147
|
+
|
|
148
|
+
constructor(
|
|
149
|
+
private readonly workspace: Workspace,
|
|
150
|
+
agentId: AgentId,
|
|
151
|
+
/** A product repo linked to the project: the agent may read and search it, never write to it. */
|
|
152
|
+
private repo?: string,
|
|
153
|
+
) {
|
|
154
|
+
super();
|
|
155
|
+
this.state = { id: agentId, status: "stopped" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolves once the agent is ready for a prompt (true), or has failed or stopped instead (false). */
|
|
159
|
+
whenReady(): Promise<boolean> {
|
|
160
|
+
if (this.state.status === "ready") return Promise.resolve(true);
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const check = (state: AgentState) => {
|
|
163
|
+
if (state.status === "starting" || state.status === "busy") return;
|
|
164
|
+
this.off("agent", check);
|
|
165
|
+
resolve(state.status === "ready");
|
|
166
|
+
};
|
|
167
|
+
this.on("agent", check);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setRepo(repo: string | undefined): void {
|
|
172
|
+
this.repo = repo;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private inRepo(target: string): boolean {
|
|
176
|
+
return this.repo !== undefined && relativeInside(this.repo, target) !== null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
get agent(): AgentState {
|
|
180
|
+
return this.state;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get chat(): ChatItem[] {
|
|
184
|
+
return this.transcript;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Brings back the conversation saved before the last restart; the next start tries to resume its session. */
|
|
188
|
+
async restore(): Promise<void> {
|
|
189
|
+
const saved = await loadChat(this.workspace.dir);
|
|
190
|
+
if (!saved) return;
|
|
191
|
+
this.transcript = saved.chat;
|
|
192
|
+
if (saved.sessionId) this.resumable = { agent: saved.agent, sessionId: saved.sessionId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async start(agentId: AgentId = this.state.id): Promise<void> {
|
|
196
|
+
await this.stop();
|
|
197
|
+
this.setState({ id: agentId, status: "starting", detail: "Starting agent…" });
|
|
198
|
+
const spec = launchSpec(agentId);
|
|
199
|
+
if (spec.missing) {
|
|
200
|
+
this.fail(spec.missing);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Through a shell, the command goes as one line: Node no longer accepts separate arguments there.
|
|
204
|
+
const [command, args] = spec.shell ? [[spec.command, ...spec.args].join(" "), []] : [spec.command, spec.args];
|
|
205
|
+
const proc = spawn(command, args, {
|
|
206
|
+
cwd: this.workspace.dir,
|
|
207
|
+
shell: spec.shell,
|
|
208
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
209
|
+
env: { ...agentEnv(), ...spec.env },
|
|
210
|
+
windowsHide: true,
|
|
211
|
+
});
|
|
212
|
+
this.proc = proc;
|
|
213
|
+
this.stderrTail = [];
|
|
214
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
215
|
+
this.stderrTail.push(...chunk.toString("utf8").split(/\r?\n/).filter(Boolean));
|
|
216
|
+
this.stderrTail = this.stderrTail.slice(-20);
|
|
217
|
+
});
|
|
218
|
+
proc.on("error", (error) => {
|
|
219
|
+
if (this.proc === proc) this.fail(`Could not start ${agentId}: ${error.message}`);
|
|
220
|
+
});
|
|
221
|
+
proc.on("exit", (code) => {
|
|
222
|
+
if (this.proc !== proc) return;
|
|
223
|
+
this.proc = undefined;
|
|
224
|
+
this.connection = undefined;
|
|
225
|
+
this.sessionId = undefined;
|
|
226
|
+
const tail = this.stderrTail.slice(-3).join(" ");
|
|
227
|
+
const hint = spec.exitHint ? ` ${spec.exitHint}` : "";
|
|
228
|
+
this.fail(`The agent exited (code ${code ?? "?"}).${tail ? ` ${tail}` : ""}${hint}`);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const stream = acp.ndJsonStream(
|
|
232
|
+
Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>,
|
|
233
|
+
Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>,
|
|
234
|
+
);
|
|
235
|
+
const app = acp
|
|
236
|
+
.client({ name: "basis" })
|
|
237
|
+
.onNotification(acp.methods.client.session.update, (ctx) => this.onUpdate(ctx.params))
|
|
238
|
+
.onRequest(acp.methods.client.session.requestPermission, (ctx) => this.onPermission(ctx.params))
|
|
239
|
+
.onRequest(acp.methods.client.fs.readTextFile, (ctx) => this.readTextFile(ctx.params))
|
|
240
|
+
.onRequest(acp.methods.client.fs.writeTextFile, (ctx) => this.writeTextFile(ctx.params));
|
|
241
|
+
const connection = app.connect(stream);
|
|
242
|
+
this.connection = connection;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const init = await connection.agent.request(acp.methods.agent.initialize, {
|
|
246
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
247
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
|
|
248
|
+
clientInfo: { name: "basis", version: "0.1.0" },
|
|
249
|
+
});
|
|
250
|
+
this.supportsSystemPrompt = Boolean(init.agentCapabilities?.sessionCapabilities?.systemPrompt);
|
|
251
|
+
this.supportsResume = Boolean(init.agentCapabilities?.sessionCapabilities?.resume);
|
|
252
|
+
const supportsImages = Boolean(init.agentCapabilities?.promptCapabilities?.image);
|
|
253
|
+
await this.openSession(true);
|
|
254
|
+
this.setState({ id: agentId, status: "ready", supportsImages });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (this.proc === proc) this.fail(describeError(error, spec.loginHint));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async stop(): Promise<void> {
|
|
261
|
+
this.cancelPermissions();
|
|
262
|
+
const proc = this.proc;
|
|
263
|
+
this.proc = undefined;
|
|
264
|
+
this.connection?.close();
|
|
265
|
+
this.connection = undefined;
|
|
266
|
+
this.sessionId = undefined;
|
|
267
|
+
if (proc?.pid && proc.exitCode === null) {
|
|
268
|
+
if (process.platform === "win32")
|
|
269
|
+
spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true });
|
|
270
|
+
else proc.kill();
|
|
271
|
+
}
|
|
272
|
+
this.setState({ ...this.state, status: "stopped", detail: undefined });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async newSession(): Promise<void> {
|
|
276
|
+
if (this.state.status === "busy") await this.cancel();
|
|
277
|
+
this.queued = [];
|
|
278
|
+
this.transcript = [];
|
|
279
|
+
this.openText = undefined;
|
|
280
|
+
this.resumable = undefined;
|
|
281
|
+
this.emit("chat-reset");
|
|
282
|
+
this.scheduleSave();
|
|
283
|
+
if (!this.connection) return this.start();
|
|
284
|
+
try {
|
|
285
|
+
await this.openSession();
|
|
286
|
+
this.setState({ ...this.state, status: "ready", detail: undefined });
|
|
287
|
+
} catch (error) {
|
|
288
|
+
this.fail(describeError(error, launchSpec(this.state.id).loginHint));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Runs what the user sent: one turn, or with `alternatives` > 1 that many
|
|
294
|
+
* takes on it to choose between. A prompt sent while the agent works is
|
|
295
|
+
* queued and runs when it is free again; `queuedId` is the chat item it was
|
|
296
|
+
* queued as.
|
|
297
|
+
*/
|
|
298
|
+
async prompt(text: string, attachments: Attachments = {}, queuedId?: string, alternatives = 1): Promise<void> {
|
|
299
|
+
const connection = this.connection;
|
|
300
|
+
if (!connection || !this.sessionId) return;
|
|
301
|
+
const count = Math.max(1, Math.min(MAX_ALTERNATIVES, Math.floor(alternatives) || 1));
|
|
302
|
+
if (this.state.status === "busy") return this.enqueue(text, attachments, count);
|
|
303
|
+
if (this.state.status !== "ready") return;
|
|
304
|
+
this.stopping = false;
|
|
305
|
+
this.setState({ ...this.state, status: "busy", detail: undefined });
|
|
306
|
+
const images = this.state.supportsImages ? acceptedImages(attachments.images) : [];
|
|
307
|
+
const annotations = attachments.annotations ?? [];
|
|
308
|
+
this.push({
|
|
309
|
+
...this.userItem(queuedId ?? this.nextId("user"), text, images, annotations, false),
|
|
310
|
+
alternatives: count > 1 ? count : undefined,
|
|
311
|
+
});
|
|
312
|
+
const label = text || annotations.map(annotationLabel).join(", ") || "Image prompt";
|
|
313
|
+
const turn = { text, images, annotations, label, instruction: attachments.instruction };
|
|
314
|
+
if (count > 1) await this.runAlternatives(turn, count);
|
|
315
|
+
else await this.runChecked(turn);
|
|
316
|
+
if (this.connection === connection) this.setState({ ...this.state, status: "ready" });
|
|
317
|
+
// Whatever the user queued meanwhile runs once everything above is done.
|
|
318
|
+
await this.runQueued();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One turn plus the fix-up turns for what it left broken. Returns false when it was stopped or failed. */
|
|
322
|
+
private async runChecked(turn: Turn): Promise<boolean> {
|
|
323
|
+
const connection = this.connection;
|
|
324
|
+
const completed = await this.runTurn(turn);
|
|
325
|
+
for (let fixes = 0; completed && fixes < MAX_AUTO_FIXES && !this.stopping; fixes += 1) {
|
|
326
|
+
const problems = await this.collectProblems();
|
|
327
|
+
if (!problems || this.connection !== connection) break;
|
|
328
|
+
this.push(this.userItem(this.nextId("user"), problems, [], [], true));
|
|
329
|
+
// Fix-up turns are labelled as such in history and reviews, not with the whole problem report.
|
|
330
|
+
await this.runTurn({ text: problems, images: [], annotations: [], label: "Fixed reported problems" });
|
|
331
|
+
}
|
|
332
|
+
return completed && !this.stopping;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Makes `count` takes on one prompt, each from the same starting point, for
|
|
337
|
+
* the user to compare in a review and pick from. Between takes Basis puts the
|
|
338
|
+
* workspace back; the agent's session remembers the earlier takes, so it can
|
|
339
|
+
* make the next one different.
|
|
340
|
+
*/
|
|
341
|
+
private async runAlternatives(turn: Turn, count: number): Promise<void> {
|
|
342
|
+
const labels = ALTERNATIVE_LABELS.slice(0, count);
|
|
343
|
+
await this.workspace.beginAlternatives(turn.label, count);
|
|
344
|
+
for (const [index, label] of labels.entries()) {
|
|
345
|
+
if (index > 0) {
|
|
346
|
+
if (!this.workspace.review?.alternatives || this.stopping || !this.connection) break;
|
|
347
|
+
await this.workspace.nextAlternative();
|
|
348
|
+
}
|
|
349
|
+
this.notice(`Alternative ${label} of ${count}`, "info");
|
|
350
|
+
const completed = await this.runChecked({
|
|
351
|
+
...turn,
|
|
352
|
+
label: `Alternative ${label}: ${turn.label}`,
|
|
353
|
+
instruction: [turn.instruction, alternativeInstruction(index, labels, Boolean(this.workspace.review))]
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join("\n\n"),
|
|
356
|
+
});
|
|
357
|
+
if (!(await this.workspace.alternativeDone(label))) {
|
|
358
|
+
if (index === 0)
|
|
359
|
+
this.notice(
|
|
360
|
+
"The agent made one version without opening a review, so there is nothing to compare it with.",
|
|
361
|
+
"info",
|
|
362
|
+
);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!completed) break;
|
|
366
|
+
}
|
|
367
|
+
await this.workspace.endAlternatives();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Sends one turn to the agent and records what it changed as one undo step (or iteration). */
|
|
371
|
+
private async runTurn(turn: Turn): Promise<boolean> {
|
|
372
|
+
const connection = this.connection;
|
|
373
|
+
const sessionId = this.sessionId;
|
|
374
|
+
if (!connection || !sessionId) return false;
|
|
375
|
+
await this.workspace.beginTurn(turn.label);
|
|
376
|
+
this.checksBefore = new Set([...this.checks, ...this.workspace.state.colorFindings]);
|
|
377
|
+
const message = turn.text || "See the attached context.";
|
|
378
|
+
const body = this.needsPreamble
|
|
379
|
+
? `${FIRST_PROMPT_PREAMBLE}
|
|
380
|
+
|
|
381
|
+
${message}`
|
|
382
|
+
: message;
|
|
383
|
+
this.needsPreamble = false;
|
|
384
|
+
const blocks: acp.ContentBlock[] = [];
|
|
385
|
+
if (this.repo) {
|
|
386
|
+
const hasNotes = existsSync(path.join(this.workspace.dir, PRODUCT_FILE));
|
|
387
|
+
blocks.push({ type: "text", text: repoContext(this.repo, hasNotes) });
|
|
388
|
+
}
|
|
389
|
+
const context = this.reviewContext();
|
|
390
|
+
if (context) blocks.push({ type: "text", text: context });
|
|
391
|
+
if (turn.instruction) blocks.push({ type: "text", text: turn.instruction });
|
|
392
|
+
if (turn.annotations.length) blocks.push({ type: "text", text: formatAnnotations(turn.annotations) });
|
|
393
|
+
for (const image of turn.images) blocks.push({ type: "image", mimeType: image.mimeType, data: image.data });
|
|
394
|
+
blocks.push({ type: "text", text: body });
|
|
395
|
+
let completed = false;
|
|
396
|
+
try {
|
|
397
|
+
const result = await connection.agent.request(acp.methods.agent.session.prompt, {
|
|
398
|
+
sessionId,
|
|
399
|
+
prompt: blocks,
|
|
400
|
+
});
|
|
401
|
+
completed = result.stopReason === "end_turn";
|
|
402
|
+
if (result.stopReason === "cancelled") this.notice("Stopped.", "info");
|
|
403
|
+
else if (!completed) this.notice(`The agent stopped early (${result.stopReason}).`, "error");
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (this.connection === connection)
|
|
406
|
+
this.notice(describeError(error, launchSpec(this.state.id).loginHint), "error");
|
|
407
|
+
} finally {
|
|
408
|
+
this.openText = undefined;
|
|
409
|
+
this.cancelPermissions();
|
|
410
|
+
}
|
|
411
|
+
// Stay busy while the turn is checked and recorded as one undo step.
|
|
412
|
+
await this.workspace.endTurn();
|
|
413
|
+
return completed && this.connection === connection;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Removes a queued prompt before it runs. */
|
|
417
|
+
unqueue(id: string): void {
|
|
418
|
+
const before = this.queued.length;
|
|
419
|
+
this.queued = this.queued.filter((item) => item.id !== id);
|
|
420
|
+
if (this.queued.length !== before) this.remove(id);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private enqueue(text: string, attachments: Attachments, alternatives: number): void {
|
|
424
|
+
const id = this.nextId("user");
|
|
425
|
+
const images = this.state.supportsImages ? acceptedImages(attachments.images) : [];
|
|
426
|
+
this.push({
|
|
427
|
+
...this.userItem(id, text, images, attachments.annotations ?? [], false),
|
|
428
|
+
queued: true,
|
|
429
|
+
alternatives: alternatives > 1 ? alternatives : undefined,
|
|
430
|
+
});
|
|
431
|
+
this.queued.push({ id, text, attachments, alternatives });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async runQueued(): Promise<void> {
|
|
435
|
+
const next = this.queued.shift();
|
|
436
|
+
if (!next) return;
|
|
437
|
+
// The message moves from where it was queued to the end of the conversation, where its turn starts.
|
|
438
|
+
this.remove(next.id);
|
|
439
|
+
await this.prompt(next.text, next.attachments, next.id, next.alternatives);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private clearQueue(): void {
|
|
443
|
+
for (const item of this.queued) this.remove(item.id);
|
|
444
|
+
this.queued = [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private userItem(
|
|
448
|
+
id: string,
|
|
449
|
+
text: string,
|
|
450
|
+
images: PromptImage[],
|
|
451
|
+
annotations: Annotation[],
|
|
452
|
+
auto: boolean,
|
|
453
|
+
): UserItem {
|
|
454
|
+
return {
|
|
455
|
+
kind: "user",
|
|
456
|
+
id,
|
|
457
|
+
text,
|
|
458
|
+
auto,
|
|
459
|
+
images: images.length ? images.map((image) => `data:${image.mimeType};base64,${image.data}`) : undefined,
|
|
460
|
+
annotations: annotations.length ? annotations.map(annotationLabel) : undefined,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Tells the agent about the open review on every turn, so its changes stay about what the review shows. */
|
|
465
|
+
private reviewContext(): string | null {
|
|
466
|
+
const review = this.workspace.review;
|
|
467
|
+
if (!review) return null;
|
|
468
|
+
return [
|
|
469
|
+
`A review is open in Basis: "${review.title}", showing ${review.show.map(describeTarget).join("; ")}.`,
|
|
470
|
+
"Everything you change now belongs to this review until the user approves or discards it in Basis.",
|
|
471
|
+
"Keep review.json pointing at what the user is asking about, and update it when the focus moves.",
|
|
472
|
+
].join(" ");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Render errors and check findings of the current tab's preview (chosen by PreviewReports); they feed the
|
|
477
|
+
* auto-fix loop.
|
|
478
|
+
*/
|
|
479
|
+
setPreviewErrors(errors: unknown[], checks: unknown[] = [], checking = false): void {
|
|
480
|
+
const strings = (items: unknown[], limit: number, length: number) =>
|
|
481
|
+
items
|
|
482
|
+
.filter((item): item is string => typeof item === "string")
|
|
483
|
+
.slice(0, limit)
|
|
484
|
+
.map((item) => item.slice(0, length));
|
|
485
|
+
this.previewErrors = strings(errors, 10, 600);
|
|
486
|
+
this.checks = strings(checks, 40, 400);
|
|
487
|
+
this.checking = checking;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Everything the last turn left broken, phrased for the agent, or null when all is well. */
|
|
491
|
+
private async collectProblems(): Promise<string | null> {
|
|
492
|
+
const { errors, typeErrors, reviewErrors } = this.workspace.state;
|
|
493
|
+
// Give the preview a moment to hot-reload the new code and report what failed to render.
|
|
494
|
+
if (!errors && !typeErrors) {
|
|
495
|
+
await sleep(PREVIEW_SETTLE_MS);
|
|
496
|
+
// Checks run once the preview settles; wait for them, so what the turn introduced is known.
|
|
497
|
+
for (let waited = 0; this.checking && waited < CHECKS_WAIT_MS; waited += 200) await sleep(200);
|
|
498
|
+
}
|
|
499
|
+
const sections: string[] = [];
|
|
500
|
+
if (reviewErrors)
|
|
501
|
+
sections.push(`review.json is not valid, so no review was opened or updated:\n${list(reviewErrors)}`);
|
|
502
|
+
if (errors)
|
|
503
|
+
sections.push(
|
|
504
|
+
`design-system.json does not validate, so the preview still shows the previous version:\n${list(errors)}`,
|
|
505
|
+
);
|
|
506
|
+
if (typeErrors) {
|
|
507
|
+
sections.push(
|
|
508
|
+
`The source does not type-check, or no longer satisfies the contracts (.basis/contracts.ts and components/contracts.ts):\n${list(typeErrors)}`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (this.previewErrors.length) sections.push(`The preview fails to render:\n${list(this.previewErrors)}`);
|
|
512
|
+
// Only what this turn introduced: findings that were there before are the user's to ask about, and
|
|
513
|
+
// repeating them would send the agent after them on every turn.
|
|
514
|
+
const introduced = this.checks.filter((check) => !this.checksBefore.has(check));
|
|
515
|
+
if (introduced.length) {
|
|
516
|
+
sections.push(
|
|
517
|
+
`Your change fails these accessibility checks (WCAG 2 AA). Fix them, unless the user asked for exactly this; then say so instead:\n${list(introduced)}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const fixedColors = this.workspace.state.colorFindings.filter((finding) => !this.checksBefore.has(finding));
|
|
521
|
+
if (fixedColors.length) {
|
|
522
|
+
sections.push(
|
|
523
|
+
`Your change uses colours that stay the same in light and dark, so they will look wrong in one of them. Derive them from the colour roles (var(--color-...), color-mix() with a role, oklch(from var(--color-...) ...)), or add the colour to both themes, unless the user asked for exactly this; then say so instead:\n${list(fixedColors)}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return sections.length
|
|
527
|
+
? `Your last change left problems. Fix them before anything else.\n\n${sections.join("\n\n")}`
|
|
528
|
+
: null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Stops the running turn; prompts queued behind it are dropped too, since Stop means stop. */
|
|
532
|
+
async cancel(): Promise<void> {
|
|
533
|
+
this.stopping = true;
|
|
534
|
+
this.clearQueue();
|
|
535
|
+
this.cancelPermissions();
|
|
536
|
+
if (this.connection && this.sessionId) {
|
|
537
|
+
await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
resolvePermission(id: string, optionId: string | null): void {
|
|
542
|
+
this.permissions.get(id)?.(optionId);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Opens the session turns run in. With `resume`, it first tries to pick up
|
|
547
|
+
* the saved session, so the agent remembers the conversation the transcript
|
|
548
|
+
* shows; otherwise, or when that fails, it starts a new one.
|
|
549
|
+
*/
|
|
550
|
+
private async openSession(resume = false): Promise<void> {
|
|
551
|
+
const connection = this.connection;
|
|
552
|
+
if (!connection) throw new Error("Agent is not connected");
|
|
553
|
+
const base = { cwd: this.workspace.dir, mcpServers: [], _meta: SESSION_META };
|
|
554
|
+
const systemPrompt = { mode: "append" as const, content: [{ type: "text" as const, text: AGENT_BRIEF }] };
|
|
555
|
+
const saved = resume ? this.resumable : undefined;
|
|
556
|
+
this.resumable = undefined;
|
|
557
|
+
if (saved && saved.agent === this.state.id && this.supportsResume) {
|
|
558
|
+
try {
|
|
559
|
+
const resumed = await connection.agent.request(acp.methods.agent.session.resume, {
|
|
560
|
+
...base,
|
|
561
|
+
sessionId: saved.sessionId,
|
|
562
|
+
...(this.supportsSystemPrompt ? { systemPrompt } : {}),
|
|
563
|
+
});
|
|
564
|
+
await this.askingMode(connection, saved.sessionId, (resumed as acp.ResumeSessionResponse | undefined)?.modes);
|
|
565
|
+
this.sessionId = saved.sessionId;
|
|
566
|
+
this.needsPreamble = false;
|
|
567
|
+
this.scheduleSave();
|
|
568
|
+
return;
|
|
569
|
+
} catch {
|
|
570
|
+
// Falls through to a new session below.
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (saved && this.transcript.length > 0) {
|
|
574
|
+
this.notice(
|
|
575
|
+
"The agent starts a new conversation after the restart; the messages above are for reference.",
|
|
576
|
+
"info",
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
let response: acp.NewSessionResponse | undefined;
|
|
580
|
+
if (this.supportsSystemPrompt) {
|
|
581
|
+
try {
|
|
582
|
+
response = await connection.agent.request(acp.methods.agent.session.new, { ...base, systemPrompt });
|
|
583
|
+
} catch {
|
|
584
|
+
// Fall back to the preamble below; some agents advertise only "override".
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.needsPreamble = response === undefined;
|
|
588
|
+
const session = response ?? (await connection.agent.request(acp.methods.agent.session.new, base));
|
|
589
|
+
if (!session?.sessionId) throw new Error("The agent did not return a session id");
|
|
590
|
+
await this.askingMode(connection, session.sessionId, session.modes);
|
|
591
|
+
this.sessionId = session.sessionId;
|
|
592
|
+
this.scheduleSave();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Agents start in the user's own default mode (Claude Code's from ~/.claude, whatever SESSION_META says; Codex's
|
|
597
|
+
* from its config), and one that edits or runs commands unasked would act without Basis' rules. The session
|
|
598
|
+
* starts in the mode that asks instead (ASKING_MODES).
|
|
599
|
+
*/
|
|
600
|
+
private async askingMode(
|
|
601
|
+
connection: acp.ClientConnection,
|
|
602
|
+
sessionId: string,
|
|
603
|
+
modes: acp.SessionModeState | null | undefined,
|
|
604
|
+
): Promise<void> {
|
|
605
|
+
const asking = ASKING_MODES[this.state.id];
|
|
606
|
+
if (!modes || modes.currentModeId === asking) return;
|
|
607
|
+
if (!modes.availableModes.some((mode) => mode.id === asking)) return;
|
|
608
|
+
await connection.agent.request(acp.methods.agent.session.setMode, { sessionId, modeId: asking });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Saves the conversation shortly after it changes; streamed text changes it many times a second. */
|
|
612
|
+
private scheduleSave(): void {
|
|
613
|
+
clearTimeout(this.saveTimer);
|
|
614
|
+
this.saveTimer = setTimeout(() => {
|
|
615
|
+
const saved = { agent: this.state.id, sessionId: this.sessionId ?? null, chat: this.transcript };
|
|
616
|
+
saveChat(this.workspace.dir, saved).catch((error: Error) =>
|
|
617
|
+
console.error("[basis] could not save the chat:", error.message),
|
|
618
|
+
);
|
|
619
|
+
}, 400);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private onUpdate(params: acp.SessionNotification): void {
|
|
623
|
+
if (params.sessionId !== this.sessionId) return;
|
|
624
|
+
const update = params.update;
|
|
625
|
+
switch (update.sessionUpdate) {
|
|
626
|
+
case "agent_message_chunk":
|
|
627
|
+
case "agent_thought_chunk": {
|
|
628
|
+
if (update.content.type !== "text") return;
|
|
629
|
+
this.appendText(update.sessionUpdate === "agent_message_chunk" ? "agent" : "thought", update.content.text);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
case "tool_call": {
|
|
633
|
+
this.push({
|
|
634
|
+
kind: "tool",
|
|
635
|
+
id: `tool-${update.toolCallId}`,
|
|
636
|
+
title: update.title,
|
|
637
|
+
toolKind: update.kind ?? "other",
|
|
638
|
+
status: update.status ?? "pending",
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
case "tool_call_update": {
|
|
643
|
+
const id = `tool-${update.toolCallId}`;
|
|
644
|
+
const existing = this.transcript.find((item) => item.id === id);
|
|
645
|
+
if (existing?.kind !== "tool") return;
|
|
646
|
+
this.push({
|
|
647
|
+
...existing,
|
|
648
|
+
title: update.title ?? existing.title,
|
|
649
|
+
toolKind: update.kind ?? existing.toolKind,
|
|
650
|
+
status: (update.status as ToolStatus | null | undefined) ?? existing.status,
|
|
651
|
+
});
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
case "plan": {
|
|
655
|
+
const entries = update.entries.map((entry) => ({ content: entry.content, status: entry.status }));
|
|
656
|
+
const last = this.transcript.findLast((item) => item.kind === "plan" || item.kind === "user");
|
|
657
|
+
const id = last?.kind === "plan" ? last.id : this.nextId("plan");
|
|
658
|
+
this.push({ kind: "plan", id, entries });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
default:
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async onPermission(params: acp.RequestPermissionRequest): Promise<acp.RequestPermissionResponse> {
|
|
667
|
+
const call = params.toolCall;
|
|
668
|
+
// Some tools (grep) name their path only in their input, not as locations.
|
|
669
|
+
const located = (call.locations ?? []).map((location) => location.path);
|
|
670
|
+
const paths = located.length ? located : inputPaths(call.rawInput, this.workspace.dir);
|
|
671
|
+
const options: PermissionChoice[] = params.options.map((option) => ({
|
|
672
|
+
optionId: option.optionId,
|
|
673
|
+
name: option.name,
|
|
674
|
+
kind: option.kind,
|
|
675
|
+
}));
|
|
676
|
+
// Reads may also look in the linked product repo; edits stay inside what the workspace lets the agent change.
|
|
677
|
+
const allowed = (p: string) =>
|
|
678
|
+
call.kind === "edit" ? this.workspace.isEditable(p) : this.workspace.isInside(p) || this.inRepo(p);
|
|
679
|
+
const autoAllow = call.kind != null && SAFE_KINDS.has(call.kind) && paths.length > 0 && paths.every(allowed);
|
|
680
|
+
const allowOnce = options.find((option) => option.kind === "allow_once");
|
|
681
|
+
if (autoAllow && allowOnce) return { outcome: { outcome: "selected", optionId: allowOnce.optionId } };
|
|
682
|
+
|
|
683
|
+
const id = this.nextId("permission");
|
|
684
|
+
const title = call.title ?? "The agent wants to run a tool";
|
|
685
|
+
const detail = describeRawInput(call.rawInput) ?? (paths.length ? paths.join("\n") : undefined);
|
|
686
|
+
this.push({ kind: "permission", id, title, detail, options });
|
|
687
|
+
const optionId = await new Promise<string | null>((resolve) => this.permissions.set(id, resolve));
|
|
688
|
+
this.permissions.delete(id);
|
|
689
|
+
const chosen = options.find((option) => option.optionId === optionId);
|
|
690
|
+
this.push({ kind: "permission", id, title, detail, options, resolved: chosen?.name ?? "Cancelled" });
|
|
691
|
+
return chosen
|
|
692
|
+
? { outcome: { outcome: "selected", optionId: chosen.optionId } }
|
|
693
|
+
: { outcome: { outcome: "cancelled" } };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async readTextFile(params: acp.ReadTextFileRequest): Promise<acp.ReadTextFileResponse> {
|
|
697
|
+
const text = await readFile(params.path, "utf8");
|
|
698
|
+
if (params.line == null && params.limit == null) return { content: text };
|
|
699
|
+
const lines = text.split("\n");
|
|
700
|
+
const start = Math.max(0, (params.line ?? 1) - 1);
|
|
701
|
+
const end = params.limit == null ? undefined : start + params.limit;
|
|
702
|
+
return { content: lines.slice(start, end).join("\n") };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private async writeTextFile(params: acp.WriteTextFileRequest): Promise<acp.WriteTextFileResponse> {
|
|
706
|
+
if (!this.workspace.isEditable(params.path)) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
"Basis only lets the agent write design-system.json, review.json, product.md and files under components/, patterns/ or views/ in its workspace.",
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
await mkdir(path.dirname(params.path), { recursive: true });
|
|
712
|
+
await writeFile(params.path, params.content);
|
|
713
|
+
return {};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private appendText(kind: "agent" | "thought", chunk: string): void {
|
|
717
|
+
const open = this.openText;
|
|
718
|
+
const current = open?.kind === kind ? this.transcript.find((item) => item.id === open.id) : undefined;
|
|
719
|
+
if (current && (current.kind === "agent" || current.kind === "thought")) {
|
|
720
|
+
this.push({ ...current, text: current.text + chunk }, true);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const id = this.nextId(kind);
|
|
724
|
+
this.push({ kind, id, text: chunk }, true);
|
|
725
|
+
this.openText = { id, kind };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Inserts or replaces a transcript item and broadcasts it. */
|
|
729
|
+
private push(item: ChatItem, keepTextOpen = false): void {
|
|
730
|
+
if (!keepTextOpen) this.openText = undefined;
|
|
731
|
+
const index = this.transcript.findIndex((existing) => existing.id === item.id);
|
|
732
|
+
if (index === -1) this.transcript.push(item);
|
|
733
|
+
else this.transcript[index] = item;
|
|
734
|
+
this.emit("chat", item);
|
|
735
|
+
this.scheduleSave();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private remove(id: string): void {
|
|
739
|
+
this.transcript = this.transcript.filter((item) => item.id !== id);
|
|
740
|
+
this.emit("chat-remove", id);
|
|
741
|
+
this.scheduleSave();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private notice(text: string, tone: "info" | "error"): void {
|
|
745
|
+
this.push({ kind: "notice", id: this.nextId("notice"), text, tone });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private fail(detail: string): void {
|
|
749
|
+
this.cancelPermissions();
|
|
750
|
+
void this.workspace.endTurn();
|
|
751
|
+
this.setState({ ...this.state, status: "error", detail });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private cancelPermissions(): void {
|
|
755
|
+
for (const resolve of this.permissions.values()) resolve(null);
|
|
756
|
+
this.permissions.clear();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private setState(next: AgentState): void {
|
|
760
|
+
this.state = next;
|
|
761
|
+
this.emit("agent", next);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private nextId(prefix: string): string {
|
|
765
|
+
this.seq += 1;
|
|
766
|
+
return `${prefix}-${Date.now().toString(36)}-${this.seq}`;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** What one turn sends: the user's words and attachments, and how history labels it. */
|
|
771
|
+
interface Turn {
|
|
772
|
+
text: string;
|
|
773
|
+
images: PromptImage[];
|
|
774
|
+
annotations: Annotation[];
|
|
775
|
+
label: string;
|
|
776
|
+
instruction?: string;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Tells the agent about the product repo linked to the project, on every turn. */
|
|
780
|
+
function repoContext(repo: string, hasNotes: boolean): string {
|
|
781
|
+
return [
|
|
782
|
+
`This project designs for a product whose repo is linked in Basis: ${repo} (read-only; see "A linked product repo" in AGENTS.md).`,
|
|
783
|
+
hasNotes
|
|
784
|
+
? "What is known about it is in product.md: read that first, look in the repo only for what it does not cover, and add what you learn."
|
|
785
|
+
: "There is no product.md yet: when you learn something about the product worth keeping, write it there.",
|
|
786
|
+
].join(" ");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Tells the agent which take it is making. The first one also opens the
|
|
791
|
+
* review the takes are compared in, unless one is open already.
|
|
792
|
+
*/
|
|
793
|
+
function alternativeInstruction(index: number, labels: readonly string[], reviewOpen: boolean): string {
|
|
794
|
+
const label = labels[index];
|
|
795
|
+
if (index === 0) {
|
|
796
|
+
return [
|
|
797
|
+
`The user asked for ${labels.length} alternatives of this change (${labels.join(", ")}), to compare side by side and pick one to continue with.`,
|
|
798
|
+
`Make alternative ${label} now; Basis then puts the workspace back and asks for the next one.`,
|
|
799
|
+
"Make each alternative a complete, clearly different take on the request (a different direction, not a small variation of the same idea).",
|
|
800
|
+
reviewOpen
|
|
801
|
+
? ""
|
|
802
|
+
: "Write review.json first, showing what the alternatives change: Basis compares them in that review, and without it there is nothing to compare.",
|
|
803
|
+
]
|
|
804
|
+
.filter(Boolean)
|
|
805
|
+
.join(" ");
|
|
806
|
+
}
|
|
807
|
+
return [
|
|
808
|
+
`Basis has put the workspace back to where alternative ${labels[0]} started.`,
|
|
809
|
+
`Make alternative ${label} now: a clearly different take on the same request from ${labels.slice(0, index).join(" and ")}, which the user will see next to it.`,
|
|
810
|
+
"Leave review.json as it is unless what the review shows has to change.",
|
|
811
|
+
].join(" ");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export interface Attachments {
|
|
815
|
+
images?: PromptImage[];
|
|
816
|
+
annotations?: Annotation[];
|
|
817
|
+
/** Instructions sent to the agent ahead of the prompt but not shown in the chat (guided turns). */
|
|
818
|
+
instruction?: string;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function acceptedImages(images: PromptImage[] | undefined): PromptImage[] {
|
|
822
|
+
return (images ?? [])
|
|
823
|
+
.filter(
|
|
824
|
+
(image) =>
|
|
825
|
+
(IMAGE_TYPES as readonly string[]).includes(image.mimeType) &&
|
|
826
|
+
typeof image.data === "string" &&
|
|
827
|
+
image.data.length <= MAX_IMAGE_BASE64,
|
|
828
|
+
)
|
|
829
|
+
.slice(0, MAX_IMAGES);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function sleep(ms: number): Promise<void> {
|
|
833
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function list(items: string[]): string {
|
|
837
|
+
return items.map((item) => `- ${item}`).join("\n");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** The paths a tool's raw input names (path, file_path), resolved against the workspace. */
|
|
841
|
+
function inputPaths(raw: unknown, dir: string): string[] {
|
|
842
|
+
if (!raw || typeof raw !== "object") return [];
|
|
843
|
+
const input = raw as Record<string, unknown>;
|
|
844
|
+
return ["path", "file_path"]
|
|
845
|
+
.map((key) => input[key])
|
|
846
|
+
.filter((value): value is string => typeof value === "string" && value.trim() !== "")
|
|
847
|
+
.map((value) => path.resolve(dir, value));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function describeRawInput(raw: unknown): string | undefined {
|
|
851
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
852
|
+
const input = raw as Record<string, unknown>;
|
|
853
|
+
for (const key of ["command", "url", "file_path", "path", "pattern"]) {
|
|
854
|
+
if (typeof input[key] === "string") return input[key] as string;
|
|
855
|
+
}
|
|
856
|
+
return undefined;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function describeError(error: unknown, loginHint?: string): string {
|
|
860
|
+
const err = error as { code?: number; message?: string; data?: unknown };
|
|
861
|
+
const message = err?.message ?? String(error);
|
|
862
|
+
if (/usage limit|rate limit|too many requests|\b429\b/i.test(message))
|
|
863
|
+
return `${message} That is your plan's usage limit; it resets after a while, and Basis keeps the conversation until then.`;
|
|
864
|
+
const needsAuth = err?.code === -32000 || /auth|login|logged in|credential/i.test(message);
|
|
865
|
+
return needsAuth && loginHint ? `${message} ${loginHint}` : message;
|
|
866
|
+
}
|