@monster-orange/den 0.4.11 → 0.4.13
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 +169 -0
- package/README.md +7 -7
- package/docs/distributions.md +212 -12
- package/package.json +2 -1
- package/src/shared/agents/catalog.ts +77 -0
- package/src/shared/agents/default-pack/be-dev.md +1 -0
- package/src/shared/agents/default-pack/devops.md +1 -0
- package/src/shared/agents/default-pack/fe-dev.md +1 -0
- package/src/shared/agents/default-pack/orchestrator.md +3 -1
- package/src/shared/agents/default-pack/planner.md +1 -0
- package/src/shared/agents/default-pack/reviewer.md +1 -0
- package/src/shared/agents/default-pack/tester.md +16 -0
- package/src/shared/agents/default-pack/ux-designer.md +1 -0
- package/src/shared/agents/den-claude.ts +151 -17
- package/src/shared/agents/permissions.ts +108 -0
- package/src/shared/agents/persona-store.ts +286 -0
- package/src/shared/agents/personas.ts +116 -12
- package/src/shared/agents/records.ts +57 -0
- package/src/shared/agents/roles.ts +288 -0
- package/src/shared/agents/runtime.ts +49 -0
- package/src/shared/agents/session-name.ts +110 -0
- package/src/shared/agents/transcript-follow.ts +180 -0
- package/src/shared/den-config.ts +27 -1
- package/src/ui/cli/agent-ops.ts +578 -11
- package/src/ui/cli/agents-command.ts +12 -2
- package/src/ui/cli/env-probe.ts +4 -1
- package/src/ui/mcp/tools.ts +1 -1
- package/src/ui/server/agent-paths.ts +52 -0
- package/src/ui/server/agent-routes.ts +329 -19
- package/src/ui/server/agents-layout-pref.ts +43 -0
- package/src/ui/server/editor-open-pref.ts +41 -0
- package/src/ui/server/index.ts +22 -0
- package/src/ui/server/public/agent-start.js +3 -2
- package/src/ui/server/public/agents-page.js +1037 -67
- package/src/ui/server/public/agents-section.js +1 -1
- package/src/ui/server/public/agents.html +236 -20
- package/src/ui/server/public/header.js +2 -0
- package/src/ui/server/public/i18n/es.js +139 -0
- package/src/ui/server/public/i18n/ja.js +139 -0
- package/src/ui/server/public/persona-ui.css +539 -0
- package/src/ui/server/public/persona-ui.js +796 -0
- package/src/ui/server/public/settings-page.js +4 -9
- package/src/ui/server/public/settings-registry.js +66 -0
- package/src/ui/server/public/settings.html +1 -0
- package/src/ui/server/public/shell.js +137 -0
- package/src/ui/server/public/term-input.js +129 -0
- package/src/ui/server/public/topbar.js +22 -4
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +34 -1
- package/src/ui/server/ui-assets.ts +8 -0
|
@@ -410,13 +410,23 @@ async function listPersonas(
|
|
|
410
410
|
return 0;
|
|
411
411
|
}
|
|
412
412
|
for (const l of columns([
|
|
413
|
-
[
|
|
413
|
+
[
|
|
414
|
+
"PERSONA",
|
|
415
|
+
"SOURCE",
|
|
416
|
+
"STANDING",
|
|
417
|
+
"RUNTIME",
|
|
418
|
+
"MODEL",
|
|
419
|
+
"ROLE",
|
|
420
|
+
"DESCRIPTION",
|
|
421
|
+
],
|
|
414
422
|
...all.map((x) => [
|
|
415
423
|
x.id,
|
|
424
|
+
x.origin,
|
|
416
425
|
x.standing ? "yes" : "-",
|
|
417
426
|
x.runtime ?? DEFAULT_RUNTIME_ID,
|
|
418
427
|
x.model ?? "-",
|
|
419
|
-
x.role,
|
|
428
|
+
x.role ?? "-",
|
|
429
|
+
x.description,
|
|
420
430
|
]),
|
|
421
431
|
]))
|
|
422
432
|
d.out(l);
|
package/src/ui/cli/env-probe.ts
CHANGED
|
@@ -112,7 +112,10 @@ function probeInput(d: ProbeDeps, dir: string): LaunchInput {
|
|
|
112
112
|
return {
|
|
113
113
|
persona: {
|
|
114
114
|
id: PROBE_ID,
|
|
115
|
-
|
|
115
|
+
description: "",
|
|
116
|
+
category: "engineering",
|
|
117
|
+
allowedCommands: [],
|
|
118
|
+
origin: "builtin",
|
|
116
119
|
prompt: "",
|
|
117
120
|
standing: false,
|
|
118
121
|
tools: [],
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -740,7 +740,7 @@ const agentResumeTool: DenTool = {
|
|
|
740
740
|
name: "agent_resume",
|
|
741
741
|
title: "Resume an agent",
|
|
742
742
|
description:
|
|
743
|
-
"Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself.",
|
|
743
|
+
"Resume a stopped or interrupted agent in the same conversation, persona, workspace and folder. Never starts a new conversation: an agent whose runtime can't resume is refused. Never the caller itself. Answers with agent: its id, persona, runtime, workspace, cwd, session (the session handle, <dist>-<ws>:<id>), sessionId, displayName (a person's name for it) and sessionTitle (the name its session shows: Den's rename or the runtime's, the later), when set.",
|
|
744
744
|
action: true,
|
|
745
745
|
shape: {
|
|
746
746
|
agent: z.string().describe("The agent's id"),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which file paths an agent printed are links (AG-4b): GET
|
|
3
|
+
* /api/workspaces/<ws>/agents/<id>/paths?p=<path>&p=<path>… answers
|
|
4
|
+
* { paths: { <path as given>: <absolute path> | null } }.
|
|
5
|
+
*
|
|
6
|
+
* A path is resolved against the agent's cwd (an absolute one stays), and
|
|
7
|
+
* is a link only when it exists and its real path (symlinks followed) is
|
|
8
|
+
* inside the agent's workspace: the workspace's directory, or the agent's
|
|
9
|
+
* own cwd (a worktree of it). Anything else is null, so a path outside
|
|
10
|
+
* the workspace is never a link and the page never learns whether it
|
|
11
|
+
* exists. Only stat is used: nothing is read, nothing is opened.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
/** The most paths one ask takes (a screen's worth). */
|
|
17
|
+
export const MAX_PATHS = 64;
|
|
18
|
+
/** The longest path taken. */
|
|
19
|
+
const MAX_PATH_LEN = 1024;
|
|
20
|
+
|
|
21
|
+
function realOrNull(p: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
return fs.realpathSync(p);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const inside = (p: string, root: string) =>
|
|
30
|
+
p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
31
|
+
|
|
32
|
+
/** Each path's absolute path when it is a link, else null. */
|
|
33
|
+
export function agentPathLinks(
|
|
34
|
+
given: readonly string[],
|
|
35
|
+
cwd: string,
|
|
36
|
+
roots: readonly string[],
|
|
37
|
+
): Record<string, string | null> {
|
|
38
|
+
const real = roots
|
|
39
|
+
.map((r) => realOrNull(r))
|
|
40
|
+
.filter((r): r is string => r !== null);
|
|
41
|
+
const base = realOrNull(cwd) ?? path.resolve(cwd);
|
|
42
|
+
const out: Record<string, string | null> = {};
|
|
43
|
+
for (const p of given) {
|
|
44
|
+
if (Object.hasOwn(out, p)) continue;
|
|
45
|
+
out[p] = null;
|
|
46
|
+
if (!p || p.length > MAX_PATH_LEN || p.includes("\0")) continue;
|
|
47
|
+
const abs = path.resolve(base, p);
|
|
48
|
+
const r = realOrNull(abs);
|
|
49
|
+
if (r && real.some((root) => inside(r, root))) out[p] = r;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
@@ -12,8 +12,26 @@
|
|
|
12
12
|
* runs its version command and
|
|
13
13
|
* refuses a path that isn't
|
|
14
14
|
* that runtime (AG-0.5)
|
|
15
|
+
* GET /api/personas/<id> one persona with its
|
|
16
|
+
* instructions (Details)
|
|
17
|
+
* POST /api/personas New persona (AG-10.7): a
|
|
18
|
+
* local one; a person only
|
|
19
|
+
* POST /api/personas/<id> Edit: a local one only
|
|
20
|
+
* (409 read_only otherwise)
|
|
21
|
+
* DELETE /api/personas/<id> Delete: a local one only
|
|
15
22
|
* GET /api/workspaces/<ws>/agents the workspace's agents
|
|
16
|
-
* POST /api/workspaces/<ws>/agents start { persona, runtime?,
|
|
23
|
+
* POST /api/workspaces/<ws>/agents start { persona, runtime?,
|
|
24
|
+
* model?, version?,
|
|
25
|
+
* permission?, allow?,
|
|
26
|
+
* instruction?, id? }
|
|
27
|
+
* (AG-10.1): each checked
|
|
28
|
+
* against the catalog; the
|
|
29
|
+
* instruction is typed into
|
|
30
|
+
* the session once it is
|
|
31
|
+
* ready, never an argument
|
|
32
|
+
* POST /api/workspaces/<ws>/sessions a plain session: the login
|
|
33
|
+
* shell in the workspace's
|
|
34
|
+
* folder (AG-10.1)
|
|
17
35
|
* POST /api/workspaces/<ws>/agents/<id>/stop
|
|
18
36
|
* POST /api/workspaces/<ws>/agents/<id>/resume the same conversation (AG-14)
|
|
19
37
|
* POST /api/workspaces/<ws>/agents/<id>/fresh a new conversation, same
|
|
@@ -23,6 +41,14 @@
|
|
|
23
41
|
* first prompt is its
|
|
24
42
|
* handoff.md; a resume of
|
|
25
43
|
* one is refused (409 rotated)
|
|
44
|
+
* POST /api/workspaces/<ws>/agents/<id>/name { name: string | null }: the
|
|
45
|
+
* name its tab and the lists
|
|
46
|
+
* show (AG-4b); the id never
|
|
47
|
+
* changes; null or "" clears
|
|
48
|
+
* GET /api/workspaces/<ws>/agents/<id>/paths?p=… which printed paths are
|
|
49
|
+
* links (AG-4b): inside its
|
|
50
|
+
* workspace and there (stat
|
|
51
|
+
* only; agent-paths.ts)
|
|
26
52
|
* POST /api/workspaces/<ws>/agents/<id>/remove forget a stopped one (Undo:
|
|
27
53
|
* /api/agents/restore)
|
|
28
54
|
* POST /api/agents/clear-stopped { workspaces: [...] }: forget
|
|
@@ -67,13 +93,22 @@ import { HostError } from "../../shared/sessions/client.js";
|
|
|
67
93
|
import {
|
|
68
94
|
AgentError,
|
|
69
95
|
agentRecord,
|
|
96
|
+
checkInstruction,
|
|
97
|
+
createPersona,
|
|
98
|
+
deletePersona,
|
|
70
99
|
listAgents,
|
|
71
100
|
listPersonas,
|
|
101
|
+
personaDetail,
|
|
102
|
+
registries,
|
|
103
|
+
sendFirstInstruction,
|
|
104
|
+
startPlainSession,
|
|
105
|
+
updatePersona,
|
|
72
106
|
listRuntimes,
|
|
73
107
|
markInterrupted,
|
|
74
108
|
NOT_IN_WORKSPACE,
|
|
75
109
|
pruneStopped,
|
|
76
110
|
removeAgent,
|
|
111
|
+
renameAgent,
|
|
77
112
|
removeStopped,
|
|
78
113
|
restoreAgents,
|
|
79
114
|
resumeInterrupted,
|
|
@@ -88,7 +123,9 @@ import {
|
|
|
88
123
|
type AgentsResolved,
|
|
89
124
|
type Started,
|
|
90
125
|
} from "../cli/agent-ops.js";
|
|
126
|
+
import { agentSessionName } from "../../shared/agents/session-name.js";
|
|
91
127
|
import { recordAction } from "./audit.js";
|
|
128
|
+
import { agentPathLinks, MAX_PATHS } from "./agent-paths.js";
|
|
92
129
|
import {
|
|
93
130
|
agentPrincipalOf,
|
|
94
131
|
auditActorOf,
|
|
@@ -101,14 +138,18 @@ import type { Actor } from "../../shared/agent-actor.js";
|
|
|
101
138
|
export interface AgentRouteDeps {
|
|
102
139
|
/** The operations' deps (tests: an in-process host, a temp state dir). */
|
|
103
140
|
agents?: AgentsDeps;
|
|
141
|
+
/** When a first instruction counts its session ready (tests: shorter). */
|
|
142
|
+
instruction?: { quietMs?: number; maxMs?: number };
|
|
104
143
|
}
|
|
105
144
|
|
|
106
145
|
const AGENTS_PATH =
|
|
107
|
-
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove))?$/;
|
|
146
|
+
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
|
|
108
147
|
|
|
109
148
|
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
110
|
-
|
|
111
|
-
const
|
|
149
|
+
const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
|
|
150
|
+
const PLAIN_PATH = /^\/api\/workspaces\/([^/]+)\/sessions$/;
|
|
151
|
+
/** A start's body: a few names, commands and a first instruction. */
|
|
152
|
+
const MAX_BODY = 32 * 1024;
|
|
112
153
|
/** A list of names (a page's workspaces, an Undo's agents). */
|
|
113
154
|
const MANY_BODY = 64 * 1024;
|
|
114
155
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
@@ -163,10 +204,20 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
|
|
|
163
204
|
throw e;
|
|
164
205
|
}
|
|
165
206
|
|
|
207
|
+
function sessionTitleOf(
|
|
208
|
+
rec: Started["rec"],
|
|
209
|
+
d: AgentsResolved,
|
|
210
|
+
): { sessionTitle?: string } {
|
|
211
|
+
const n = agentSessionName(rec, d.stateDir());
|
|
212
|
+
return n ? { sessionTitle: n } : {};
|
|
213
|
+
}
|
|
214
|
+
|
|
166
215
|
/** A started or resumed agent's answer: its record, or its early exit. */
|
|
167
216
|
function sendStarted(
|
|
168
217
|
res: http.ServerResponse,
|
|
169
218
|
s: Started & { fresh?: boolean },
|
|
219
|
+
d: AgentsResolved,
|
|
220
|
+
extra: Record<string, unknown> = {},
|
|
170
221
|
): void {
|
|
171
222
|
if (s.early) {
|
|
172
223
|
const code =
|
|
@@ -197,17 +248,47 @@ function sendStarted(
|
|
|
197
248
|
// The host's id: the Agents tab opens its pane by it (?open=<id>).
|
|
198
249
|
sessionId: s.info.ref.id,
|
|
199
250
|
startedAt: s.rec.startedAt,
|
|
251
|
+
...(s.rec.model ? { model: s.rec.model } : {}),
|
|
252
|
+
...(s.rec.version ? { version: s.rec.version } : {}),
|
|
253
|
+
...(s.rec.permission ? { permission: s.rec.permission } : {}),
|
|
254
|
+
...(s.rec.overrides?.length ? { overrides: s.rec.overrides } : {}),
|
|
255
|
+
...(s.rec.displayName ? { displayName: s.rec.displayName } : {}),
|
|
256
|
+
// AG-4b: the name its session shows (Den's rename or the runtime's),
|
|
257
|
+
// as the lists show it (MCP agent_resume answers with this). Its
|
|
258
|
+
// handle is `session`.
|
|
259
|
+
...sessionTitleOf(s.rec, d),
|
|
200
260
|
},
|
|
201
261
|
// A resume with nothing to continue yet started fresh (same id).
|
|
202
262
|
...(s.fresh ? { fresh: true } : {}),
|
|
203
263
|
...(s.warnings.length ? { warnings: s.warnings } : {}),
|
|
264
|
+
...extra,
|
|
204
265
|
});
|
|
205
266
|
}
|
|
206
267
|
|
|
207
|
-
/** What a
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
268
|
+
/** What a start's body names: optional strings, a command list, an instruction. */
|
|
269
|
+
interface StartBody {
|
|
270
|
+
persona?: string;
|
|
271
|
+
runtime?: string;
|
|
272
|
+
model?: string;
|
|
273
|
+
id?: string;
|
|
274
|
+
version?: string;
|
|
275
|
+
permission?: string;
|
|
276
|
+
allow?: unknown;
|
|
277
|
+
instruction?: unknown;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const START_KEYS = [
|
|
281
|
+
"persona",
|
|
282
|
+
"runtime",
|
|
283
|
+
"model",
|
|
284
|
+
"id",
|
|
285
|
+
"version",
|
|
286
|
+
"permission",
|
|
287
|
+
"allow",
|
|
288
|
+
"instruction",
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
async function startBody(req: http.IncomingMessage): Promise<StartBody> {
|
|
211
292
|
const raw = await readBody(req).catch(() => "");
|
|
212
293
|
if (raw.length > MAX_BODY)
|
|
213
294
|
throw new AgentError("request too large", { status: 413, usage: true });
|
|
@@ -219,15 +300,38 @@ async function startBody(
|
|
|
219
300
|
}
|
|
220
301
|
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
221
302
|
throw new AgentError("the body must be a JSON object", { usage: true });
|
|
222
|
-
const
|
|
223
|
-
for (const k of
|
|
224
|
-
|
|
303
|
+
const o = v as Record<string, unknown>;
|
|
304
|
+
for (const k of Object.keys(o))
|
|
305
|
+
if (!START_KEYS.includes(k))
|
|
306
|
+
throw new AgentError(
|
|
307
|
+
`${k}: unknown field (a start takes ${START_KEYS.join(", ")})`,
|
|
308
|
+
{ usage: true },
|
|
309
|
+
);
|
|
310
|
+
const out: Record<string, unknown> = {};
|
|
311
|
+
for (const k of [
|
|
312
|
+
"persona",
|
|
313
|
+
"runtime",
|
|
314
|
+
"model",
|
|
315
|
+
"id",
|
|
316
|
+
"version",
|
|
317
|
+
"permission",
|
|
318
|
+
]) {
|
|
319
|
+
const x = o[k];
|
|
225
320
|
if (x === undefined || x === null || x === "") continue;
|
|
226
321
|
if (typeof x !== "string")
|
|
227
322
|
throw new AgentError(`${k} must be a string`, { usage: true });
|
|
323
|
+
if (x.length > 128 || /[^\x21-\x7e]/.test(x))
|
|
324
|
+
throw new AgentError(
|
|
325
|
+
`${k} must be a short name (no spaces or control characters)`,
|
|
326
|
+
{
|
|
327
|
+
usage: true,
|
|
328
|
+
},
|
|
329
|
+
);
|
|
228
330
|
out[k] = x;
|
|
229
331
|
}
|
|
230
|
-
|
|
332
|
+
if (o.allow !== undefined && o.allow !== null) out.allow = o.allow;
|
|
333
|
+
if (o.instruction !== undefined) out.instruction = o.instruction;
|
|
334
|
+
return out as StartBody;
|
|
231
335
|
}
|
|
232
336
|
|
|
233
337
|
/**
|
|
@@ -243,6 +347,8 @@ export async function handleAgentRoutes(
|
|
|
243
347
|
): Promise<boolean> {
|
|
244
348
|
const m = AGENTS_PATH.exec(pathname);
|
|
245
349
|
const rp = RUNTIME_PATH.exec(pathname);
|
|
350
|
+
const pp = PERSONA_PATH.exec(pathname);
|
|
351
|
+
const plain = PLAIN_PATH.exec(pathname);
|
|
246
352
|
// AG-14's routes over many agents at once.
|
|
247
353
|
const many =
|
|
248
354
|
pathname === "/api/agents/clear-stopped" ||
|
|
@@ -253,22 +359,34 @@ export async function handleAgentRoutes(
|
|
|
253
359
|
pathname !== "/api/agents/runtimes" &&
|
|
254
360
|
!m &&
|
|
255
361
|
!rp &&
|
|
362
|
+
!pp &&
|
|
363
|
+
!plain &&
|
|
256
364
|
!many
|
|
257
365
|
)
|
|
258
366
|
return false;
|
|
259
367
|
const method = req.method ?? "GET";
|
|
260
368
|
const d = webDeps(deps.agents);
|
|
261
369
|
|
|
370
|
+
if (pp) return personaRoute(req, res, safeDecode(pp[1]), d);
|
|
371
|
+
if (plain) return plainSession(req, res, safeDecode(plain[1]), d);
|
|
372
|
+
if (pathname === "/api/personas" && method === "POST")
|
|
373
|
+
return personaRoute(req, res, null, d);
|
|
374
|
+
|
|
262
375
|
if (rp) return runtimePath(req, res, safeDecode(rp[1]), d);
|
|
263
376
|
if (many) return manyAgents(req, res, pathname, d);
|
|
264
377
|
if (!m) {
|
|
265
378
|
if (method !== "GET" && method !== "HEAD")
|
|
266
|
-
return methodNotAllowed(
|
|
379
|
+
return methodNotAllowed(
|
|
380
|
+
res,
|
|
381
|
+
pathname === "/api/personas" ? "GET, POST" : "GET",
|
|
382
|
+
);
|
|
267
383
|
try {
|
|
268
384
|
const ws = queryWorkspace(req, d);
|
|
269
385
|
const runtimes = await listRuntimes(d, ws);
|
|
270
386
|
sendJson(res, {
|
|
271
|
-
...(pathname === "/api/personas"
|
|
387
|
+
...(pathname === "/api/personas"
|
|
388
|
+
? { personas: listPersonas(d), ...registries(d) }
|
|
389
|
+
: {}),
|
|
272
390
|
runtimes,
|
|
273
391
|
...(ws ? { workspace: ws } : {}),
|
|
274
392
|
// AG-0.2: the file a person edits to set a runtime's path.
|
|
@@ -294,6 +412,8 @@ export async function handleAgentRoutes(
|
|
|
294
412
|
| "fresh"
|
|
295
413
|
| "relaunch"
|
|
296
414
|
| "remove"
|
|
415
|
+
| "name"
|
|
416
|
+
| "paths"
|
|
297
417
|
| undefined;
|
|
298
418
|
|
|
299
419
|
if (!action && method === "GET") {
|
|
@@ -313,6 +433,38 @@ export async function handleAgentRoutes(
|
|
|
313
433
|
}
|
|
314
434
|
return true;
|
|
315
435
|
}
|
|
436
|
+
// AG-4b: which paths an agent printed are links (read-only: stat only).
|
|
437
|
+
if (action === "paths") {
|
|
438
|
+
if (method !== "GET" && method !== "HEAD")
|
|
439
|
+
return methodNotAllowed(res, "GET");
|
|
440
|
+
try {
|
|
441
|
+
const rec = agentRecord(d, id!, ws);
|
|
442
|
+
const given = new URL(
|
|
443
|
+
req.url ?? "/",
|
|
444
|
+
"http://localhost",
|
|
445
|
+
).searchParams.getAll("p");
|
|
446
|
+
if (given.length > MAX_PATHS)
|
|
447
|
+
throw new AgentError(`at most ${MAX_PATHS} paths at once`, {
|
|
448
|
+
usage: true,
|
|
449
|
+
});
|
|
450
|
+
let wsDir: string | null = null;
|
|
451
|
+
try {
|
|
452
|
+
wsDir = d.workspaceDir(ws);
|
|
453
|
+
} catch {
|
|
454
|
+
wsDir = null;
|
|
455
|
+
}
|
|
456
|
+
sendJson(res, {
|
|
457
|
+
paths: agentPathLinks(
|
|
458
|
+
given,
|
|
459
|
+
rec.cwd,
|
|
460
|
+
[wsDir, rec.cwd].filter((x): x is string => !!x),
|
|
461
|
+
),
|
|
462
|
+
});
|
|
463
|
+
} catch (e) {
|
|
464
|
+
sendError(res, e, ws);
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
316
468
|
if (method !== "POST")
|
|
317
469
|
return methodNotAllowed(res, action ? "POST" : "GET, POST");
|
|
318
470
|
|
|
@@ -345,16 +497,54 @@ export async function handleAgentRoutes(
|
|
|
345
497
|
if (!body.persona)
|
|
346
498
|
throw new AgentError("persona is required", { usage: true });
|
|
347
499
|
what = `start ${body.id ?? body.persona} in ${ws}`;
|
|
500
|
+
const instruction = checkInstruction(body.instruction);
|
|
348
501
|
const s = await startAgent(d, {
|
|
349
502
|
workspace: ws,
|
|
350
503
|
persona: body.persona,
|
|
351
504
|
runtime: body.runtime,
|
|
352
505
|
model: body.model,
|
|
353
506
|
id: body.id,
|
|
507
|
+
...(body.version !== undefined ? { version: body.version } : {}),
|
|
508
|
+
...(body.permission !== undefined
|
|
509
|
+
? { permission: body.permission }
|
|
510
|
+
: {}),
|
|
511
|
+
...(body.allow !== undefined ? { allow: body.allow } : {}),
|
|
512
|
+
strict: true,
|
|
354
513
|
});
|
|
355
|
-
|
|
514
|
+
const ov = s.rec.overrides?.length
|
|
515
|
+
? `; overrides ${s.rec.overrides.join(", ")}`
|
|
516
|
+
: "";
|
|
517
|
+
what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}${ov}) in ${ws}`;
|
|
356
518
|
audit(what, s.early ? "exited at once" : undefined);
|
|
357
|
-
sendStarted(res, s);
|
|
519
|
+
sendStarted(res, s, d, instruction ? { instruction: "pending" } : {});
|
|
520
|
+
// AG-10.1: its first message, once it is ready: typed into its
|
|
521
|
+
// session (the host's write path), never an argument. Its text is
|
|
522
|
+
// never logged; its length is.
|
|
523
|
+
if (instruction && !s.early) {
|
|
524
|
+
const id = s.rec.id;
|
|
525
|
+
void sendFirstInstruction(
|
|
526
|
+
d,
|
|
527
|
+
s.info.ref.id,
|
|
528
|
+
s.rec.runtime,
|
|
529
|
+
instruction,
|
|
530
|
+
deps.instruction ?? {},
|
|
531
|
+
).then((sent) =>
|
|
532
|
+
recordAction(
|
|
533
|
+
actor,
|
|
534
|
+
{
|
|
535
|
+
verb: "agents",
|
|
536
|
+
detail: `first instruction to ${id} in ${ws} (${instruction.length} characters)`,
|
|
537
|
+
...(sent
|
|
538
|
+
? {}
|
|
539
|
+
: {
|
|
540
|
+
outcome: "refused" as const,
|
|
541
|
+
reason: "its session ended or no host",
|
|
542
|
+
}),
|
|
543
|
+
},
|
|
544
|
+
principalOf(req),
|
|
545
|
+
),
|
|
546
|
+
);
|
|
547
|
+
}
|
|
358
548
|
} else if (verb === "stop") {
|
|
359
549
|
// The answer comes once the session has ended (200, stopped: true),
|
|
360
550
|
// or, still running after the wait, 202 with stillStopping: true.
|
|
@@ -375,12 +565,25 @@ export async function handleAgentRoutes(
|
|
|
375
565
|
const s = await startFresh(d, id!, ws);
|
|
376
566
|
what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
377
567
|
audit(what, s.early ? "exited at once" : undefined);
|
|
378
|
-
sendStarted(res, s);
|
|
568
|
+
sendStarted(res, s, d);
|
|
379
569
|
} else if (verb === "relaunch") {
|
|
380
570
|
const s = await relaunchAgent(d, id!, ws);
|
|
381
571
|
what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
|
|
382
572
|
audit(what, s.early ? "exited at once" : undefined);
|
|
383
|
-
sendStarted(res, s);
|
|
573
|
+
sendStarted(res, s, d);
|
|
574
|
+
} else if (verb === "name") {
|
|
575
|
+
const body = await jsonBody(req);
|
|
576
|
+
if (!("name" in body))
|
|
577
|
+
throw new AgentError("name is required (null clears it)", {
|
|
578
|
+
usage: true,
|
|
579
|
+
});
|
|
580
|
+
const rec = renameAgent(d, id!, ws, body.name);
|
|
581
|
+
audit(what);
|
|
582
|
+
sendJson(res, {
|
|
583
|
+
ok: true,
|
|
584
|
+
agent: rec.id,
|
|
585
|
+
...(rec.displayName ? { displayName: rec.displayName } : {}),
|
|
586
|
+
});
|
|
384
587
|
} else if (verb === "remove") {
|
|
385
588
|
const removed = await removeAgent(d, id!, ws);
|
|
386
589
|
audit(what);
|
|
@@ -388,7 +591,7 @@ export async function handleAgentRoutes(
|
|
|
388
591
|
} else {
|
|
389
592
|
const s = await resumeAgent(d, id!, ws);
|
|
390
593
|
audit(what);
|
|
391
|
-
sendStarted(res, s);
|
|
594
|
+
sendStarted(res, s, d);
|
|
392
595
|
}
|
|
393
596
|
} catch (e) {
|
|
394
597
|
audit(what, e instanceof Error ? e.message : String(e));
|
|
@@ -515,6 +718,113 @@ function queryWorkspace(
|
|
|
515
718
|
return ws;
|
|
516
719
|
}
|
|
517
720
|
|
|
721
|
+
/**
|
|
722
|
+
* The persona routes (AG-10.6, 10.7): GET one (anyone who may read);
|
|
723
|
+
* POST (new: `id` null; edit: a local one), DELETE (a local one): a
|
|
724
|
+
* person only. An agent's token can't make or change a persona (its
|
|
725
|
+
* permissions are the person's to set). Every change is audited, refused
|
|
726
|
+
* or done; never the instructions themselves.
|
|
727
|
+
*/
|
|
728
|
+
async function personaRoute(
|
|
729
|
+
req: http.IncomingMessage,
|
|
730
|
+
res: http.ServerResponse,
|
|
731
|
+
id: string | null,
|
|
732
|
+
d: AgentsResolved,
|
|
733
|
+
): Promise<true> {
|
|
734
|
+
const method = req.method ?? "GET";
|
|
735
|
+
if (id !== null && (method === "GET" || method === "HEAD")) {
|
|
736
|
+
try {
|
|
737
|
+
sendJson(res, { persona: personaDetail(d, id) });
|
|
738
|
+
} catch (e) {
|
|
739
|
+
sendError(res, e);
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
if (method !== "POST" && !(method === "DELETE" && id !== null))
|
|
744
|
+
return methodNotAllowed(
|
|
745
|
+
res,
|
|
746
|
+
id === null ? "GET, POST" : "GET, POST, DELETE",
|
|
747
|
+
);
|
|
748
|
+
const actor = auditActorOf(req);
|
|
749
|
+
const verb = id === null ? "create" : method === "DELETE" ? "delete" : "edit";
|
|
750
|
+
let what = `persona ${verb}${id ? ` ${id}` : ""}`;
|
|
751
|
+
const audit = (reason?: string) =>
|
|
752
|
+
recordAction(
|
|
753
|
+
actor,
|
|
754
|
+
{
|
|
755
|
+
verb: "agents",
|
|
756
|
+
detail: what,
|
|
757
|
+
...(reason ? { outcome: "refused" as const, reason } : {}),
|
|
758
|
+
},
|
|
759
|
+
principalOf(req),
|
|
760
|
+
);
|
|
761
|
+
try {
|
|
762
|
+
if (actor && !actor.unverified)
|
|
763
|
+
throw new AgentError(
|
|
764
|
+
"an agent can't create, change or delete personas: a person sets what agents may do",
|
|
765
|
+
{ status: 403 },
|
|
766
|
+
);
|
|
767
|
+
if (verb === "delete") {
|
|
768
|
+
deletePersona(d, id!);
|
|
769
|
+
audit();
|
|
770
|
+
sendJson(res, { ok: true, deleted: id });
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
const body = await jsonBody(req);
|
|
774
|
+
const p =
|
|
775
|
+
verb === "create" ? createPersona(d, body) : updatePersona(d, id!, body);
|
|
776
|
+
what = `persona ${verb} ${p.id}`;
|
|
777
|
+
audit();
|
|
778
|
+
sendJson(res, { ok: true, persona: p });
|
|
779
|
+
} catch (e) {
|
|
780
|
+
audit(e instanceof Error ? e.message : String(e));
|
|
781
|
+
sendError(res, e);
|
|
782
|
+
}
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* POST /api/workspaces/<ws>/sessions (AG-10.1 Plain session): a person's
|
|
788
|
+
* login shell in the workspace's folder. A person only; audited.
|
|
789
|
+
*/
|
|
790
|
+
async function plainSession(
|
|
791
|
+
req: http.IncomingMessage,
|
|
792
|
+
res: http.ServerResponse,
|
|
793
|
+
ws: string,
|
|
794
|
+
d: AgentsResolved,
|
|
795
|
+
): Promise<true> {
|
|
796
|
+
if (req.method !== "POST") return methodNotAllowed(res, "POST");
|
|
797
|
+
const actor = auditActorOf(req);
|
|
798
|
+
let what = `plain session in ${ws}`;
|
|
799
|
+
const audit = (reason?: string) =>
|
|
800
|
+
recordAction(
|
|
801
|
+
actor,
|
|
802
|
+
{
|
|
803
|
+
verb: "sessions",
|
|
804
|
+
detail: what,
|
|
805
|
+
...(reason ? { outcome: "refused" as const, reason } : {}),
|
|
806
|
+
},
|
|
807
|
+
principalOf(req),
|
|
808
|
+
);
|
|
809
|
+
try {
|
|
810
|
+
if (actor && !actor.unverified)
|
|
811
|
+
throw new AgentError("an agent can't start a session from the page", {
|
|
812
|
+
status: 403,
|
|
813
|
+
});
|
|
814
|
+
const { info } = await startPlainSession(d, ws);
|
|
815
|
+
what = `plain session ${info.name} in ${ws}`;
|
|
816
|
+
audit();
|
|
817
|
+
sendJson(res, {
|
|
818
|
+
ok: true,
|
|
819
|
+
session: { id: info.ref.id, name: info.name, workspace: ws },
|
|
820
|
+
});
|
|
821
|
+
} catch (e) {
|
|
822
|
+
audit(e instanceof Error ? e.message : String(e));
|
|
823
|
+
sendError(res, e, ws);
|
|
824
|
+
}
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
|
|
518
828
|
/**
|
|
519
829
|
* POST /api/agents/runtimes/<id>/path: a person only, never an agent (it
|
|
520
830
|
* runs the path given). Audited, refused or done; never the path's output.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents tab's layout (AG-4b), a person's view preference: Focus (one
|
|
3
|
+
* pane, the default), Tabs or Tile. Stored in the user config as
|
|
4
|
+
* `agents.layout` and served by /api/settings as `agents_layout`, like the
|
|
5
|
+
* other view prefs (graph-prefs.ts, shell-prefs.ts). It changes no session
|
|
6
|
+
* and no agent: only how the page lays out their terminals.
|
|
7
|
+
*/
|
|
8
|
+
import type { UserConfig } from "../../shared/den-config.js";
|
|
9
|
+
|
|
10
|
+
export const AGENTS_LAYOUTS = ["focus", "tabs", "tile"] as const;
|
|
11
|
+
export type AgentsLayout = (typeof AGENTS_LAYOUTS)[number];
|
|
12
|
+
export const AGENTS_LAYOUT_DEFAULT: AgentsLayout = "focus";
|
|
13
|
+
|
|
14
|
+
const isLayout = (v: unknown): v is AgentsLayout =>
|
|
15
|
+
typeof v === "string" && (AGENTS_LAYOUTS as readonly string[]).includes(v);
|
|
16
|
+
|
|
17
|
+
/** The stored layout, or the default when unset or hand-broken. */
|
|
18
|
+
export function agentsLayout(user: UserConfig): AgentsLayout {
|
|
19
|
+
const v = (user.agents as { layout?: unknown } | undefined)?.layout;
|
|
20
|
+
return isLayout(v) ? v : AGENTS_LAYOUT_DEFAULT;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply a POSTed `agents_layout` to `user` (in place): a layout sets
|
|
25
|
+
* agents.layout, null clears it (and an agents block left empty goes).
|
|
26
|
+
* Anything else: the error to answer with.
|
|
27
|
+
*/
|
|
28
|
+
export function patchAgentsLayout(
|
|
29
|
+
user: UserConfig,
|
|
30
|
+
v: unknown,
|
|
31
|
+
): { ok: true } | { ok: false; error: string } {
|
|
32
|
+
if (v !== null && !isLayout(v))
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: `agents_layout must be one of ${AGENTS_LAYOUTS.map((l) => `"${l}"`).join(", ")}, or null`,
|
|
36
|
+
};
|
|
37
|
+
const agents = { ...(user.agents ?? {}) } as Record<string, unknown>;
|
|
38
|
+
if (v === null) delete agents.layout;
|
|
39
|
+
else agents.layout = v;
|
|
40
|
+
if (Object.keys(agents).length) user.agents = agents as UserConfig["agents"];
|
|
41
|
+
else delete user.agents;
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|