genaicode 2.14.0 → 2.15.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/CHANGELOG.md +23 -0
- package/README.md +70 -0
- package/dist/agents/drivers/claude.d.ts +5 -0
- package/dist/agents/drivers/claude.js +12 -4
- package/dist/agents/drivers/claude.js.map +1 -1
- package/dist/agents/drivers/codex.d.ts +34 -2
- package/dist/agents/drivers/codex.js +83 -8
- package/dist/agents/drivers/codex.js.map +1 -1
- package/dist/agents/drivers/copilot.d.ts +13 -0
- package/dist/agents/drivers/copilot.js +26 -1
- package/dist/agents/drivers/copilot.js.map +1 -1
- package/dist/agents/drivers/mcp-isolation.d.ts +9 -0
- package/dist/agents/drivers/mcp-isolation.js +24 -0
- package/dist/agents/drivers/mcp-isolation.js.map +1 -0
- package/dist/agents/prepare.d.ts +2 -0
- package/dist/agents/prepare.js +2 -1
- package/dist/agents/prepare.js.map +1 -1
- package/dist/agents/session.d.ts +12 -2
- package/dist/agents/session.js +53 -9
- package/dist/agents/session.js.map +1 -1
- package/dist/app-agent/app-agent.d.ts +133 -0
- package/dist/app-agent/app-agent.js +678 -0
- package/dist/app-agent/app-agent.js.map +1 -0
- package/dist/app-agent/git.d.ts +25 -0
- package/dist/app-agent/git.js +286 -0
- package/dist/app-agent/git.js.map +1 -0
- package/dist/app-agent/http.d.ts +41 -0
- package/dist/app-agent/http.js +228 -0
- package/dist/app-agent/http.js.map +1 -0
- package/dist/app-agent/json.d.ts +7 -0
- package/dist/app-agent/json.js +62 -0
- package/dist/app-agent/json.js.map +1 -0
- package/dist/app-agent/mcp.d.ts +15 -0
- package/dist/app-agent/mcp.js +53 -0
- package/dist/app-agent/mcp.js.map +1 -0
- package/dist/app-agent/policy.d.ts +13 -0
- package/dist/app-agent/policy.js +97 -0
- package/dist/app-agent/policy.js.map +1 -0
- package/dist/app-agent/state.d.ts +75 -0
- package/dist/app-agent/state.js +10 -0
- package/dist/app-agent/state.js.map +1 -0
- package/dist/app-agent.d.ts +8 -0
- package/dist/app-agent.js +5 -0
- package/dist/app-agent.js.map +1 -0
- package/dist/react/agent-chat.css +137 -0
- package/dist/react/app-agent-chat.d.ts +55 -0
- package/dist/react/app-agent-chat.js +229 -0
- package/dist/react/app-agent-chat.js.map +1 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -1
- package/dist/ui/index.js +59 -59
- package/dist/ui/web-client.js +148 -11
- package/package.json +5 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { detectAgents } from '../agents/discovery.js';
|
|
7
|
+
import { MCP_NAME } from '../agents/prepare.js';
|
|
8
|
+
import { createAgentSession } from '../agents/session.js';
|
|
9
|
+
import { commitTree, snapshotTree, treeChanges } from './git.js';
|
|
10
|
+
import { safeJson } from './json.js';
|
|
11
|
+
import { defaultAppAgents } from './policy.js';
|
|
12
|
+
import { APP_NOTE, PROMPT_MARKER, userText, } from './state.js';
|
|
13
|
+
/**
|
|
14
|
+
* A tool the app gives its agent. Read tools run when the agent calls them. Write tools
|
|
15
|
+
* (`write: true`) only create an action: the user approves or rejects it in the app, and the
|
|
16
|
+
* agent hears the outcome as its next message.
|
|
17
|
+
*/
|
|
18
|
+
const APPROVAL_SUMMARY = 'approval_summary';
|
|
19
|
+
export class AppAgentError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(message, status) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const MAX_ACTIONS = 50;
|
|
27
|
+
const MAX_LINKS = 6;
|
|
28
|
+
const MAX_HISTORY = 100;
|
|
29
|
+
const CARRY_MAX = 12_000;
|
|
30
|
+
const STOP_WAIT_MS = 10_000;
|
|
31
|
+
const COPILOT_KINDS = ['read', 'write', 'shell', 'url', 'memory'];
|
|
32
|
+
/**
|
|
33
|
+
* A coding agent inside a local app: the user chats with an agent CLI from their machine
|
|
34
|
+
* (Claude Code, Codex, Copilot...), the agent uses the app through tools the app serves over
|
|
35
|
+
* MCP, writes wait for the user's approval, and in `dev` mode the agent also changes the
|
|
36
|
+
* app's code and proposes a commit of exactly the reviewed tree.
|
|
37
|
+
*
|
|
38
|
+
* This is the state and logic only; `createAppAgentHandler` serves it over HTTP.
|
|
39
|
+
*/
|
|
40
|
+
export function createAppAgent(options) {
|
|
41
|
+
const names = new Set(['app_view', 'app_links', 'app_open', 'propose_commit']);
|
|
42
|
+
for (const tool of options.tools ?? []) {
|
|
43
|
+
if (names.has(tool.name))
|
|
44
|
+
throw new Error(`tool name "${tool.name}" is taken`);
|
|
45
|
+
names.add(tool.name);
|
|
46
|
+
const properties = (tool.inputSchema?.properties ?? {});
|
|
47
|
+
if (tool.write && APPROVAL_SUMMARY in properties) {
|
|
48
|
+
throw new Error(`write tool "${tool.name}" cannot take an argument named ${APPROVAL_SUMMARY}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const now = options.now ?? Date.now;
|
|
52
|
+
const env = options.env ?? process.env;
|
|
53
|
+
const serverName = options.serverName ?? 'app';
|
|
54
|
+
// Checked here, not on the first turn: every driver refuses other names.
|
|
55
|
+
if (!MCP_NAME.test(serverName))
|
|
56
|
+
throw new Error(`serverName must match ${MCP_NAME}: ${JSON.stringify(serverName)}`);
|
|
57
|
+
// Copilot reads these bare names as permission kinds, so its rules would not name the server.
|
|
58
|
+
if (COPILOT_KINDS.includes(serverName))
|
|
59
|
+
throw new Error(`serverName "${serverName}" is a Copilot permission kind`);
|
|
60
|
+
// A dropped session's process may still be winding down: its late calls must not reach the app.
|
|
61
|
+
const newToken = () => randomBytes(24).toString('base64url');
|
|
62
|
+
let mcpToken = newToken();
|
|
63
|
+
const modes = options.dev ? ['use', 'dev'] : ['use'];
|
|
64
|
+
const agentsFor = (target) => options.agents?.(target) ?? defaultAppAgents(target, options.dev?.commands ?? []);
|
|
65
|
+
// PATH lookups once per mode, not on every streamed update.
|
|
66
|
+
const found = new Map();
|
|
67
|
+
const installed = (target) => {
|
|
68
|
+
let list = found.get(target);
|
|
69
|
+
if (!list) {
|
|
70
|
+
list = detectAgents(agentsFor(target), env).map(({ agent, path: where }) => ({
|
|
71
|
+
name: agent.name,
|
|
72
|
+
installed: where !== null,
|
|
73
|
+
}));
|
|
74
|
+
found.set(target, list);
|
|
75
|
+
}
|
|
76
|
+
return list;
|
|
77
|
+
};
|
|
78
|
+
let mode = 'use';
|
|
79
|
+
const startList = installed(mode);
|
|
80
|
+
let agentName = options.agent ?? startList.find((choice) => choice.installed)?.name ?? startList[0]?.name ?? 'claude';
|
|
81
|
+
if (!startList.some((choice) => choice.name === agentName)) {
|
|
82
|
+
throw new AppAgentError(`Unknown agent "${agentName}".`, 400);
|
|
83
|
+
}
|
|
84
|
+
let session = null;
|
|
85
|
+
let unsubscribeSession = null;
|
|
86
|
+
let briefed = false;
|
|
87
|
+
let useCwd = null;
|
|
88
|
+
let actions = [];
|
|
89
|
+
let nextAction = 1;
|
|
90
|
+
let view = null;
|
|
91
|
+
let links = [];
|
|
92
|
+
let navigation = null;
|
|
93
|
+
let history = [];
|
|
94
|
+
let nextArchivedId = -1;
|
|
95
|
+
let carryOver = '';
|
|
96
|
+
// The user's prompts sent while an action runs: they follow its outcome, never overtake it.
|
|
97
|
+
let held = [];
|
|
98
|
+
// The turn each commit was proposed in: approving one stops that turn, never a newer prompt.
|
|
99
|
+
// Turn ids restart in a new session (after a switch), so the session is part of the key.
|
|
100
|
+
const proposedIn = new Map();
|
|
101
|
+
const runningTurn = () => (session?.get().status === 'running' ? session.get().turns.at(-1)?.id : undefined);
|
|
102
|
+
// The session's own queue, kept aside while a commit stops its turn; a user stop drops it too.
|
|
103
|
+
let saved = [];
|
|
104
|
+
let closed = false;
|
|
105
|
+
let traced = new Set();
|
|
106
|
+
// Logging only observes: a failing sink must not change what happens (an action's status).
|
|
107
|
+
const trace = (record) => {
|
|
108
|
+
try {
|
|
109
|
+
// An async sink's rejection is dropped too, never left unhandled.
|
|
110
|
+
const result = options.trace?.(record);
|
|
111
|
+
if (result instanceof Promise)
|
|
112
|
+
result.catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// dropped
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const listeners = new Set();
|
|
119
|
+
const get = () => ({
|
|
120
|
+
name: options.name,
|
|
121
|
+
mode,
|
|
122
|
+
modes,
|
|
123
|
+
agent: agentName,
|
|
124
|
+
agents: installed(mode),
|
|
125
|
+
session: session && (saved.length || held.length)
|
|
126
|
+
? { ...session.get(), queued: [...saved, ...session.get().queued, ...held] }
|
|
127
|
+
: (session?.get() ?? null),
|
|
128
|
+
history,
|
|
129
|
+
actions,
|
|
130
|
+
links,
|
|
131
|
+
view,
|
|
132
|
+
navigation,
|
|
133
|
+
});
|
|
134
|
+
const changed = () => {
|
|
135
|
+
traceTurns();
|
|
136
|
+
const state = get();
|
|
137
|
+
for (const listener of [...listeners]) {
|
|
138
|
+
try {
|
|
139
|
+
listener(state);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Keep other subscribers going.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const traceTurns = () => {
|
|
147
|
+
if (!options.trace)
|
|
148
|
+
return;
|
|
149
|
+
for (const turn of session?.get().turns ?? []) {
|
|
150
|
+
if (!turn.result || traced.has(turn.id))
|
|
151
|
+
continue;
|
|
152
|
+
const record = {
|
|
153
|
+
kind: 'turn',
|
|
154
|
+
agent: turn.agent,
|
|
155
|
+
mode,
|
|
156
|
+
turn: turn.id,
|
|
157
|
+
status: turn.result.status,
|
|
158
|
+
ok: turn.result.ok,
|
|
159
|
+
...(turn.result.error ? { error: turn.result.error } : {}),
|
|
160
|
+
ms: (turn.endedAt ?? now()) - turn.startedAt,
|
|
161
|
+
...(turn.costUsd !== undefined ? { costUsd: turn.costUsd } : {}),
|
|
162
|
+
entries: turn.entries.map((entry) => entry.kind === 'tool'
|
|
163
|
+
? {
|
|
164
|
+
...entry,
|
|
165
|
+
input: clip(safeJson(entry.input), 1_000),
|
|
166
|
+
output: clip(entry.output ?? '', 2_000),
|
|
167
|
+
}
|
|
168
|
+
: entry.kind === 'text'
|
|
169
|
+
? { ...entry, text: clip(entry.text, 4_000) }
|
|
170
|
+
: entry),
|
|
171
|
+
};
|
|
172
|
+
traced.add(turn.id);
|
|
173
|
+
trace(record);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const cwdFor = (target) => {
|
|
177
|
+
if (target === 'dev')
|
|
178
|
+
return options.dev.cwd;
|
|
179
|
+
// No files to touch in use mode: an empty directory keeps the agent on the app's tools.
|
|
180
|
+
if (!useCwd) {
|
|
181
|
+
useCwd = mkdtempSync(path.join(tmpdir(), 'genaicode-app-agent-'));
|
|
182
|
+
// Copilot reads hooks from the user's config too; its repository settings switch them all off.
|
|
183
|
+
mkdirSync(path.join(useCwd, '.github', 'copilot'), { recursive: true });
|
|
184
|
+
writeFileSync(path.join(useCwd, '.github', 'copilot', 'settings.json'), '{ "disableAllHooks": true }\n');
|
|
185
|
+
}
|
|
186
|
+
return useCwd;
|
|
187
|
+
};
|
|
188
|
+
const ensureSession = () => {
|
|
189
|
+
if (session)
|
|
190
|
+
return session;
|
|
191
|
+
const agent = agentsFor(mode).find((candidate) => candidate.name === agentName);
|
|
192
|
+
if (!agent)
|
|
193
|
+
throw new AppAgentError(`Agent "${agentName}" is not offered in ${mode} mode.`, 400);
|
|
194
|
+
const url = typeof options.mcpUrl === 'function' ? options.mcpUrl() : options.mcpUrl;
|
|
195
|
+
session = createAgentSession({
|
|
196
|
+
agent,
|
|
197
|
+
cwd: cwdFor(mode),
|
|
198
|
+
task: {
|
|
199
|
+
env,
|
|
200
|
+
mcpServers: [{ name: serverName, url, headers: { authorization: `Bearer ${mcpToken}` } }],
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
unsubscribeSession = session.subscribe(changed);
|
|
204
|
+
briefed = false;
|
|
205
|
+
traced = new Set();
|
|
206
|
+
trace({ kind: 'session', agent: agentName, mode });
|
|
207
|
+
return session;
|
|
208
|
+
};
|
|
209
|
+
// The turns so far stay in the conversation; negative ids keep their chips attached.
|
|
210
|
+
const archiveSession = () => {
|
|
211
|
+
const turns = session?.get().turns ?? [];
|
|
212
|
+
if (turns.length === 0)
|
|
213
|
+
return;
|
|
214
|
+
const ids = new Map(turns.map((turn) => [turn.id, nextArchivedId--]));
|
|
215
|
+
history = [...history, ...turns.map((turn) => ({ ...turn, id: ids.get(turn.id) }))].slice(-MAX_HISTORY);
|
|
216
|
+
links = links.flatMap((link) => {
|
|
217
|
+
if (link.turnId < 0)
|
|
218
|
+
return [link];
|
|
219
|
+
const id = ids.get(link.turnId);
|
|
220
|
+
return id === undefined ? [] : [{ ...link, turnId: id }];
|
|
221
|
+
});
|
|
222
|
+
carryOver = clip(`${carryOver}\n\n${transcript(turns)}`.trim(), CARRY_MAX, true);
|
|
223
|
+
};
|
|
224
|
+
// Notes the old session still held (the user stopped its turn) go to the next agent.
|
|
225
|
+
const archiveNotes = () => {
|
|
226
|
+
for (const note of session?.get().notes ?? []) {
|
|
227
|
+
carryOver = clip(`${carryOver}\n\n${userText(note)}`.trim(), CARRY_MAX, true);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const dropSession = () => {
|
|
231
|
+
traceTurns();
|
|
232
|
+
links = links.filter((link) => link.turnId < 0);
|
|
233
|
+
unsubscribeSession?.();
|
|
234
|
+
session?.close();
|
|
235
|
+
session = null;
|
|
236
|
+
unsubscribeSession = null;
|
|
237
|
+
briefed = false;
|
|
238
|
+
mcpToken = newToken();
|
|
239
|
+
};
|
|
240
|
+
const briefing = () => {
|
|
241
|
+
const tool = (name) => `\`${name}\``;
|
|
242
|
+
const lines = [
|
|
243
|
+
`You are the assistant inside ${options.name}, a local app on the user's machine.`,
|
|
244
|
+
`Use the \`${serverName}\` MCP tools to read from and act in the app.`,
|
|
245
|
+
'Tools that make changes do not run at once: the user approves or rejects each one in the app,',
|
|
246
|
+
'and you get a message with the outcome. Never say a change happened before that message.',
|
|
247
|
+
'Answer in the language the user writes in. Be brief and lead with the answer.',
|
|
248
|
+
];
|
|
249
|
+
if (options.pages) {
|
|
250
|
+
lines.push(`Prompts start with the page the user has open; ${tool('app_view')} reads it again.`, `When a page of the app shows what you mean, offer it with ${tool('app_links')} chips.`, 'Chips appear under your reply on their own; do not point at them.', `Call ${tool('app_open')} only when the user asks you to take them somewhere.`);
|
|
251
|
+
}
|
|
252
|
+
if (mode === 'dev' && options.dev) {
|
|
253
|
+
const commands = options.dev.commands ?? [];
|
|
254
|
+
lines.push('', `Dev mode: your cwd is ${options.dev.cwd}; you may change the app's own code there.`, ...(commands.length ? [`You may run: ${commands.join(', ')}.`] : []), 'Do not commit or push. When the change is ready, call `propose_commit` with a commit message;', 'the user reviews the diff and approves it.', ...(options.dev.instructions ? [options.dev.instructions] : []));
|
|
255
|
+
}
|
|
256
|
+
const extra = typeof options.instructions === 'function' ? options.instructions(mode) : options.instructions;
|
|
257
|
+
if (extra)
|
|
258
|
+
lines.push('', extra);
|
|
259
|
+
return lines.join('\n');
|
|
260
|
+
};
|
|
261
|
+
// App notes go ahead of queued prompts and survive a stop: an action's outcome is the agent's
|
|
262
|
+
// next message.
|
|
263
|
+
const deliver = (text, withView, note = false, queue = false) => {
|
|
264
|
+
if (closed)
|
|
265
|
+
return;
|
|
266
|
+
const target = ensureSession();
|
|
267
|
+
const seen = withView && view ? `[The user has ${view} open]` : '';
|
|
268
|
+
if (!briefed) {
|
|
269
|
+
briefed = true;
|
|
270
|
+
const earlier = carryOver ? `\n\nEarlier in this conversation, with another agent or mode:\n${carryOver}` : '';
|
|
271
|
+
target.send(`${briefing()}${earlier}${seen ? `\n\n${seen}` : ''}${PROMPT_MARKER}${text}`, { note, queue });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
target.send(seen ? `${seen}${PROMPT_MARKER}${text}` : text, { note, queue });
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
// App notes reach an agent that is already talking; they never start a conversation.
|
|
278
|
+
const tell = (text) => {
|
|
279
|
+
if (!session) {
|
|
280
|
+
// The conversation moved to another agent or mode: the next one hears it in its briefing.
|
|
281
|
+
if (carryOver)
|
|
282
|
+
carryOver = clip(`${carryOver}\n\n${APP_NOTE}${text}`, CARRY_MAX, true);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
trace({ kind: 'note', text: clip(text, 2_000) });
|
|
286
|
+
deliver(`${APP_NOTE}${text}`, false, true);
|
|
287
|
+
};
|
|
288
|
+
const updateAction = (id, patch) => {
|
|
289
|
+
actions = actions.map((action) => (action.id === id ? { ...action, ...patch } : action));
|
|
290
|
+
changed();
|
|
291
|
+
};
|
|
292
|
+
const propose = (action) => {
|
|
293
|
+
const open = actions.filter((candidate) => candidate.status === 'pending' || candidate.status === 'running');
|
|
294
|
+
if (open.length >= MAX_ACTIONS) {
|
|
295
|
+
throw new Error(`${open.length} actions already wait for the user; wait for their decisions first`);
|
|
296
|
+
}
|
|
297
|
+
const created = { ...action, id: String(nextAction++), status: 'pending', createdAt: now() };
|
|
298
|
+
actions = [...actions, created];
|
|
299
|
+
// Only settled actions make room: a pending one still waits for the user.
|
|
300
|
+
while (actions.length > MAX_ACTIONS) {
|
|
301
|
+
const settled = actions.findIndex((candidate) => candidate.status !== 'pending' && candidate.status !== 'running');
|
|
302
|
+
if (settled < 0)
|
|
303
|
+
break;
|
|
304
|
+
actions = actions.filter((_, index) => index !== settled);
|
|
305
|
+
}
|
|
306
|
+
changed();
|
|
307
|
+
return created;
|
|
308
|
+
};
|
|
309
|
+
const knownPage = (value) => {
|
|
310
|
+
const target = String(value ?? '');
|
|
311
|
+
const pages = options.pages?.() ?? [];
|
|
312
|
+
if (!pages.includes(target))
|
|
313
|
+
throw new Error(`unknown page "${target}"; known: ${pages.join(', ')}`);
|
|
314
|
+
return target;
|
|
315
|
+
};
|
|
316
|
+
const waiting = (action) => `Waiting for the user as action #${action.id}. You will get a message with the outcome.`;
|
|
317
|
+
const appTools = () => (options.tools ?? []).map((tool) => {
|
|
318
|
+
const schema = tool.inputSchema ?? { type: 'object', properties: {} };
|
|
319
|
+
if (!tool.write) {
|
|
320
|
+
return {
|
|
321
|
+
name: tool.name,
|
|
322
|
+
description: tool.description,
|
|
323
|
+
inputSchema: schema,
|
|
324
|
+
call: async (args) => {
|
|
325
|
+
tool.validate?.(args);
|
|
326
|
+
return await tool.run(args);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const properties = (schema.properties ?? {});
|
|
331
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
332
|
+
return {
|
|
333
|
+
name: tool.name,
|
|
334
|
+
description: `${tool.description} Runs only after the user approves it.`,
|
|
335
|
+
inputSchema: {
|
|
336
|
+
...schema,
|
|
337
|
+
type: 'object',
|
|
338
|
+
properties: {
|
|
339
|
+
...properties,
|
|
340
|
+
[APPROVAL_SUMMARY]: { type: 'string', description: 'One line the user reads before approving' },
|
|
341
|
+
},
|
|
342
|
+
required: [...required, APPROVAL_SUMMARY],
|
|
343
|
+
},
|
|
344
|
+
call: async (args) => {
|
|
345
|
+
const { [APPROVAL_SUMMARY]: summary, ...rest } = args;
|
|
346
|
+
tool.validate?.(rest);
|
|
347
|
+
const action = propose({
|
|
348
|
+
kind: 'tool',
|
|
349
|
+
summary: String(summary ?? '').trim() || tool.name,
|
|
350
|
+
tool: { name: tool.name, args: rest },
|
|
351
|
+
});
|
|
352
|
+
return waiting(action);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
const pageTools = () => [
|
|
357
|
+
{
|
|
358
|
+
name: 'app_view',
|
|
359
|
+
description: 'The page the user has open in the app now, and the pages they can open.',
|
|
360
|
+
inputSchema: { type: 'object', properties: {} },
|
|
361
|
+
call: async () => `Open: ${view ?? 'unknown'}\nPages: ${(options.pages?.() ?? []).join(', ')}`,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: 'app_links',
|
|
365
|
+
description: 'Offer the user clickable chips under your answer, each opening a page of the app.',
|
|
366
|
+
inputSchema: {
|
|
367
|
+
type: 'object',
|
|
368
|
+
properties: {
|
|
369
|
+
links: {
|
|
370
|
+
type: 'array',
|
|
371
|
+
maxItems: MAX_LINKS,
|
|
372
|
+
items: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: { label: { type: 'string' }, path: { type: 'string' } },
|
|
375
|
+
required: ['label', 'path'],
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
required: ['links'],
|
|
380
|
+
},
|
|
381
|
+
call: async (args) => {
|
|
382
|
+
const turnId = session?.get().turns.at(-1)?.id;
|
|
383
|
+
if (!turnId)
|
|
384
|
+
throw new Error('no turn to attach links to');
|
|
385
|
+
const offered = (Array.isArray(args.links) ? args.links : []).slice(0, MAX_LINKS).map((link) => {
|
|
386
|
+
const { label, path: target } = (link ?? {});
|
|
387
|
+
const page = knownPage(target);
|
|
388
|
+
return { turnId, label: String(label ?? '').slice(0, 60) || page, path: page };
|
|
389
|
+
});
|
|
390
|
+
links = [...links, ...offered].slice(-30);
|
|
391
|
+
changed();
|
|
392
|
+
return `Showing ${offered.length} chip(s).`;
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'app_open',
|
|
397
|
+
description: 'Open a page of the app for the user. Use only when they ask to go there.',
|
|
398
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
399
|
+
call: async (args) => {
|
|
400
|
+
const target = knownPage(args.path);
|
|
401
|
+
// Time-based: a restarted or another backend never reuses an id the page already followed.
|
|
402
|
+
navigation = { id: Math.max(now(), (navigation?.id ?? 0) + 1), path: target };
|
|
403
|
+
changed();
|
|
404
|
+
return `Opened ${target}.`;
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
];
|
|
408
|
+
const devTools = () => [
|
|
409
|
+
{
|
|
410
|
+
name: 'propose_commit',
|
|
411
|
+
description: 'Ask the user to commit all current changes in the repository with this message.',
|
|
412
|
+
inputSchema: {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: { message: { type: 'string', description: 'Commit message: subject line, blank line, body' } },
|
|
415
|
+
required: ['message'],
|
|
416
|
+
},
|
|
417
|
+
call: async (args) => {
|
|
418
|
+
const message = String(args.message ?? '').trim();
|
|
419
|
+
if (!message)
|
|
420
|
+
throw new Error('message is required');
|
|
421
|
+
const token = mcpToken;
|
|
422
|
+
const snapshot = await snapshotTree(repo());
|
|
423
|
+
// A reset or agent switch meanwhile: this call belongs to a session that is gone.
|
|
424
|
+
if (token !== mcpToken)
|
|
425
|
+
throw new Error('This session has ended.');
|
|
426
|
+
const action = propose({ kind: 'commit', summary: message.split('\n')[0], commit: { message, ...snapshot } });
|
|
427
|
+
proposedIn.set(action.id, { session, turn: runningTurn() });
|
|
428
|
+
return waiting(action);
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
const repo = () => options.dev?.repo ?? options.dev.cwd;
|
|
433
|
+
const execute = async (action) => {
|
|
434
|
+
updateAction(action.id, { status: 'running' });
|
|
435
|
+
try {
|
|
436
|
+
if (action.kind === 'commit') {
|
|
437
|
+
// No edits may land between the check and the commit. Stopping drops the queue: keep it.
|
|
438
|
+
const stopped = session;
|
|
439
|
+
saved = [...saved, ...(stopped?.get().queued ?? [])];
|
|
440
|
+
stopped?.stop();
|
|
441
|
+
try {
|
|
442
|
+
// Let the stopped run wind down: none of its edits may land after the check.
|
|
443
|
+
const ended = !stopped ||
|
|
444
|
+
(await Promise.race([
|
|
445
|
+
stopped.idle().then(() => true),
|
|
446
|
+
delay(options.stopTimeoutMs ?? STOP_WAIT_MS, false, { ref: false }),
|
|
447
|
+
]));
|
|
448
|
+
// A throw is this action's outcome too: it must reach the agent before the queued prompts.
|
|
449
|
+
// The user can only approve what they could read: a clipped diff never lands, whichever client approved it.
|
|
450
|
+
const land = async () => !ended
|
|
451
|
+
? {
|
|
452
|
+
ok: false,
|
|
453
|
+
output: 'The agent did not stop in time, so nothing was committed; approve again once it is idle.',
|
|
454
|
+
}
|
|
455
|
+
: (await treeChanges(repo(), action.commit)).truncated
|
|
456
|
+
? {
|
|
457
|
+
ok: false,
|
|
458
|
+
output: 'The diff is too large to review in full; split the change into smaller commits.',
|
|
459
|
+
}
|
|
460
|
+
: commitTree(repo(), action.commit, action.commit.message);
|
|
461
|
+
const outcome = await land().catch((error) => ({ ok: false, output: error.message }));
|
|
462
|
+
// A note sent while the stopped run still winds down would wait for the user's next
|
|
463
|
+
// prompt; send it once the session is idle, so it starts its own turn. Until then the
|
|
464
|
+
// action still runs, so a prompt sent meanwhile is held behind its outcome.
|
|
465
|
+
if (stopped && session === stopped)
|
|
466
|
+
await stopped.idle();
|
|
467
|
+
updateAction(action.id, { status: outcome.ok ? 'done' : 'failed', result: outcome.output });
|
|
468
|
+
tell(`Commit #${action.id} ${outcome.ok ? 'succeeded' : 'failed'}:\n${outcome.output}`);
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
// The last of several commits approved together gives the queue back.
|
|
472
|
+
const others = actions.some((other) => other.id !== action.id && other.kind === 'commit' && other.status === 'running');
|
|
473
|
+
if (!others) {
|
|
474
|
+
const restore = saved;
|
|
475
|
+
saved = [];
|
|
476
|
+
if (session === stopped)
|
|
477
|
+
for (const prompt of restore)
|
|
478
|
+
session?.send(prompt, { queue: true });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
const tool = options.tools?.find((candidate) => candidate.name === action.tool.name);
|
|
484
|
+
if (!tool)
|
|
485
|
+
throw new Error(`tool ${action.tool.name} is gone`);
|
|
486
|
+
const output = await tool.run(action.tool.args);
|
|
487
|
+
updateAction(action.id, { status: 'done', result: output });
|
|
488
|
+
tell(`The user approved action #${action.id} (${action.summary}). Result:\n${output}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
updateAction(action.id, { status: 'failed', result: error.message });
|
|
493
|
+
tell(`Action #${action.id} failed: ${error.message}`);
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
if (!actions.some((candidate) => candidate.status === 'running')) {
|
|
497
|
+
const release = held;
|
|
498
|
+
held = [];
|
|
499
|
+
// Behind every outcome note, never steered into one.
|
|
500
|
+
for (const prompt of release)
|
|
501
|
+
deliver(prompt, true, false, true);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
return {
|
|
506
|
+
get mcpToken() {
|
|
507
|
+
return mcpToken;
|
|
508
|
+
},
|
|
509
|
+
serverName,
|
|
510
|
+
get,
|
|
511
|
+
subscribe(listener) {
|
|
512
|
+
listeners.add(listener);
|
|
513
|
+
return () => listeners.delete(listener);
|
|
514
|
+
},
|
|
515
|
+
send(text) {
|
|
516
|
+
const trimmed = text.trim();
|
|
517
|
+
if (!trimmed)
|
|
518
|
+
throw new AppAgentError('empty message', 400);
|
|
519
|
+
trace({ kind: 'prompt', agent: agentName, mode, view, text: clip(trimmed, 4_000) });
|
|
520
|
+
if (actions.some((action) => action.status === 'running')) {
|
|
521
|
+
held = [...held, trimmed];
|
|
522
|
+
changed();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// An outcome note still waiting (a rejection while the proposing turn runs) goes first.
|
|
526
|
+
// A pending commit stops its turn when approved: a prompt steered into that turn could be lost.
|
|
527
|
+
const commitPending = actions.some((action) => action.kind === 'commit' && action.status === 'pending');
|
|
528
|
+
deliver(trimmed, true, false, commitPending || !!session?.get().notes?.length);
|
|
529
|
+
},
|
|
530
|
+
stop() {
|
|
531
|
+
if (held.length || saved.length) {
|
|
532
|
+
held = [];
|
|
533
|
+
saved = [];
|
|
534
|
+
changed();
|
|
535
|
+
}
|
|
536
|
+
session?.stop();
|
|
537
|
+
},
|
|
538
|
+
reset() {
|
|
539
|
+
if (actions.some((action) => action.status === 'running'))
|
|
540
|
+
throw new AppAgentError('an action is running', 409);
|
|
541
|
+
dropSession();
|
|
542
|
+
history = [];
|
|
543
|
+
links = [];
|
|
544
|
+
carryOver = '';
|
|
545
|
+
// A fresh conversation cannot hear their outcome: nothing waits for the user any more.
|
|
546
|
+
actions = actions.map((action) => action.status === 'pending' ? { ...action, status: 'rejected', result: 'Dropped by starting over.' } : action);
|
|
547
|
+
changed();
|
|
548
|
+
},
|
|
549
|
+
configure(change) {
|
|
550
|
+
const nextMode = change.mode ?? mode;
|
|
551
|
+
if (!modes.includes(nextMode))
|
|
552
|
+
throw new AppAgentError(`unknown mode "${nextMode}"`, 400);
|
|
553
|
+
const offered = installed(nextMode);
|
|
554
|
+
// A mode-only switch keeps the agent when the target mode offers it, else picks one there.
|
|
555
|
+
const nextAgent = change.agent ??
|
|
556
|
+
(offered.some((choice) => choice.name === agentName)
|
|
557
|
+
? agentName
|
|
558
|
+
: (offered.find((choice) => choice.installed)?.name ?? offered[0]?.name ?? agentName));
|
|
559
|
+
if (!agentsFor(nextMode).some((agent) => agent.name === nextAgent)) {
|
|
560
|
+
throw new AppAgentError(`unknown agent "${nextAgent}"`, 400);
|
|
561
|
+
}
|
|
562
|
+
if (session?.get().status === 'running')
|
|
563
|
+
throw new AppAgentError('the agent is busy', 409);
|
|
564
|
+
if (nextMode === mode && nextAgent === agentName)
|
|
565
|
+
return;
|
|
566
|
+
// A running action reports to this conversation (and a commit gives back its queue).
|
|
567
|
+
if (actions.some((action) => action.status === 'running'))
|
|
568
|
+
throw new AppAgentError('an action is running', 409);
|
|
569
|
+
archiveSession();
|
|
570
|
+
archiveNotes();
|
|
571
|
+
mode = nextMode;
|
|
572
|
+
agentName = nextAgent;
|
|
573
|
+
dropSession();
|
|
574
|
+
changed();
|
|
575
|
+
},
|
|
576
|
+
setView(next) {
|
|
577
|
+
if (next === view)
|
|
578
|
+
return;
|
|
579
|
+
view = next;
|
|
580
|
+
changed();
|
|
581
|
+
},
|
|
582
|
+
decide(id, decision) {
|
|
583
|
+
// Nothing runs once the app closed the agent, whoever still holds it.
|
|
584
|
+
if (closed)
|
|
585
|
+
throw new AppAgentError('the agent is closed', 409);
|
|
586
|
+
const action = actions.find((candidate) => candidate.id === id);
|
|
587
|
+
if (!action)
|
|
588
|
+
throw new AppAgentError('no such action', 404);
|
|
589
|
+
if (action.status !== 'pending')
|
|
590
|
+
throw new AppAgentError(`action is ${action.status}`, 409);
|
|
591
|
+
if (decision === 'approve' && action.kind === 'commit') {
|
|
592
|
+
const active = runningTurn();
|
|
593
|
+
const proposed = proposedIn.get(id);
|
|
594
|
+
if (active !== undefined && (proposed?.session !== session || active !== proposed.turn)) {
|
|
595
|
+
throw new AppAgentError('the agent is working on a newer prompt; approve the commit once it finishes', 409);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
trace({ kind: 'decision', action: id, summary: action.summary, decision });
|
|
599
|
+
proposedIn.delete(id);
|
|
600
|
+
if (decision === 'reject') {
|
|
601
|
+
updateAction(id, { status: 'rejected' });
|
|
602
|
+
tell(`The user rejected action #${id} (${action.summary}).`);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
void execute(action);
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
approve(id, decision) {
|
|
609
|
+
if (!session?.approve(id, decision))
|
|
610
|
+
throw new AppAgentError('no such approval request', 404);
|
|
611
|
+
},
|
|
612
|
+
async changes(id) {
|
|
613
|
+
const action = actions.find((candidate) => candidate.id === id);
|
|
614
|
+
if (!action?.commit)
|
|
615
|
+
throw new AppAgentError('no such commit action', 404);
|
|
616
|
+
return treeChanges(repo(), action.commit);
|
|
617
|
+
},
|
|
618
|
+
mcpTools() {
|
|
619
|
+
const tools = [...appTools(), ...(options.pages ? pageTools() : []), ...(mode === 'dev' ? devTools() : [])];
|
|
620
|
+
if (!options.trace)
|
|
621
|
+
return tools;
|
|
622
|
+
return tools.map((tool) => ({
|
|
623
|
+
...tool,
|
|
624
|
+
call: async (args) => {
|
|
625
|
+
const started = now();
|
|
626
|
+
try {
|
|
627
|
+
const result = await tool.call(args);
|
|
628
|
+
trace({ kind: 'mcp', tool: tool.name, args, ok: true, ms: now() - started, result: clip(result, 2_000) });
|
|
629
|
+
return result;
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
const message = error.message;
|
|
633
|
+
trace({ kind: 'mcp', tool: tool.name, args, ok: false, ms: now() - started, error: message });
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
}));
|
|
638
|
+
},
|
|
639
|
+
close() {
|
|
640
|
+
closed = true;
|
|
641
|
+
held = [];
|
|
642
|
+
saved = [];
|
|
643
|
+
listeners.clear();
|
|
644
|
+
dropSession();
|
|
645
|
+
if (useCwd)
|
|
646
|
+
rmSync(useCwd, { recursive: true, force: true });
|
|
647
|
+
useCwd = null;
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function clip(text, max, keepEnd = false) {
|
|
652
|
+
if (text.length <= max)
|
|
653
|
+
return text;
|
|
654
|
+
return keepEnd ? `… (earlier part dropped)\n${text.slice(-max)}` : `${text.slice(0, max)}… (clipped)`;
|
|
655
|
+
}
|
|
656
|
+
// What the user asked and what came back, without the tool calls.
|
|
657
|
+
function transcript(turns) {
|
|
658
|
+
return turns
|
|
659
|
+
.map((turn) => {
|
|
660
|
+
// Input steered into the turn is the user's too, in its place among the answers.
|
|
661
|
+
const lines = [`User: ${userText(turn.prompt).trim()}`];
|
|
662
|
+
let last;
|
|
663
|
+
for (const entry of turn.entries) {
|
|
664
|
+
if (entry.kind === 'text')
|
|
665
|
+
last = entry.text;
|
|
666
|
+
if (entry.kind !== 'input')
|
|
667
|
+
continue;
|
|
668
|
+
if (last !== undefined)
|
|
669
|
+
lines.push(`${turn.agent}: ${last.trim()}`);
|
|
670
|
+
last = undefined;
|
|
671
|
+
lines.push(`User: ${userText(entry.text).trim()}`);
|
|
672
|
+
}
|
|
673
|
+
lines.push(`${turn.agent}: ${last?.trim() ?? '(no answer)'}`);
|
|
674
|
+
return lines.join('\n');
|
|
675
|
+
})
|
|
676
|
+
.join('\n\n');
|
|
677
|
+
}
|
|
678
|
+
//# sourceMappingURL=app-agent.js.map
|