@monster-orange/den 0.4.15 → 0.4.16
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 +67 -0
- package/README.md +7 -7
- package/docs/distributions.md +82 -11
- package/package.json +1 -1
- package/src/shared/agents/conversations.ts +122 -4
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +19 -0
- package/src/shared/agents/runtime.ts +5 -0
- package/src/shared/github-app.ts +7 -0
- package/src/ui/cli/agent-ops.ts +297 -1
- package/src/ui/cli/agents-command.ts +67 -3
- package/src/ui/server/agent-routes.ts +89 -2
- package/src/ui/server/github-routes.ts +1 -1
- package/src/ui/server/index.ts +22 -5
- package/src/ui/server/public/agents-page.js +287 -26
- package/src/ui/server/public/agents.html +1 -0
- package/src/ui/server/public/error-page.js +70 -15
- package/src/ui/server/public/error.html +18 -0
- package/src/ui/server/public/i18n/es.js +36 -5
- package/src/ui/server/public/i18n/ja.js +36 -5
- package/src/ui/server/public/persona-ui.css +11 -0
- package/src/ui/server/public/shell.js +35 -5
- package/src/ui/server/route-classes.ts +6 -0
- package/src/ui/server/session-routes.ts +3 -0
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -455,6 +455,11 @@ export interface AgentRow {
|
|
|
455
455
|
* runtime's resume token, and it isn't running.
|
|
456
456
|
*/
|
|
457
457
|
resumable: boolean;
|
|
458
|
+
/**
|
|
459
|
+
* AG-18: Change persona… applies: its runtime can resume and its record
|
|
460
|
+
* has a conversation to continue (running or not), not rotated.
|
|
461
|
+
*/
|
|
462
|
+
personaChange?: boolean;
|
|
458
463
|
/**
|
|
459
464
|
* Why Resume doesn't apply to a stopped agent (AG-14.2), e.g. "codex
|
|
460
465
|
* can't resume a conversation": only Start fresh is offered.
|
|
@@ -553,6 +558,9 @@ export function agentRows(
|
|
|
553
558
|
state: st,
|
|
554
559
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
555
560
|
resumable,
|
|
561
|
+
...(!rot?.ready && !personaChangeRefusal(rec)
|
|
562
|
+
? { personaChange: true }
|
|
563
|
+
: {}),
|
|
556
564
|
...(why ? { noResume: why } : {}),
|
|
557
565
|
...(rec.stoppedAt ? { stoppedAt: rec.stoppedAt } : {}),
|
|
558
566
|
...(rec.endedAt ? { endedAt: rec.endedAt } : {}),
|
|
@@ -1675,6 +1683,17 @@ export interface ListedConversation extends RuntimeConversation {
|
|
|
1675
1683
|
open: boolean;
|
|
1676
1684
|
/** The Den agent already continuing it, if one is. */
|
|
1677
1685
|
agent?: string;
|
|
1686
|
+
/**
|
|
1687
|
+
* AG-17.1c: the persona Bring in preselects: the one of the latest Den
|
|
1688
|
+
* agent that worked in its folder (or one inside it), else the
|
|
1689
|
+
* workspace's latest agent's. The person confirms; never submitted.
|
|
1690
|
+
*/
|
|
1691
|
+
suggest?: string;
|
|
1692
|
+
/**
|
|
1693
|
+
* AG-17.1c: the MCP servers its tools came from that Den won't load (an
|
|
1694
|
+
* agent runs with Den's tools only).
|
|
1695
|
+
*/
|
|
1696
|
+
lost?: string[];
|
|
1678
1697
|
}
|
|
1679
1698
|
|
|
1680
1699
|
/** Each runtime, and whether it can bring a conversation in. */
|
|
@@ -1729,6 +1748,7 @@ export function listConversations(
|
|
|
1729
1748
|
// Its folder and every repo's; a shared root only exactly (AG-17.1).
|
|
1730
1749
|
const scope = d.workspaceFolders(ws);
|
|
1731
1750
|
const taken = agentByToken(d);
|
|
1751
|
+
const suggestFor = personaSuggester(d, ws);
|
|
1732
1752
|
const now = Date.now();
|
|
1733
1753
|
const conversations: ListedConversation[] = [];
|
|
1734
1754
|
const seen = new Set<string>();
|
|
@@ -1740,11 +1760,15 @@ export function listConversations(
|
|
|
1740
1760
|
if (seen.has(`${id} ${c.id}`)) continue;
|
|
1741
1761
|
seen.add(`${id} ${c.id}`);
|
|
1742
1762
|
const agent = taken.get(`${id} ${c.id}`);
|
|
1763
|
+
const lost = (c.mcp ?? []).filter((m) => m !== d.cli);
|
|
1764
|
+
const suggest = suggestFor(c.cwd);
|
|
1743
1765
|
conversations.push({
|
|
1744
1766
|
...c,
|
|
1745
1767
|
runtime: id,
|
|
1746
1768
|
open: openElsewhere(c.lastAt, now),
|
|
1747
1769
|
...(agent ? { agent } : {}),
|
|
1770
|
+
...(suggest ? { suggest } : {}),
|
|
1771
|
+
...(lost.length ? { lost } : {}),
|
|
1748
1772
|
});
|
|
1749
1773
|
}
|
|
1750
1774
|
}
|
|
@@ -1752,6 +1776,36 @@ export function listConversations(
|
|
|
1752
1776
|
return { runtimes: bringInRuntimes(), conversations };
|
|
1753
1777
|
}
|
|
1754
1778
|
|
|
1779
|
+
/**
|
|
1780
|
+
* AG-17.1c: the persona to preselect for a conversation that ran in a
|
|
1781
|
+
* folder: the latest agent's of this workspace that worked there (or in a
|
|
1782
|
+
* folder inside it), else the workspace's latest agent's; only a persona
|
|
1783
|
+
* that still exists. Undefined: none to suggest (the page's first).
|
|
1784
|
+
*/
|
|
1785
|
+
function personaSuggester(
|
|
1786
|
+
d: AgentsResolved,
|
|
1787
|
+
ws: string,
|
|
1788
|
+
): (cwd: string) => string | undefined {
|
|
1789
|
+
let known: Set<string>;
|
|
1790
|
+
try {
|
|
1791
|
+
known = new Set(personas(d, agentsConfig(d)).keys());
|
|
1792
|
+
} catch {
|
|
1793
|
+
return () => undefined;
|
|
1794
|
+
}
|
|
1795
|
+
const recs = listRecords(d.stateDir())
|
|
1796
|
+
.filter((r) => r.workspace === ws && known.has(r.persona))
|
|
1797
|
+
.sort((a, b) =>
|
|
1798
|
+
(b.resumedAt ?? b.startedAt).localeCompare(a.resumedAt ?? a.startedAt),
|
|
1799
|
+
);
|
|
1800
|
+
const inside = (p: string, dir: string) => {
|
|
1801
|
+
const rel = path.relative(dir, p);
|
|
1802
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
1803
|
+
};
|
|
1804
|
+
return (cwd) =>
|
|
1805
|
+
(recs.find((r) => inside(r.cwd, cwd) || inside(cwd, r.cwd)) ?? recs[0])
|
|
1806
|
+
?.persona;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1755
1809
|
export interface ImportRequest {
|
|
1756
1810
|
workspace: string;
|
|
1757
1811
|
/** The runtime's conversation id. */
|
|
@@ -1759,6 +1813,13 @@ export interface ImportRequest {
|
|
|
1759
1813
|
persona: string;
|
|
1760
1814
|
runtime?: string;
|
|
1761
1815
|
id?: string;
|
|
1816
|
+
/**
|
|
1817
|
+
* AG-17.1c: a model for it (Advanced). Absent: the conversation's own
|
|
1818
|
+
* (its last answer's), else the persona's.
|
|
1819
|
+
*/
|
|
1820
|
+
model?: string;
|
|
1821
|
+
/** With model: "latest" or a pinned version (AG-12.3). */
|
|
1822
|
+
version?: string;
|
|
1762
1823
|
}
|
|
1763
1824
|
|
|
1764
1825
|
/** The persona's id, else the first `<persona>-<n>` no agent has. */
|
|
@@ -1831,9 +1892,23 @@ export async function importAgent(
|
|
|
1831
1892
|
const id = req.id ?? freeId(d, req.persona);
|
|
1832
1893
|
if (readRecord(d.stateDir(), id))
|
|
1833
1894
|
throw refused(`${where}: agent ${id} exists; give this one another --id`);
|
|
1895
|
+
// AG-17.1c: the conversation keeps its own model unless one is picked.
|
|
1896
|
+
const model = req.model ?? c.model;
|
|
1834
1897
|
return startAgent(
|
|
1835
1898
|
d,
|
|
1836
|
-
{
|
|
1899
|
+
{
|
|
1900
|
+
workspace: req.workspace,
|
|
1901
|
+
persona: req.persona,
|
|
1902
|
+
runtime,
|
|
1903
|
+
id,
|
|
1904
|
+
...(model ? { model } : {}),
|
|
1905
|
+
...(req.model !== undefined && req.version !== undefined
|
|
1906
|
+
? { version: req.version }
|
|
1907
|
+
: {}),
|
|
1908
|
+
// A model picked on the page is checked against the catalog; the
|
|
1909
|
+
// conversation's own is taken as it ran.
|
|
1910
|
+
...(req.model !== undefined ? { strict: true } : {}),
|
|
1911
|
+
},
|
|
1837
1912
|
{ bringIn: { token: c.id, cwd: c.cwd } },
|
|
1838
1913
|
);
|
|
1839
1914
|
}
|
|
@@ -2113,6 +2188,227 @@ export async function resumeAgent(
|
|
|
2113
2188
|
});
|
|
2114
2189
|
}
|
|
2115
2190
|
|
|
2191
|
+
// ── AG-18: change a running agent's persona ─────────────────────────────────
|
|
2192
|
+
|
|
2193
|
+
/** The refusal code while its turn is going and no `when` was given. */
|
|
2194
|
+
export const PERSONA_BUSY = "busy";
|
|
2195
|
+
|
|
2196
|
+
export interface PersonaRequest {
|
|
2197
|
+
persona: string;
|
|
2198
|
+
/** Advanced: another model. Absent: the one it runs (the conversation's). */
|
|
2199
|
+
model?: string;
|
|
2200
|
+
/** With model: "latest" or a pinned version. */
|
|
2201
|
+
version?: string;
|
|
2202
|
+
/**
|
|
2203
|
+
* Mid-turn: "after-turn" waits until its turn ends, "now" stops it. None:
|
|
2204
|
+
* refused (PERSONA_BUSY) while it works, so a turn is never cut silently.
|
|
2205
|
+
*/
|
|
2206
|
+
when?: "now" | "after-turn";
|
|
2207
|
+
/** Who asked (a person's sign-in, "cli"), for its record. */
|
|
2208
|
+
by?: string;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
export interface PersonaChanged extends Resumed {
|
|
2212
|
+
from: string;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/** How often, and how long, "after this turn" checks its turn (AG-18). */
|
|
2216
|
+
export const TURN_POLL_MS = 1_000;
|
|
2217
|
+
export const TURN_WAIT_MS = 30 * 60_000;
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Whether agent `rec` is mid-turn: true or false from its runtime (den-
|
|
2221
|
+
* claude: its transcript), null when it can't tell. Not running: false.
|
|
2222
|
+
*/
|
|
2223
|
+
async function agentBusy(
|
|
2224
|
+
d: AgentsResolved,
|
|
2225
|
+
rec: AgentRecord,
|
|
2226
|
+
): Promise<boolean | null> {
|
|
2227
|
+
const live = await withClient(d, async (c) => {
|
|
2228
|
+
const s = sessionOf(await c.list(), rec);
|
|
2229
|
+
return Boolean(s && !s.exited);
|
|
2230
|
+
});
|
|
2231
|
+
if (!live) return false;
|
|
2232
|
+
const rt = getRuntime(rec.runtime);
|
|
2233
|
+
if (!rt?.turnBusy || !rec.resumeToken) return null;
|
|
2234
|
+
return rt.turnBusy(rec.resumeToken, d.env);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** Why agent `rec` can't change persona (it can't resume), or null. */
|
|
2238
|
+
export function personaChangeRefusal(rec: AgentRecord): string | null {
|
|
2239
|
+
const rt = getRuntime(rec.runtime);
|
|
2240
|
+
if (!rt?.resume)
|
|
2241
|
+
return `${rt?.label ?? rec.runtime} can't continue a conversation, so it can't change persona`;
|
|
2242
|
+
return resumeRefusal(rec);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* AG-18, change persona: Den stops the agent's session and resumes the
|
|
2247
|
+
* same conversation with the new persona's launch settings (its role
|
|
2248
|
+
* prompt, permission mode, allowed commands). The history stays; so do the
|
|
2249
|
+
* id, name, workspace, folder and grants, and the model it runs unless
|
|
2250
|
+
* one is given. Mid-turn it never stops a turn unasked: refused with
|
|
2251
|
+
* PERSONA_BUSY, or `when` says to wait for the turn's end or stop now.
|
|
2252
|
+
* Only a runtime that can resume can. The person's only: callers refuse an
|
|
2253
|
+
* agent's token before this.
|
|
2254
|
+
*/
|
|
2255
|
+
export async function changePersona(
|
|
2256
|
+
d: AgentsResolved,
|
|
2257
|
+
id: string,
|
|
2258
|
+
ws: string | undefined,
|
|
2259
|
+
req: PersonaRequest,
|
|
2260
|
+
opts: { pollMs?: number; maxMs?: number } = {},
|
|
2261
|
+
): Promise<PersonaChanged> {
|
|
2262
|
+
const where = "agents persona";
|
|
2263
|
+
const rec = agentRecord(d, id, ws);
|
|
2264
|
+
const cfg = agentsConfig(d);
|
|
2265
|
+
const all = personas(d, cfg);
|
|
2266
|
+
if (!all.get(req.persona))
|
|
2267
|
+
throw usage(
|
|
2268
|
+
`${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
|
|
2269
|
+
);
|
|
2270
|
+
if (req.persona === rec.persona && req.model === undefined)
|
|
2271
|
+
throw usage(`${where}: agent ${rec.id} is already ${rec.persona}`);
|
|
2272
|
+
const why = personaChangeRefusal(rec);
|
|
2273
|
+
if (why) throw refused(why, [], 422);
|
|
2274
|
+
let busy = await agentBusy(d, rec);
|
|
2275
|
+
if (busy !== false && req.when === undefined)
|
|
2276
|
+
throw new AgentError(
|
|
2277
|
+
busy
|
|
2278
|
+
? `agent ${rec.id} is working. Change after this turn, or stop it now?`
|
|
2279
|
+
: `agent ${rec.id} is running and Den can't tell whether it is mid-turn. Change after this turn, or stop it now?`,
|
|
2280
|
+
{
|
|
2281
|
+
status: 409,
|
|
2282
|
+
code: PERSONA_BUSY,
|
|
2283
|
+
hints: [
|
|
2284
|
+
`After this turn: ${d.cli} agents persona ${rec.id} ${req.persona} --after-turn`,
|
|
2285
|
+
`Stop it now: ${d.cli} agents persona ${rec.id} ${req.persona} --now`,
|
|
2286
|
+
],
|
|
2287
|
+
},
|
|
2288
|
+
);
|
|
2289
|
+
if (busy === null && req.when === "after-turn")
|
|
2290
|
+
throw refused(
|
|
2291
|
+
`${where}: Den can't tell when agent ${rec.id}'s turn ends; stop it now instead (--now)`,
|
|
2292
|
+
);
|
|
2293
|
+
if (req.when !== "after-turn") return switchPersona(d, rec, req, where);
|
|
2294
|
+
// Waiting: marked on the record, so a restart that drops the wait can
|
|
2295
|
+
// say so (cancelStalePersonaChanges); cleared however this ends.
|
|
2296
|
+
writeRecord(d.stateDir(), {
|
|
2297
|
+
...rec,
|
|
2298
|
+
pendingPersona: {
|
|
2299
|
+
to: req.persona,
|
|
2300
|
+
since: new Date().toISOString(),
|
|
2301
|
+
...(req.by ? { by: req.by } : {}),
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
try {
|
|
2305
|
+
const deadline = Date.now() + (opts.maxMs ?? TURN_WAIT_MS);
|
|
2306
|
+
while (busy) {
|
|
2307
|
+
if (Date.now() >= deadline)
|
|
2308
|
+
throw refused(
|
|
2309
|
+
`${where}: agent ${rec.id} was still working after ${Math.round((opts.maxMs ?? TURN_WAIT_MS) / 60_000)} min; nothing changed`,
|
|
2310
|
+
);
|
|
2311
|
+
await new Promise((r) => setTimeout(r, opts.pollMs ?? TURN_POLL_MS));
|
|
2312
|
+
busy = await agentBusy(d, agentRecord(d, id, ws));
|
|
2313
|
+
}
|
|
2314
|
+
return await switchPersona(d, rec, req, where);
|
|
2315
|
+
} catch (e) {
|
|
2316
|
+
clearPendingPersona(d, rec.id);
|
|
2317
|
+
throw e;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/** Drop agent `id`'s pending persona change mark, if it has one. */
|
|
2322
|
+
function clearPendingPersona(d: AgentsResolved, id: string): void {
|
|
2323
|
+
const cur = readRecord(d.stateDir(), id);
|
|
2324
|
+
if (!cur?.pendingPersona) return;
|
|
2325
|
+
const { pendingPersona: _pp, ...rest } = cur;
|
|
2326
|
+
writeRecord(d.stateDir(), rest);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* At start (AG-18): every persona change still marked pending was dropped
|
|
2331
|
+
* when Den stopped, since the wait lived in memory. Clears each mark and
|
|
2332
|
+
* returns them, for the audit trail.
|
|
2333
|
+
*/
|
|
2334
|
+
export function cancelStalePersonaChanges(d: AgentsResolved): {
|
|
2335
|
+
id: string;
|
|
2336
|
+
workspace: string;
|
|
2337
|
+
from: string;
|
|
2338
|
+
to: string;
|
|
2339
|
+
by?: string;
|
|
2340
|
+
}[] {
|
|
2341
|
+
const out: ReturnType<typeof cancelStalePersonaChanges> = [];
|
|
2342
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
2343
|
+
const p = rec.pendingPersona;
|
|
2344
|
+
if (!p) continue;
|
|
2345
|
+
clearPendingPersona(d, rec.id);
|
|
2346
|
+
out.push({
|
|
2347
|
+
id: rec.id,
|
|
2348
|
+
workspace: rec.workspace,
|
|
2349
|
+
from: rec.persona,
|
|
2350
|
+
to: p.to,
|
|
2351
|
+
...(p.by ? { by: p.by } : {}),
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
return out;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** The switch itself: stop, the new persona's settings, resume. */
|
|
2358
|
+
async function switchPersona(
|
|
2359
|
+
d: AgentsResolved,
|
|
2360
|
+
rec: AgentRecord,
|
|
2361
|
+
req: PersonaRequest,
|
|
2362
|
+
where: string,
|
|
2363
|
+
): Promise<PersonaChanged> {
|
|
2364
|
+
const stopped = await stopAgent(d, rec.id, rec.workspace);
|
|
2365
|
+
if (stopped.stillStopping)
|
|
2366
|
+
throw refused(
|
|
2367
|
+
`${where}: agent ${rec.id} is still stopping; try again in a moment`,
|
|
2368
|
+
);
|
|
2369
|
+
// The new persona's permissions apply: the old one's choices go. The
|
|
2370
|
+
// model stays (the conversation's) unless one is given.
|
|
2371
|
+
const {
|
|
2372
|
+
permission: _p,
|
|
2373
|
+
allow: _a,
|
|
2374
|
+
overrides: _o,
|
|
2375
|
+
pendingPersona: _pp,
|
|
2376
|
+
...base
|
|
2377
|
+
} = stopped.rec;
|
|
2378
|
+
const keptOverrides = (rec.overrides ?? []).filter(
|
|
2379
|
+
(o) => o === "runtime" || o === "model" || o === "version",
|
|
2380
|
+
);
|
|
2381
|
+
const next: AgentRecord = {
|
|
2382
|
+
...base,
|
|
2383
|
+
persona: req.persona,
|
|
2384
|
+
...(req.model !== undefined
|
|
2385
|
+
? { model: req.model, version: req.version ?? "latest" }
|
|
2386
|
+
: {}),
|
|
2387
|
+
...(keptOverrides.length || req.model !== undefined
|
|
2388
|
+
? {
|
|
2389
|
+
overrides: [
|
|
2390
|
+
...new Set([
|
|
2391
|
+
...keptOverrides,
|
|
2392
|
+
...(req.model !== undefined ? (["model"] as const) : []),
|
|
2393
|
+
]),
|
|
2394
|
+
],
|
|
2395
|
+
}
|
|
2396
|
+
: {}),
|
|
2397
|
+
personaChanges: [
|
|
2398
|
+
...(rec.personaChanges ?? []),
|
|
2399
|
+
{
|
|
2400
|
+
from: rec.persona,
|
|
2401
|
+
to: req.persona,
|
|
2402
|
+
at: new Date().toISOString(),
|
|
2403
|
+
...(req.by ? { by: req.by } : {}),
|
|
2404
|
+
},
|
|
2405
|
+
],
|
|
2406
|
+
};
|
|
2407
|
+
writeRecord(d.stateDir(), next);
|
|
2408
|
+
const r = await resumeAgent(d, rec.id, rec.workspace);
|
|
2409
|
+
return { ...r, from: rec.persona };
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2116
2412
|
// ── AG-14: start fresh, remove, restart ─────────────────────────────────────
|
|
2117
2413
|
|
|
2118
2414
|
/**
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
*/
|
|
65
65
|
import * as fs from "fs";
|
|
66
66
|
import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
|
|
67
|
+
import { ACTOR_TOKEN_ENV } from "../../shared/actor-token.js";
|
|
67
68
|
import { initLoginPath } from "../../shared/agents/runtime-search.js";
|
|
68
69
|
import { HostError } from "../../shared/sessions/client.js";
|
|
69
70
|
import { checkHandback } from "../../shared/agents/handback.js";
|
|
@@ -77,6 +78,7 @@ import {
|
|
|
77
78
|
import {
|
|
78
79
|
AgentError,
|
|
79
80
|
importAgent,
|
|
81
|
+
changePersona,
|
|
80
82
|
listAgents,
|
|
81
83
|
listConversations,
|
|
82
84
|
listPersonas as personaList,
|
|
@@ -116,7 +118,7 @@ export {
|
|
|
116
118
|
|
|
117
119
|
export const AGENTS_HELP = {
|
|
118
120
|
usage:
|
|
119
|
-
"agents <list|start|stop|resume|relaunch|import|rm|personas|doctor|handback-check|handoff-check> ...",
|
|
121
|
+
"agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
|
|
120
122
|
lines: [
|
|
121
123
|
"Agents: a persona and a workspace, run by a runtime in a session that",
|
|
122
124
|
"outlives the web server (see `{cli} sessions`).",
|
|
@@ -142,11 +144,17 @@ export const AGENTS_HELP = {
|
|
|
142
144
|
" passes the check, a fresh conversation (same persona, workspace, folder,",
|
|
143
145
|
" grants) whose first prompt is the handoff",
|
|
144
146
|
"{cli} agents import -w <ws> [--runtime <r>] [--json]",
|
|
145
|
-
"{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>]",
|
|
147
|
+
"{cli} agents import -w <ws> --session <id> --persona <p> [--runtime <r>] [--id <id>] [--model <m>]",
|
|
146
148
|
" bring in a conversation started outside Den: the first lists the runtime's",
|
|
147
149
|
" own (default den-claude) in the workspace's folder; the second continues",
|
|
148
150
|
" one as a Den agent (its resume, in the folder it ran in). Refused while it",
|
|
149
|
-
" is still open in another terminal: close it there first",
|
|
151
|
+
" is still open in another terminal: close it there first. It keeps the",
|
|
152
|
+
" conversation's own model unless --model gives one",
|
|
153
|
+
"{cli} agents persona <id> <persona> [-w <ws>] [--model <m>] [--now | --after-turn]",
|
|
154
|
+
" change an agent's persona: the same conversation resumes with the new",
|
|
155
|
+
" persona's role, permissions and commands; id, name, folder and model stay.",
|
|
156
|
+
" Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
|
|
157
|
+
" A person's only (refused in an agent's session)",
|
|
150
158
|
"{cli} agents rm <id> [-w <ws>]",
|
|
151
159
|
"{cli} agents rm --stopped [-w <ws>]",
|
|
152
160
|
" forget a stopped, interrupted or exited agent (never a running one);",
|
|
@@ -306,6 +314,8 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
306
314
|
"--session": true,
|
|
307
315
|
"--persona": true,
|
|
308
316
|
"--id": true,
|
|
317
|
+
"--model": true,
|
|
318
|
+
"--version": true,
|
|
309
319
|
"--json": false,
|
|
310
320
|
});
|
|
311
321
|
noExtra(where, p);
|
|
@@ -355,6 +365,11 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
355
365
|
persona,
|
|
356
366
|
runtime,
|
|
357
367
|
id: str(p, "--id"),
|
|
368
|
+
// AG-17.1c: the conversation's own model unless one is given.
|
|
369
|
+
...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
|
|
370
|
+
...(str(p, "--version") !== undefined
|
|
371
|
+
? { version: str(p, "--version") }
|
|
372
|
+
: {}),
|
|
358
373
|
});
|
|
359
374
|
return report(
|
|
360
375
|
d,
|
|
@@ -363,6 +378,52 @@ async function bringIn(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
363
378
|
);
|
|
364
379
|
}
|
|
365
380
|
|
|
381
|
+
/**
|
|
382
|
+
* `agents persona <id> <persona> [-w <ws>] [--model <m>] [--version <v>]
|
|
383
|
+
* [--now | --after-turn]` (AG-18). A person's only: refused inside an
|
|
384
|
+
* agent's session (its DEN_ACTOR_TOKEN), so an agent can't widen its own
|
|
385
|
+
* permissions.
|
|
386
|
+
*/
|
|
387
|
+
async function persona(args: string[], d: AgentsResolved): Promise<number> {
|
|
388
|
+
const where = "agents persona";
|
|
389
|
+
if (d.env[ACTOR_TOKEN_ENV] !== undefined)
|
|
390
|
+
throw new AgentError(
|
|
391
|
+
`${where}: only a person can change an agent's persona (this is an agent's session)`,
|
|
392
|
+
);
|
|
393
|
+
const p = parseArgs(where, args, {
|
|
394
|
+
"--workspace": true,
|
|
395
|
+
"--model": true,
|
|
396
|
+
"--version": true,
|
|
397
|
+
"--now": false,
|
|
398
|
+
"--after-turn": false,
|
|
399
|
+
});
|
|
400
|
+
noExtra(where, p, 2);
|
|
401
|
+
const [id, to] = p.positional;
|
|
402
|
+
if (!id || !to) throw new UsageError(`${where} needs <id> <persona>`);
|
|
403
|
+
if (p.flags["--now"] && p.flags["--after-turn"])
|
|
404
|
+
throw new UsageError(`${where}: --now or --after-turn, not both`);
|
|
405
|
+
const when = p.flags["--now"]
|
|
406
|
+
? "now"
|
|
407
|
+
: p.flags["--after-turn"]
|
|
408
|
+
? "after-turn"
|
|
409
|
+
: undefined;
|
|
410
|
+
if (when === "after-turn") d.out(`waiting for agent ${id}'s turn to end…`);
|
|
411
|
+
const s = await changePersona(d, id, workspaceFlag(where, p), {
|
|
412
|
+
persona: to,
|
|
413
|
+
...(str(p, "--model") !== undefined ? { model: str(p, "--model") } : {}),
|
|
414
|
+
...(str(p, "--version") !== undefined
|
|
415
|
+
? { version: str(p, "--version") }
|
|
416
|
+
: {}),
|
|
417
|
+
...(when ? { when } : {}),
|
|
418
|
+
by: "cli",
|
|
419
|
+
});
|
|
420
|
+
return report(
|
|
421
|
+
d,
|
|
422
|
+
s,
|
|
423
|
+
`agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
366
427
|
/** `<id> [-w <ws>]`. */
|
|
367
428
|
function idAndWorkspace(
|
|
368
429
|
where: string,
|
|
@@ -703,6 +764,9 @@ export async function cmdAgents(
|
|
|
703
764
|
case "import":
|
|
704
765
|
await loadAgentWorkspaces(deps);
|
|
705
766
|
return await bringIn(rest, d);
|
|
767
|
+
case "persona":
|
|
768
|
+
await loadAgentWorkspaces(deps);
|
|
769
|
+
return await persona(rest, d);
|
|
706
770
|
case "rm":
|
|
707
771
|
await loadAgentWorkspaces(deps);
|
|
708
772
|
return await rm(rest, d);
|
|
@@ -119,6 +119,7 @@ import {
|
|
|
119
119
|
updatePersona,
|
|
120
120
|
listRuntimes,
|
|
121
121
|
markInterrupted,
|
|
122
|
+
cancelStalePersonaChanges,
|
|
122
123
|
NOT_IN_WORKSPACE,
|
|
123
124
|
pruneStopped,
|
|
124
125
|
removeAgent,
|
|
@@ -129,6 +130,7 @@ import {
|
|
|
129
130
|
setRuntimePath,
|
|
130
131
|
resolveAgentsDeps,
|
|
131
132
|
importAgent,
|
|
133
|
+
changePersona,
|
|
132
134
|
listConversations,
|
|
133
135
|
relaunchAgent,
|
|
134
136
|
resumeAgent,
|
|
@@ -159,10 +161,12 @@ export interface AgentRouteDeps {
|
|
|
159
161
|
agents?: AgentsDeps;
|
|
160
162
|
/** When a first instruction counts its session ready (tests: shorter). */
|
|
161
163
|
instruction?: { quietMs?: number; maxMs?: number };
|
|
164
|
+
/** AG-18: how "after this turn" polls (tests: shorter). */
|
|
165
|
+
personaWait?: { pollMs?: number; maxMs?: number };
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
const AGENTS_PATH =
|
|
165
|
-
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths))?$/;
|
|
169
|
+
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona))?$/;
|
|
166
170
|
|
|
167
171
|
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
168
172
|
const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
|
|
@@ -476,6 +480,7 @@ export async function handleAgentRoutes(
|
|
|
476
480
|
| "remove"
|
|
477
481
|
| "name"
|
|
478
482
|
| "paths"
|
|
483
|
+
| "persona"
|
|
479
484
|
| undefined;
|
|
480
485
|
|
|
481
486
|
if (!action && method === "GET") {
|
|
@@ -550,6 +555,13 @@ export async function handleAgentRoutes(
|
|
|
550
555
|
if (id !== undefined) agentRecord(d, id, ws);
|
|
551
556
|
// AG-14.4: MCP agent_resume may resume another agent of its scope.
|
|
552
557
|
const agent = agentPrincipalOf(req);
|
|
558
|
+
// AG-18: a persona is the person's choice: an agent could widen its
|
|
559
|
+
// own permissions with it. Any agent credential or claim is refused.
|
|
560
|
+
if (verb === "persona" && (agent || actor))
|
|
561
|
+
throw new AgentError(
|
|
562
|
+
"only a person can change an agent's persona (an agent can't change its own or another's)",
|
|
563
|
+
{ status: 403 },
|
|
564
|
+
);
|
|
553
565
|
if (verb === "resume" && agent) {
|
|
554
566
|
const why = agentResumeRefusal(agent, ws, id!, mcpActions());
|
|
555
567
|
if (why) throw new AgentError(why, { status: 403 });
|
|
@@ -647,6 +659,62 @@ export async function handleAgentRoutes(
|
|
|
647
659
|
agent: rec.id,
|
|
648
660
|
...(rec.displayName ? { displayName: rec.displayName } : {}),
|
|
649
661
|
});
|
|
662
|
+
} else if (verb === "persona") {
|
|
663
|
+
const body = await jsonBody(req);
|
|
664
|
+
for (const k of Object.keys(body))
|
|
665
|
+
if (!PERSONA_KEYS.includes(k))
|
|
666
|
+
throw new AgentError(
|
|
667
|
+
`${k}: unknown field (a persona change takes ${PERSONA_KEYS.join(", ")})`,
|
|
668
|
+
{ usage: true },
|
|
669
|
+
);
|
|
670
|
+
const str = (k: string): string | undefined => {
|
|
671
|
+
const v = body[k];
|
|
672
|
+
if (v === undefined || v === null || v === "") return undefined;
|
|
673
|
+
if (typeof v !== "string" || v.length > 128 || /[^\x21-\x7e]/.test(v))
|
|
674
|
+
throw new AgentError(`${k} must be a short name`, { usage: true });
|
|
675
|
+
return v;
|
|
676
|
+
};
|
|
677
|
+
const persona = str("persona");
|
|
678
|
+
if (!persona)
|
|
679
|
+
throw new AgentError("persona is required", { usage: true });
|
|
680
|
+
const when = str("when");
|
|
681
|
+
if (when !== undefined && when !== "now" && when !== "after-turn")
|
|
682
|
+
throw new AgentError("when must be now or after-turn", {
|
|
683
|
+
usage: true,
|
|
684
|
+
});
|
|
685
|
+
const from = agentRecord(d, id!, ws).persona;
|
|
686
|
+
const by = principalOf(req) ?? "this machine";
|
|
687
|
+
what = `persona ${id} in ${ws}: ${from} → ${persona}, by ${by}`;
|
|
688
|
+
const change = () =>
|
|
689
|
+
changePersona(
|
|
690
|
+
d,
|
|
691
|
+
id!,
|
|
692
|
+
ws,
|
|
693
|
+
{
|
|
694
|
+
persona,
|
|
695
|
+
...(str("model") !== undefined ? { model: str("model") } : {}),
|
|
696
|
+
...(str("version") !== undefined
|
|
697
|
+
? { version: str("version") }
|
|
698
|
+
: {}),
|
|
699
|
+
...(when ? { when: when as "now" | "after-turn" } : {}),
|
|
700
|
+
by,
|
|
701
|
+
},
|
|
702
|
+
deps.personaWait ?? {},
|
|
703
|
+
);
|
|
704
|
+
if (when === "after-turn") {
|
|
705
|
+
// It may wait for a long turn: answer now, audit when it's done.
|
|
706
|
+
agentRecord(d, id!, ws);
|
|
707
|
+
void change().then(
|
|
708
|
+
(s) => audit(what, s.early ? "exited at once" : undefined),
|
|
709
|
+
(e: unknown) =>
|
|
710
|
+
audit(what, e instanceof Error ? e.message : String(e)),
|
|
711
|
+
);
|
|
712
|
+
sendJson(res, { ok: true, agent: id, pending: true, persona }, 202);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
const s = await change();
|
|
716
|
+
audit(what, s.early ? "exited at once" : undefined);
|
|
717
|
+
sendStarted(res, s, d);
|
|
650
718
|
} else if (verb === "remove") {
|
|
651
719
|
const removed = await removeAgent(d, id!, ws);
|
|
652
720
|
audit(what);
|
|
@@ -663,7 +731,9 @@ export async function handleAgentRoutes(
|
|
|
663
731
|
return true;
|
|
664
732
|
}
|
|
665
733
|
|
|
666
|
-
const IMPORT_KEYS = ["persona", "runtime", "id"];
|
|
734
|
+
const IMPORT_KEYS = ["persona", "runtime", "id", "model", "version"];
|
|
735
|
+
/** AG-18: what a persona change takes. */
|
|
736
|
+
const PERSONA_KEYS = ["persona", "model", "version", "when"];
|
|
667
737
|
|
|
668
738
|
/**
|
|
669
739
|
* Bring in (AG-17.1): GET lists the workspace's conversations, POST
|
|
@@ -737,6 +807,8 @@ async function conversations(
|
|
|
737
807
|
persona,
|
|
738
808
|
runtime: str("runtime"),
|
|
739
809
|
id: str("id"),
|
|
810
|
+
...(str("model") !== undefined ? { model: str("model") } : {}),
|
|
811
|
+
...(str("version") !== undefined ? { version: str("version") } : {}),
|
|
740
812
|
});
|
|
741
813
|
what = `bring in ${session} as ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
742
814
|
audit(s.early ? "exited at once" : undefined);
|
|
@@ -1089,6 +1161,21 @@ export function startAgentUpkeep(
|
|
|
1089
1161
|
): void {
|
|
1090
1162
|
const d = webDeps(deps.agents);
|
|
1091
1163
|
const restart = async () => {
|
|
1164
|
+
// AG-18: a persona change waiting for a turn's end lived in memory.
|
|
1165
|
+
try {
|
|
1166
|
+
for (const c of cancelStalePersonaChanges(d)) {
|
|
1167
|
+
const what = `persona ${c.id} in ${c.workspace}: ${c.from} → ${c.to}, by ${c.by ?? "this machine"}`;
|
|
1168
|
+
recordAction(null, {
|
|
1169
|
+
verb: "agents",
|
|
1170
|
+
detail: what,
|
|
1171
|
+
outcome: "refused",
|
|
1172
|
+
reason: "persona change cancelled: Den restarted",
|
|
1173
|
+
});
|
|
1174
|
+
log(`Persona change cancelled (Den restarted): ${what}`);
|
|
1175
|
+
}
|
|
1176
|
+
} catch (e) {
|
|
1177
|
+
log(` ⚠ persona changes after the restart: ${(e as Error).message}`);
|
|
1178
|
+
}
|
|
1092
1179
|
try {
|
|
1093
1180
|
const f = await markInterrupted(d);
|
|
1094
1181
|
if (f.interrupted.length)
|
|
@@ -98,7 +98,7 @@ function finishedMessage(v: FlowView, cli: string): string | undefined {
|
|
|
98
98
|
switch (v.state) {
|
|
99
99
|
case "refused":
|
|
100
100
|
return v.purpose === "signin"
|
|
101
|
-
? `@${v.login} isn't
|
|
101
|
+
? `@${v.login} isn't the owner of this Den. Only the owner's account can use Sign in with GitHub. Use Approve this computer instead.`
|
|
102
102
|
: `An owner is linked already; \`${cli} web owner unlink\` first.`;
|
|
103
103
|
case "expired":
|
|
104
104
|
return "The code expired before it was approved. Start again.";
|