@datalabrotterdam/nova-ai-cli 1.1.0 → 1.2.0-alpha.2
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/CHANGELOG.md +70 -0
- package/README.md +160 -101
- package/dist/chunk-NHGIUEIR.js +754 -0
- package/dist/chunk-SKAW2KXC.js +10 -0
- package/dist/headless-OFJFVJ3O.js +543 -0
- package/dist/index.js +155 -17
- package/dist/tui-NJWDD5X7.js +4810 -0
- package/dist/webui-L4GGMXJ3.js +9 -0
- package/package.json +32 -32
- package/dist/acp/agent.d.ts +0 -13
- package/dist/acp/agent.js +0 -242
- package/dist/acp/auth-server.d.ts +0 -10
- package/dist/acp/auth-server.js +0 -67
- package/dist/acp/credentials.d.ts +0 -11
- package/dist/acp/credentials.js +0 -30
- package/dist/acp/index.d.ts +0 -2
- package/dist/acp/index.js +0 -21
- package/dist/acp/mcp.d.ts +0 -11
- package/dist/acp/mcp.js +0 -90
- package/dist/acp/sessions.d.ts +0 -20
- package/dist/acp/sessions.js +0 -91
- package/dist/acp/tools/index.d.ts +0 -6
- package/dist/acp/tools/index.js +0 -5
- package/dist/acp/tools/marker.d.ts +0 -17
- package/dist/acp/tools/marker.js +0 -43
- package/dist/acp/tools/permission.d.ts +0 -2
- package/dist/acp/tools/permission.js +0 -20
- package/dist/acp/tools/read-file.d.ts +0 -2
- package/dist/acp/tools/read-file.js +0 -20
- package/dist/acp/tools/registry.d.ts +0 -4
- package/dist/acp/tools/registry.js +0 -10
- package/dist/acp/tools/reporting.d.ts +0 -3
- package/dist/acp/tools/reporting.js +0 -26
- package/dist/acp/tools/run-command.d.ts +0 -2
- package/dist/acp/tools/run-command.js +0 -29
- package/dist/acp/tools/system-prompt.d.ts +0 -2
- package/dist/acp/tools/system-prompt.js +0 -23
- package/dist/acp/tools/types.d.ts +0 -18
- package/dist/acp/tools/types.js +0 -1
- package/dist/acp/tools/write-file.d.ts +0 -2
- package/dist/acp/tools/write-file.js +0 -20
- package/dist/index.d.ts +0 -2
- package/dist/tui/index.d.ts +0 -7
- package/dist/tui/index.js +0 -11
- package/dist/webui/index.d.ts +0 -6
- package/dist/webui/index.js +0 -9
- package/src/acp/web/dist/assets/index-CSccI7lJ.css +0 -1
- package/src/acp/web/dist/assets/index-D2Z0sfLR.js +0 -1
- package/src/acp/web/dist/favicon.svg +0 -46
- package/src/acp/web/dist/index.html +0 -32
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
// src/tui/settings/workspace-mcp.ts
|
|
2
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
3
|
+
import { join as join2 } from "path";
|
|
4
|
+
import { isWorkspaceTrusted } from "@datalabrotterdam/nova-ai-agent/core/nova-home.js";
|
|
5
|
+
import { readSettings } from "@datalabrotterdam/nova-ai-agent/core/policy/settings.js";
|
|
6
|
+
|
|
7
|
+
// src/tui/settings/workspace-settings.ts
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
function settingsPath(cwd) {
|
|
11
|
+
return join(cwd, ".nova-ai", "settings.json");
|
|
12
|
+
}
|
|
13
|
+
function readWorkspaceSettings(cwd) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(settingsPath(cwd), "utf8");
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/tui/settings/workspace-mcp.ts
|
|
23
|
+
var UNTRUSTED_MCP_MESSAGE = "Not started: MCP servers declared by this workspace only run once you trust it (/trust in the TUI, --trust-workspace headless).";
|
|
24
|
+
function readWorkspaceMcpConfiguration(cwd, options = {}) {
|
|
25
|
+
const configured = readWorkspaceSettings(cwd).mcpServers ?? [];
|
|
26
|
+
const project = readProjectMcpConfiguration(cwd);
|
|
27
|
+
const servers = new Map(configured.map((server) => [server.name, server]));
|
|
28
|
+
for (const server of project.servers) servers.set(server.name, server);
|
|
29
|
+
const declared = [...servers.values()];
|
|
30
|
+
if (declared.length && !(options.trusted ?? isWorkspaceTrusted(cwd))) {
|
|
31
|
+
return {
|
|
32
|
+
servers: [],
|
|
33
|
+
failures: [
|
|
34
|
+
...project.failures,
|
|
35
|
+
...declared.map((server) => ({ serverName: server.name, message: UNTRUSTED_MCP_MESSAGE }))
|
|
36
|
+
],
|
|
37
|
+
untrusted: declared.map((server) => server.name)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { servers: declared, failures: project.failures };
|
|
41
|
+
}
|
|
42
|
+
function workspaceNeedsTrust(cwd) {
|
|
43
|
+
if (isWorkspaceTrusted(cwd)) return false;
|
|
44
|
+
if (readWorkspaceMcpConfiguration(cwd, { trusted: true }).servers.length) return true;
|
|
45
|
+
return loadWorkspaceAllowRules(cwd).length > 0;
|
|
46
|
+
}
|
|
47
|
+
function loadWorkspaceAllowRules(cwd) {
|
|
48
|
+
return ["settings.json", "settings.local.json"].flatMap((name) => {
|
|
49
|
+
const allow = readSettings(join2(cwd, ".nova-ai", name))?.permissions?.allow;
|
|
50
|
+
return Array.isArray(allow) ? allow : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function readProjectMcpConfiguration(cwd) {
|
|
54
|
+
const path = join2(cwd, ".mcp.json");
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync2(path, "utf8");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code === "ENOENT") return { servers: [], failures: [] };
|
|
60
|
+
return { servers: [], failures: [{ serverName: ".mcp.json", message: errorMessage(error, "Could not read file") }] };
|
|
61
|
+
}
|
|
62
|
+
let document;
|
|
63
|
+
try {
|
|
64
|
+
document = JSON.parse(raw);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { servers: [], failures: [{ serverName: ".mcp.json", message: errorMessage(error, "Invalid JSON") }] };
|
|
67
|
+
}
|
|
68
|
+
const root = asRecord(document);
|
|
69
|
+
const definitions = root ? asRecord(root.mcpServers) : null;
|
|
70
|
+
if (!definitions) {
|
|
71
|
+
return {
|
|
72
|
+
servers: [],
|
|
73
|
+
failures: [{ serverName: ".mcp.json", message: 'Expected an "mcpServers" object.' }]
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const servers = [];
|
|
77
|
+
const failures = [];
|
|
78
|
+
for (const [name, value] of Object.entries(definitions)) {
|
|
79
|
+
const config = asRecord(value);
|
|
80
|
+
if (config?.disabled === true || config?.enabled === false) continue;
|
|
81
|
+
try {
|
|
82
|
+
servers.push(normalizeProjectServer(name, config));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
failures.push({ serverName: `.mcp.json:${name}`, message: errorMessage(error, "Invalid server configuration") });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { servers, failures };
|
|
88
|
+
}
|
|
89
|
+
function normalizeProjectServer(name, config) {
|
|
90
|
+
if (!config) throw new Error("Server definition must be an object.");
|
|
91
|
+
const type = typeof config.type === "string" ? config.type : config.url ? "http" : "stdio";
|
|
92
|
+
if (type === "http" || type === "sse") {
|
|
93
|
+
if (typeof config.url !== "string" || !config.url) throw new Error(`${type} server requires a URL.`);
|
|
94
|
+
try {
|
|
95
|
+
new URL(config.url);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error(`${type} server requires a valid URL.`);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
type,
|
|
102
|
+
url: config.url,
|
|
103
|
+
headers: normalizePairs(config.headers, "headers")
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (type !== "stdio") throw new Error(`Unsupported transport type: ${type}.`);
|
|
107
|
+
if (typeof config.command !== "string" || !config.command) throw new Error("stdio server requires a command.");
|
|
108
|
+
if (config.args !== void 0 && (!Array.isArray(config.args) || !config.args.every((arg) => typeof arg === "string"))) {
|
|
109
|
+
throw new Error("args must be an array of strings.");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
command: config.command,
|
|
114
|
+
args: config.args ?? [],
|
|
115
|
+
env: normalizePairs(config.env, "env")
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function normalizePairs(value, label) {
|
|
119
|
+
if (value === void 0) return [];
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
if (!value.every((entry) => {
|
|
122
|
+
const pair = asRecord(entry);
|
|
123
|
+
return typeof pair?.name === "string" && typeof pair.value === "string";
|
|
124
|
+
})) {
|
|
125
|
+
throw new Error(`${label} must contain name/value strings.`);
|
|
126
|
+
}
|
|
127
|
+
return value.map((entry) => {
|
|
128
|
+
const pair = entry;
|
|
129
|
+
return { name: pair.name, value: pair.value };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const record = asRecord(value);
|
|
133
|
+
if (!record || !Object.values(record).every((entry) => typeof entry === "string")) {
|
|
134
|
+
throw new Error(`${label} must be an object of string values.`);
|
|
135
|
+
}
|
|
136
|
+
return Object.entries(record).map(([name, entry]) => ({ name, value: entry }));
|
|
137
|
+
}
|
|
138
|
+
function asRecord(value) {
|
|
139
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
140
|
+
}
|
|
141
|
+
function errorMessage(error, fallback) {
|
|
142
|
+
return error instanceof Error && error.message ? `${fallback}: ${error.message}` : fallback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/tui/session/tui-acp-client.ts
|
|
146
|
+
import { NOVA_NOTIFICATIONS } from "@datalabrotterdam/nova-ai-agent/acp/extensions.js";
|
|
147
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
148
|
+
import { dirname } from "path";
|
|
149
|
+
import {
|
|
150
|
+
spawnCommand,
|
|
151
|
+
terminateProcessTree
|
|
152
|
+
} from "@datalabrotterdam/nova-ai-agent/core/process-tree.js";
|
|
153
|
+
import { resolveWorkspaceFile } from "@datalabrotterdam/nova-ai-agent/core/tools/workspace-paths.js";
|
|
154
|
+
import {
|
|
155
|
+
isInteractionMode
|
|
156
|
+
} from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
|
|
157
|
+
import {
|
|
158
|
+
fromElicitationRequest,
|
|
159
|
+
toElicitationResponse
|
|
160
|
+
} from "@datalabrotterdam/nova-ai-agent/acp/user-questions.js";
|
|
161
|
+
var DEFAULT_OUTPUT_LIMIT = 1e5;
|
|
162
|
+
var nextMessageId = 0;
|
|
163
|
+
function uid() {
|
|
164
|
+
nextMessageId += 1;
|
|
165
|
+
return `m${nextMessageId}`;
|
|
166
|
+
}
|
|
167
|
+
var TuiAcpClient = class {
|
|
168
|
+
constructor(store, cwd, onBackgroundLifecycle, onInteractionModeChange) {
|
|
169
|
+
this.store = store;
|
|
170
|
+
this.cwd = cwd;
|
|
171
|
+
this.onBackgroundLifecycle = onBackgroundLifecycle;
|
|
172
|
+
this.onInteractionModeChange = onInteractionModeChange;
|
|
173
|
+
}
|
|
174
|
+
store;
|
|
175
|
+
cwd;
|
|
176
|
+
onBackgroundLifecycle;
|
|
177
|
+
onInteractionModeChange;
|
|
178
|
+
capabilities = {
|
|
179
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
180
|
+
terminal: true,
|
|
181
|
+
elicitation: { form: {} }
|
|
182
|
+
};
|
|
183
|
+
terminals = /* @__PURE__ */ new Map();
|
|
184
|
+
streamingMessageId = null;
|
|
185
|
+
nextTerminalId = 0;
|
|
186
|
+
/**
|
|
187
|
+
* Set while the runner loads a session whose transcript it restored itself;
|
|
188
|
+
* the agent's replay would show everything twice.
|
|
189
|
+
*/
|
|
190
|
+
muteSessionUpdates = false;
|
|
191
|
+
/** The agent's latest prompt queue (from _nova/queue/changed or a response). */
|
|
192
|
+
queueSnapshot = [];
|
|
193
|
+
/** This client as the SDK's ACP Client, for a real (in-process) connection. */
|
|
194
|
+
asAcpClient() {
|
|
195
|
+
return {
|
|
196
|
+
sessionUpdate: (params) => this.muteSessionUpdates ? Promise.resolve() : this.sessionUpdate(params),
|
|
197
|
+
requestPermission: (params) => this.requestPermission(params),
|
|
198
|
+
readTextFile: (params) => this.readTextFile(params),
|
|
199
|
+
writeTextFile: async (params) => {
|
|
200
|
+
await this.writeTextFile(params);
|
|
201
|
+
return {};
|
|
202
|
+
},
|
|
203
|
+
createTerminal: (params) => this.createTerminal(params),
|
|
204
|
+
terminalOutput: (params) => this.terminalOutput(params),
|
|
205
|
+
waitForTerminalExit: (params) => this.waitForTerminalExit(params),
|
|
206
|
+
killTerminal: async (params) => {
|
|
207
|
+
await this.killTerminal(params);
|
|
208
|
+
return {};
|
|
209
|
+
},
|
|
210
|
+
releaseTerminal: async (params) => {
|
|
211
|
+
await this.releaseTerminal(params);
|
|
212
|
+
return {};
|
|
213
|
+
},
|
|
214
|
+
unstable_createElicitation: (params) => this.createElicitation(params),
|
|
215
|
+
extNotification: (method, params) => this.handleNotification(method, params)
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
context() {
|
|
219
|
+
return {
|
|
220
|
+
request: (method, params, options) => this.handleRequest(method, params, options),
|
|
221
|
+
notify: (method, params) => this.handleNotification(method, params)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
resetStreaming() {
|
|
225
|
+
if (this.streamingMessageId) {
|
|
226
|
+
const id = this.streamingMessageId;
|
|
227
|
+
this.store.setState((state) => ({
|
|
228
|
+
messages: state.messages.map(
|
|
229
|
+
(message) => message.id === id && message.role === "assistant" ? { ...message, streaming: false } : message
|
|
230
|
+
)
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
this.streamingMessageId = null;
|
|
234
|
+
}
|
|
235
|
+
appendUserMessage(text) {
|
|
236
|
+
this.appendMessage({ id: uid(), role: "user", text });
|
|
237
|
+
}
|
|
238
|
+
appendError(text) {
|
|
239
|
+
this.appendMessage({ id: uid(), role: "error", text });
|
|
240
|
+
}
|
|
241
|
+
failPendingTools(reason) {
|
|
242
|
+
let count = 0;
|
|
243
|
+
this.store.setState((state) => ({
|
|
244
|
+
messages: state.messages.map((message) => {
|
|
245
|
+
if (message.role !== "tool" || message.call.status !== "pending")
|
|
246
|
+
return message;
|
|
247
|
+
count++;
|
|
248
|
+
const output = message.call.output ? `${message.call.output.replace(/\s+$/, "")}
|
|
249
|
+
${reason}` : reason;
|
|
250
|
+
return {
|
|
251
|
+
...message,
|
|
252
|
+
call: { ...message.call, status: "failed", output }
|
|
253
|
+
};
|
|
254
|
+
})
|
|
255
|
+
}));
|
|
256
|
+
return count;
|
|
257
|
+
}
|
|
258
|
+
async releaseAllTerminals() {
|
|
259
|
+
await Promise.all(
|
|
260
|
+
[...this.terminals.keys()].map(
|
|
261
|
+
(terminalId) => this.releaseTerminal({
|
|
262
|
+
sessionId: this.store.getState().sessionId,
|
|
263
|
+
terminalId
|
|
264
|
+
})
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
async readTextFile(params) {
|
|
269
|
+
let content = await readFile(await this.confine(params.path), "utf8");
|
|
270
|
+
if (params.line || params.limit) {
|
|
271
|
+
const start = Math.max((params.line ?? 1) - 1, 0);
|
|
272
|
+
const end = params.limit ? start + params.limit : void 0;
|
|
273
|
+
content = content.split(/\r?\n/).slice(start, end).join("\n");
|
|
274
|
+
}
|
|
275
|
+
return { content };
|
|
276
|
+
}
|
|
277
|
+
async writeTextFile(params) {
|
|
278
|
+
const path = await this.confine(params.path);
|
|
279
|
+
await mkdir(dirname(path), { recursive: true });
|
|
280
|
+
await writeFile(path, params.content, "utf8");
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Second line of defence behind the agent's own path checks: this client
|
|
284
|
+
* only touches files inside its workspace, whatever the agent asks for.
|
|
285
|
+
*/
|
|
286
|
+
async confine(path) {
|
|
287
|
+
const resolved = await resolveWorkspaceFile(this.cwd, path);
|
|
288
|
+
if ("error" in resolved) throw new Error(resolved.error);
|
|
289
|
+
return resolved.path;
|
|
290
|
+
}
|
|
291
|
+
async createTerminal(params) {
|
|
292
|
+
const terminalId = `tui-${++this.nextTerminalId}`;
|
|
293
|
+
const outputByteLimit = params.outputByteLimit ?? DEFAULT_OUTPUT_LIMIT;
|
|
294
|
+
const env = { ...process.env };
|
|
295
|
+
for (const variable of params.env ?? [])
|
|
296
|
+
env[variable.name] = variable.value;
|
|
297
|
+
const child = spawnCommand(params.command, {
|
|
298
|
+
cwd: params.cwd ?? this.cwd,
|
|
299
|
+
env,
|
|
300
|
+
args: params.args
|
|
301
|
+
});
|
|
302
|
+
const record = {
|
|
303
|
+
child,
|
|
304
|
+
command: params.command,
|
|
305
|
+
toolCallId: this.findPendingTerminalTool(params.command),
|
|
306
|
+
output: "",
|
|
307
|
+
truncated: false,
|
|
308
|
+
outputByteLimit,
|
|
309
|
+
previewTimer: null,
|
|
310
|
+
exitStatus: null,
|
|
311
|
+
exitPromise: Promise.resolve({})
|
|
312
|
+
};
|
|
313
|
+
const append = (chunk) => {
|
|
314
|
+
record.output += chunk.toString("utf8");
|
|
315
|
+
if (record.output.length > record.outputByteLimit) {
|
|
316
|
+
record.output = record.output.slice(
|
|
317
|
+
record.output.length - record.outputByteLimit
|
|
318
|
+
);
|
|
319
|
+
record.truncated = true;
|
|
320
|
+
}
|
|
321
|
+
this.scheduleTerminalPreview(record);
|
|
322
|
+
};
|
|
323
|
+
child.stdout?.on("data", append);
|
|
324
|
+
child.stderr?.on("data", append);
|
|
325
|
+
record.exitPromise = new Promise((resolve) => {
|
|
326
|
+
child.on("error", (err) => {
|
|
327
|
+
record.output += `
|
|
328
|
+
${err.message}`;
|
|
329
|
+
this.flushTerminalPreview(record);
|
|
330
|
+
record.exitStatus = { exitCode: null, signal: "error" };
|
|
331
|
+
resolve(record.exitStatus);
|
|
332
|
+
});
|
|
333
|
+
child.on("close", (exitCode, signal) => {
|
|
334
|
+
this.flushTerminalPreview(record);
|
|
335
|
+
record.exitStatus = { exitCode, signal };
|
|
336
|
+
resolve(record.exitStatus);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
this.terminals.set(terminalId, record);
|
|
340
|
+
return { terminalId };
|
|
341
|
+
}
|
|
342
|
+
async terminalOutput(params) {
|
|
343
|
+
const record = this.requireTerminal(params.terminalId);
|
|
344
|
+
return {
|
|
345
|
+
output: record.output,
|
|
346
|
+
truncated: record.truncated,
|
|
347
|
+
exitStatus: record.exitStatus
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
async waitForTerminalExit(params) {
|
|
351
|
+
return this.requireTerminal(params.terminalId).exitPromise;
|
|
352
|
+
}
|
|
353
|
+
async killTerminal(params) {
|
|
354
|
+
const record = this.requireTerminal(params.terminalId);
|
|
355
|
+
if (!record.exitStatus) await terminateProcessTree(record.child);
|
|
356
|
+
}
|
|
357
|
+
async releaseTerminal(params) {
|
|
358
|
+
const record = this.terminals.get(params.terminalId);
|
|
359
|
+
if (!record) return;
|
|
360
|
+
if (record.previewTimer) clearTimeout(record.previewTimer);
|
|
361
|
+
this.terminals.delete(params.terminalId);
|
|
362
|
+
if (!record.exitStatus) await terminateProcessTree(record.child);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Only shows the agent's question: rules, modes and remembered approvals
|
|
366
|
+
* are the agent's permission policy, so every ACP client behaves the same.
|
|
367
|
+
*/
|
|
368
|
+
async requestPermission(params) {
|
|
369
|
+
const toolName = toolNameOf(params.toolCall) ?? "tool";
|
|
370
|
+
const kind = params.toolCall.kind ?? "other";
|
|
371
|
+
const args = toRecord(params.toolCall.rawInput);
|
|
372
|
+
return new Promise((resolve) => {
|
|
373
|
+
this.store.setState({
|
|
374
|
+
pendingPermission: {
|
|
375
|
+
toolCallId: params.toolCall.toolCallId,
|
|
376
|
+
toolName,
|
|
377
|
+
title: params.toolCall.title ?? toolName,
|
|
378
|
+
kind,
|
|
379
|
+
args,
|
|
380
|
+
resolve: (allow, scope) => {
|
|
381
|
+
this.store.setState({ pendingPermission: null });
|
|
382
|
+
const optionId = choosePermissionOption(params.options, allow, scope);
|
|
383
|
+
resolve(
|
|
384
|
+
optionId ? selected(optionId) : { outcome: { outcome: "cancelled" } }
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async createElicitation(params, signal) {
|
|
392
|
+
const request = fromElicitationRequest(params);
|
|
393
|
+
if (!request) return { action: "decline" };
|
|
394
|
+
const id = `question-${uid()}`;
|
|
395
|
+
return new Promise((resolve) => {
|
|
396
|
+
let settled = false;
|
|
397
|
+
const finish = (response) => {
|
|
398
|
+
if (settled) return;
|
|
399
|
+
settled = true;
|
|
400
|
+
signal?.removeEventListener("abort", abort);
|
|
401
|
+
if (this.store.getState().pendingQuestion?.id === id) {
|
|
402
|
+
this.store.setState({ pendingQuestion: null });
|
|
403
|
+
}
|
|
404
|
+
resolve(toElicitationResponse(request, response));
|
|
405
|
+
};
|
|
406
|
+
const abort = () => finish({ action: "cancel", answers: [] });
|
|
407
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
408
|
+
if (signal?.aborted) {
|
|
409
|
+
abort();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.store.setState({
|
|
413
|
+
pendingQuestion: { id, request, resolve: finish }
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
async sessionUpdate(params) {
|
|
418
|
+
const update = params.update;
|
|
419
|
+
switch (update.sessionUpdate) {
|
|
420
|
+
case "user_message_chunk":
|
|
421
|
+
if (update.content.type === "text") {
|
|
422
|
+
this.resetStreaming();
|
|
423
|
+
this.appendUserMessage(update.content.text);
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
case "agent_message_chunk":
|
|
427
|
+
if (update.content.type === "text")
|
|
428
|
+
this.appendStreamingText(update.content.text);
|
|
429
|
+
return;
|
|
430
|
+
case "agent_thought_chunk":
|
|
431
|
+
if (update.content.type === "text" && update.content.text.trim()) {
|
|
432
|
+
this.resetStreaming();
|
|
433
|
+
this.appendMessage({
|
|
434
|
+
id: uid(),
|
|
435
|
+
role: "assistant",
|
|
436
|
+
text: update.content.text,
|
|
437
|
+
streaming: false
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
case "current_mode_update":
|
|
442
|
+
if (isInteractionMode(update.currentModeId)) {
|
|
443
|
+
this.onInteractionModeChange?.(update.currentModeId);
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
case "plan":
|
|
447
|
+
this.store.setState({
|
|
448
|
+
plan: update.entries.map(
|
|
449
|
+
({ content, priority, status }) => ({
|
|
450
|
+
content,
|
|
451
|
+
priority,
|
|
452
|
+
status
|
|
453
|
+
})
|
|
454
|
+
)
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
case "tool_call":
|
|
458
|
+
this.resetStreaming();
|
|
459
|
+
if (toolNameOf(update) === "update_plan") return;
|
|
460
|
+
this.appendMessage({
|
|
461
|
+
id: uid(),
|
|
462
|
+
role: "tool",
|
|
463
|
+
call: {
|
|
464
|
+
toolCallId: update.toolCallId,
|
|
465
|
+
name: toolNameOf(update) ?? update.title,
|
|
466
|
+
mutating: typeof update._meta?.["nova-ai-cli/mutating"] === "boolean" ? update._meta["nova-ai-cli/mutating"] : update.kind !== "read" && update.kind !== "search",
|
|
467
|
+
kind: update.kind ?? "other",
|
|
468
|
+
args: toRecord(update.rawInput),
|
|
469
|
+
status: normalizeToolStatus(update.status),
|
|
470
|
+
output: null,
|
|
471
|
+
diff: null
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
case "tool_call_update":
|
|
476
|
+
this.patchToolCall(update.toolCallId, {
|
|
477
|
+
status: normalizeToolStatus(update.status),
|
|
478
|
+
output: extractToolOutput(update),
|
|
479
|
+
diff: extractToolDiff(update)
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
482
|
+
default:
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async backgroundUpdate(params) {
|
|
487
|
+
if (!params || typeof params !== "object") return;
|
|
488
|
+
const event = "event" in params ? String(params.event) : "background";
|
|
489
|
+
const job = parseBackgroundJob("job" in params ? params.job : null);
|
|
490
|
+
if (!job) return;
|
|
491
|
+
const preview = job.outputPath && event !== "started" ? await readPreview(job.outputPath) : "";
|
|
492
|
+
this.upsertBackgroundMessage({ ...job, preview });
|
|
493
|
+
this.store.setState({ statusLine: `Background ${event}: ${job.title}` });
|
|
494
|
+
await this.onBackgroundLifecycle?.(event, job);
|
|
495
|
+
}
|
|
496
|
+
queueChanged(params) {
|
|
497
|
+
if (!params || typeof params !== "object") return;
|
|
498
|
+
const rawEntries = "entries" in params && Array.isArray(params.entries) ? params.entries : [];
|
|
499
|
+
const entries = rawEntries.flatMap((value) => {
|
|
500
|
+
if (!value || typeof value !== "object") return [];
|
|
501
|
+
const entry = value;
|
|
502
|
+
if (typeof entry.id !== "string" || typeof entry.text !== "string" || entry.kind !== "steer" && entry.kind !== "followup") {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
return [{ id: entry.id, text: entry.text, kind: entry.kind }];
|
|
506
|
+
});
|
|
507
|
+
this.queueSnapshot = rawEntries.filter(
|
|
508
|
+
(value) => !!value && typeof value === "object" && typeof value.id === "string" && typeof value.text === "string"
|
|
509
|
+
);
|
|
510
|
+
this.store.setState((state) => {
|
|
511
|
+
const transcript = state.messages.filter(
|
|
512
|
+
(message) => !(message.role === "user" && message.queued)
|
|
513
|
+
);
|
|
514
|
+
const queued = entries.map((entry) => ({
|
|
515
|
+
id: entry.id,
|
|
516
|
+
role: "user",
|
|
517
|
+
text: entry.text,
|
|
518
|
+
queued: entry.kind
|
|
519
|
+
}));
|
|
520
|
+
return {
|
|
521
|
+
messages: [...transcript, ...queued],
|
|
522
|
+
queuedCount: queued.length
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
async handleRequest(method, params, options) {
|
|
527
|
+
options?.cancellationSignal?.throwIfAborted();
|
|
528
|
+
switch (method) {
|
|
529
|
+
case "fs/read_text_file":
|
|
530
|
+
return this.readTextFile(params);
|
|
531
|
+
case "fs/write_text_file":
|
|
532
|
+
return this.writeTextFile(params);
|
|
533
|
+
case "terminal/create":
|
|
534
|
+
return this.createTerminal(params);
|
|
535
|
+
case "terminal/output":
|
|
536
|
+
return this.terminalOutput(params);
|
|
537
|
+
case "terminal/wait_for_exit":
|
|
538
|
+
return this.waitForTerminalExit(params);
|
|
539
|
+
case "terminal/kill":
|
|
540
|
+
return this.killTerminal(params);
|
|
541
|
+
case "terminal/release":
|
|
542
|
+
return this.releaseTerminal(params);
|
|
543
|
+
case "session/request_permission":
|
|
544
|
+
return this.requestPermission(params);
|
|
545
|
+
case "elicitation/create":
|
|
546
|
+
return this.createElicitation(
|
|
547
|
+
params,
|
|
548
|
+
options?.cancellationSignal
|
|
549
|
+
);
|
|
550
|
+
default:
|
|
551
|
+
throw new Error(`Unsupported ACP client request: ${method}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async handleNotification(method, params) {
|
|
555
|
+
switch (method) {
|
|
556
|
+
case "session/update":
|
|
557
|
+
return this.sessionUpdate(params);
|
|
558
|
+
case NOVA_NOTIFICATIONS.backgroundUpdate:
|
|
559
|
+
return this.backgroundUpdate(params);
|
|
560
|
+
case NOVA_NOTIFICATIONS.queueChanged:
|
|
561
|
+
return this.queueChanged(params);
|
|
562
|
+
default:
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
requireTerminal(terminalId) {
|
|
567
|
+
const record = this.terminals.get(terminalId);
|
|
568
|
+
if (!record) throw new Error(`Terminal ${terminalId} not found`);
|
|
569
|
+
return record;
|
|
570
|
+
}
|
|
571
|
+
findPendingTerminalTool(command) {
|
|
572
|
+
const messages = this.store.getState().messages;
|
|
573
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
574
|
+
const message = messages[index];
|
|
575
|
+
if (!message || message.role !== "tool" || message.call.status !== "pending")
|
|
576
|
+
continue;
|
|
577
|
+
if (message.call.name === "run_package_script")
|
|
578
|
+
return message.call.toolCallId;
|
|
579
|
+
if (message.call.name === "run_command" && message.call.args.command === command)
|
|
580
|
+
return message.call.toolCallId;
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
scheduleTerminalPreview(record) {
|
|
585
|
+
if (!record.toolCallId || record.previewTimer) return;
|
|
586
|
+
record.previewTimer = setTimeout(() => {
|
|
587
|
+
record.previewTimer = null;
|
|
588
|
+
this.patchToolCall(record.toolCallId, { output: record.output });
|
|
589
|
+
}, 50);
|
|
590
|
+
record.previewTimer.unref?.();
|
|
591
|
+
}
|
|
592
|
+
flushTerminalPreview(record) {
|
|
593
|
+
if (record.previewTimer) {
|
|
594
|
+
clearTimeout(record.previewTimer);
|
|
595
|
+
record.previewTimer = null;
|
|
596
|
+
}
|
|
597
|
+
if (record.toolCallId && record.output)
|
|
598
|
+
this.patchToolCall(record.toolCallId, { output: record.output });
|
|
599
|
+
}
|
|
600
|
+
appendStreamingText(text) {
|
|
601
|
+
if (!this.streamingMessageId) {
|
|
602
|
+
const id = uid();
|
|
603
|
+
this.streamingMessageId = id;
|
|
604
|
+
this.appendMessage({ id, role: "assistant", text, streaming: true });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
this.store.setState((state) => ({
|
|
608
|
+
messages: state.messages.map(
|
|
609
|
+
(message) => message.id === this.streamingMessageId && message.role === "assistant" ? { ...message, text: message.text + text } : message
|
|
610
|
+
)
|
|
611
|
+
}));
|
|
612
|
+
}
|
|
613
|
+
patchToolCall(toolCallId, patch) {
|
|
614
|
+
this.store.setState((state) => ({
|
|
615
|
+
messages: state.messages.map(
|
|
616
|
+
(message) => message.role === "tool" && message.call.toolCallId === toolCallId ? { ...message, call: { ...message.call, ...patch } } : message
|
|
617
|
+
)
|
|
618
|
+
}));
|
|
619
|
+
}
|
|
620
|
+
appendMessage(message) {
|
|
621
|
+
this.store.setState((state) => ({
|
|
622
|
+
messages: insertBeforeQueuedMessages(state.messages, message)
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
upsertBackgroundMessage(job) {
|
|
626
|
+
this.store.setState((state) => {
|
|
627
|
+
const existing = state.messages.find(
|
|
628
|
+
(message) => message.role === "background" && message.job.jobId === job.jobId
|
|
629
|
+
);
|
|
630
|
+
if (!existing)
|
|
631
|
+
return {
|
|
632
|
+
messages: insertBeforeQueuedMessages(state.messages, {
|
|
633
|
+
id: uid(),
|
|
634
|
+
role: "background",
|
|
635
|
+
job
|
|
636
|
+
})
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
messages: state.messages.map(
|
|
640
|
+
(message) => message.role === "background" && message.job.jobId === job.jobId ? { ...message, job } : message
|
|
641
|
+
)
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
function insertBeforeQueuedMessages(messages, message) {
|
|
647
|
+
const queuedIndex = messages.findIndex(
|
|
648
|
+
(item) => item.role === "user" && item.queued !== void 0
|
|
649
|
+
);
|
|
650
|
+
if (queuedIndex < 0) return [...messages, message];
|
|
651
|
+
return [
|
|
652
|
+
...messages.slice(0, queuedIndex),
|
|
653
|
+
message,
|
|
654
|
+
...messages.slice(queuedIndex)
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
function choosePermissionOption(options, allow, scope) {
|
|
658
|
+
const preferred = !allow ? ["reject_once"] : scope === "always" ? ["allow_always", "allow_session", "allow_once"] : scope === "session" ? ["allow_session", "allow_once"] : ["allow_once"];
|
|
659
|
+
for (const id of preferred) {
|
|
660
|
+
const byId = options.find((option) => option.optionId === id);
|
|
661
|
+
if (byId) return byId.optionId;
|
|
662
|
+
}
|
|
663
|
+
const kinds = !allow ? ["reject_once", "reject_always"] : scope === "once" ? ["allow_once"] : ["allow_always", "allow_once"];
|
|
664
|
+
for (const kind of kinds) {
|
|
665
|
+
const byKind = options.find((option) => option.kind === kind);
|
|
666
|
+
if (byKind) return byKind.optionId;
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
function toolNameOf(call) {
|
|
671
|
+
const fromMeta = call._meta?.["nova-ai-cli/tool"];
|
|
672
|
+
return typeof fromMeta === "string" ? fromMeta : call.title ?? void 0;
|
|
673
|
+
}
|
|
674
|
+
function selected(optionId) {
|
|
675
|
+
return { outcome: { outcome: "selected", optionId } };
|
|
676
|
+
}
|
|
677
|
+
function toRecord(value) {
|
|
678
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
679
|
+
}
|
|
680
|
+
function normalizeToolStatus(status) {
|
|
681
|
+
if (status === "completed" || status === "failed") return status;
|
|
682
|
+
return "pending";
|
|
683
|
+
}
|
|
684
|
+
function extractToolOutput(update) {
|
|
685
|
+
if (typeof update.rawOutput === "object" && update.rawOutput && "output" in update.rawOutput) {
|
|
686
|
+
return String(update.rawOutput.output ?? "");
|
|
687
|
+
}
|
|
688
|
+
const content = update.content?.find(
|
|
689
|
+
(item) => item.type === "content" && item.content.type === "text"
|
|
690
|
+
);
|
|
691
|
+
return content && content.type === "content" && content.content.type === "text" ? content.content.text : null;
|
|
692
|
+
}
|
|
693
|
+
function extractToolDiff(update) {
|
|
694
|
+
const diff = update.content?.find((item) => item.type === "diff");
|
|
695
|
+
if (!diff || diff.type !== "diff") return null;
|
|
696
|
+
return {
|
|
697
|
+
path: diff.path,
|
|
698
|
+
oldText: diff.oldText ?? null,
|
|
699
|
+
newText: diff.newText
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function parseBackgroundJob(value) {
|
|
703
|
+
if (!value || typeof value !== "object") return null;
|
|
704
|
+
const job = value;
|
|
705
|
+
const jobId = typeof job.jobId === "string" ? job.jobId : null;
|
|
706
|
+
const kind = job.kind === "terminal" || job.kind === "prompt" ? job.kind : null;
|
|
707
|
+
const title = typeof job.title === "string" ? job.title : "background job";
|
|
708
|
+
const status = job.status === "running" || job.status === "completed" || job.status === "failed" || job.status === "killed" || job.status === "released" ? job.status : null;
|
|
709
|
+
if (!jobId || !kind || !status) return null;
|
|
710
|
+
return {
|
|
711
|
+
jobId,
|
|
712
|
+
kind,
|
|
713
|
+
title,
|
|
714
|
+
status,
|
|
715
|
+
outputPath: typeof job.outputPath === "string" ? job.outputPath : void 0,
|
|
716
|
+
preview: ""
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
async function readPreview(path) {
|
|
720
|
+
try {
|
|
721
|
+
const raw = await readFile(path, "utf8");
|
|
722
|
+
return raw.split(/\r?\n/).slice(0, 12).join("\n");
|
|
723
|
+
} catch {
|
|
724
|
+
return "";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/tui/state/store.ts
|
|
729
|
+
function createStore(initial) {
|
|
730
|
+
let state = initial;
|
|
731
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
732
|
+
return {
|
|
733
|
+
getState() {
|
|
734
|
+
return state;
|
|
735
|
+
},
|
|
736
|
+
setState(update) {
|
|
737
|
+
const partial = typeof update === "function" ? update(state) : update;
|
|
738
|
+
state = { ...state, ...partial };
|
|
739
|
+
for (const listener of listeners) listener();
|
|
740
|
+
},
|
|
741
|
+
subscribe(listener) {
|
|
742
|
+
listeners.add(listener);
|
|
743
|
+
return () => listeners.delete(listener);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export {
|
|
749
|
+
readWorkspaceMcpConfiguration,
|
|
750
|
+
workspaceNeedsTrust,
|
|
751
|
+
TuiAcpClient,
|
|
752
|
+
choosePermissionOption,
|
|
753
|
+
createStore
|
|
754
|
+
};
|