@monster-orange/den 0.4.22 → 0.4.23
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 +47 -0
- package/docs/distributions.md +91 -30
- package/package.json +1 -1
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/den-config.ts +27 -12
- package/src/ui/cli/agent-ops.ts +449 -15
- package/src/ui/cli/agents-command.ts +169 -3
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/web-command.ts +32 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +149 -5
- package/src/ui/server/index.ts +13 -5
- package/src/ui/server/public/agents-page.js +214 -22
- package/src/ui/server/public/agents.html +32 -0
- package/src/ui/server/public/i18n/es.js +44 -2
- package/src/ui/server/public/i18n/ja.js +40 -2
- package/src/ui/server/public/settings-registry.js +204 -0
- package/src/ui/server/public/settings.html +43 -4
- package/src/ui/server/public/shell.js +42 -2
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +23 -5
|
@@ -99,8 +99,10 @@ import {
|
|
|
99
99
|
import {
|
|
100
100
|
readWorkspacesFile,
|
|
101
101
|
conversationFolders,
|
|
102
|
+
listWorkspaceServices,
|
|
102
103
|
resolveWorkspaceDir,
|
|
103
104
|
} from "../../shared/environment-manager/workspaces.js";
|
|
105
|
+
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
104
106
|
import { getStateDir } from "../../shared/paths.js";
|
|
105
107
|
import { HostError } from "../../shared/sessions/client.js";
|
|
106
108
|
import {
|
|
@@ -133,7 +135,13 @@ import {
|
|
|
133
135
|
changePersona,
|
|
134
136
|
listConversations,
|
|
135
137
|
relaunchAgent,
|
|
136
|
-
|
|
138
|
+
resumeOrRelaunch,
|
|
139
|
+
keepAgentConversation,
|
|
140
|
+
askForHandoff,
|
|
141
|
+
rotationSettings,
|
|
142
|
+
setRotationSetting,
|
|
143
|
+
rotationTick,
|
|
144
|
+
ROTATION_TICK_MS,
|
|
137
145
|
startAgent,
|
|
138
146
|
startFresh,
|
|
139
147
|
stopAgent,
|
|
@@ -167,7 +175,7 @@ export interface AgentRouteDeps {
|
|
|
167
175
|
}
|
|
168
176
|
|
|
169
177
|
const AGENTS_PATH =
|
|
170
|
-
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona))?$/;
|
|
178
|
+
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona|keep|handoff))?$/;
|
|
171
179
|
|
|
172
180
|
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
173
181
|
const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
|
|
@@ -203,6 +211,15 @@ export function webDeps(over: AgentsDeps = {}): AgentsResolved {
|
|
|
203
211
|
return conversationFolders(ws, cfg, getReposRoot());
|
|
204
212
|
},
|
|
205
213
|
}),
|
|
214
|
+
// D28: the service scope of rotation settings.
|
|
215
|
+
workspaceServices: (ws) => {
|
|
216
|
+
const cfg = readWorkspacesFile().workspaces[ws];
|
|
217
|
+
if (!cfg) return [];
|
|
218
|
+
return listWorkspaceServices(cfg, readCatalog(), ws).map((r) => ({
|
|
219
|
+
address: r.address,
|
|
220
|
+
path: r.repoPath,
|
|
221
|
+
}));
|
|
222
|
+
},
|
|
206
223
|
stdout: NO_TTY,
|
|
207
224
|
out: () => undefined,
|
|
208
225
|
err: () => undefined,
|
|
@@ -406,6 +423,8 @@ export async function handleAgentRoutes(
|
|
|
406
423
|
pathname === "/api/agents/clear-stopped" ||
|
|
407
424
|
pathname === "/api/agents/restore" ||
|
|
408
425
|
pathname === "/api/agents/resume-interrupted";
|
|
426
|
+
if (pathname === "/api/agents/rotation")
|
|
427
|
+
return rotationRoute(req, res, webDeps(deps.agents));
|
|
409
428
|
if (
|
|
410
429
|
pathname !== "/api/personas" &&
|
|
411
430
|
pathname !== "/api/agents/runtimes" &&
|
|
@@ -490,6 +509,8 @@ export async function handleAgentRoutes(
|
|
|
490
509
|
| "name"
|
|
491
510
|
| "paths"
|
|
492
511
|
| "persona"
|
|
512
|
+
| "keep"
|
|
513
|
+
| "handoff"
|
|
493
514
|
| undefined;
|
|
494
515
|
|
|
495
516
|
if (!action && method === "GET") {
|
|
@@ -571,6 +592,12 @@ export async function handleAgentRoutes(
|
|
|
571
592
|
"only a person can change an agent's persona (an agent can't change its own or another's)",
|
|
572
593
|
{ status: 403 },
|
|
573
594
|
);
|
|
595
|
+
// D28: an agent could keep itself from ever rotating.
|
|
596
|
+
if (verb === "keep" && (agent || actor))
|
|
597
|
+
throw new AgentError(
|
|
598
|
+
"only a person can keep an agent's conversation past its rotation",
|
|
599
|
+
{ status: 403 },
|
|
600
|
+
);
|
|
574
601
|
if (verb === "resume" && agent) {
|
|
575
602
|
const why = agentResumeRefusal(agent, ws, id!, mcpActions());
|
|
576
603
|
if (why) throw new AgentError(why, { status: 403 });
|
|
@@ -651,6 +678,38 @@ export async function handleAgentRoutes(
|
|
|
651
678
|
what = `start ${s.rec.id} fresh (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
|
|
652
679
|
audit(what, s.early ? "exited at once" : undefined);
|
|
653
680
|
sendStarted(res, s, d);
|
|
681
|
+
} else if (verb === "keep") {
|
|
682
|
+
const body = await jsonBody(req);
|
|
683
|
+
for (const k of Object.keys(body))
|
|
684
|
+
if (k !== "never")
|
|
685
|
+
throw new AgentError(`${k}: unknown field (keep takes never)`, {
|
|
686
|
+
usage: true,
|
|
687
|
+
});
|
|
688
|
+
if (body.never !== undefined && typeof body.never !== "boolean")
|
|
689
|
+
throw new AgentError("never must be true or false", { usage: true });
|
|
690
|
+
const rec = keepAgentConversation(d, id!, ws, {
|
|
691
|
+
never: body.never === true,
|
|
692
|
+
});
|
|
693
|
+
what = `keep ${rec.id}'s conversation in ${ws}${body.never ? " (never rotate)" : ""}`;
|
|
694
|
+
audit(what);
|
|
695
|
+
sendJson(res, { ok: true, agent: rec.id, never: body.never === true });
|
|
696
|
+
} else if (verb === "handoff") {
|
|
697
|
+
const body = await jsonBody(req);
|
|
698
|
+
for (const k of Object.keys(body))
|
|
699
|
+
if (k !== "relaunch")
|
|
700
|
+
throw new AgentError(`${k}: unknown field (handoff takes relaunch)`, {
|
|
701
|
+
usage: true,
|
|
702
|
+
});
|
|
703
|
+
const relaunch = body.relaunch === true;
|
|
704
|
+
const rec = await askForHandoff(d, id!, ws, undefined, { relaunch });
|
|
705
|
+
what = `ask ${rec.id} for its handoff in ${ws}${relaunch ? ", then relaunch" : ""}${rec.resumed ? " (resumed to write it)" : ""}`;
|
|
706
|
+
audit(what);
|
|
707
|
+
sendJson(res, {
|
|
708
|
+
ok: true,
|
|
709
|
+
agent: rec.id,
|
|
710
|
+
relaunch,
|
|
711
|
+
...(rec.resumed ? { resumed: true } : {}),
|
|
712
|
+
});
|
|
654
713
|
} else if (verb === "relaunch") {
|
|
655
714
|
const s = await relaunchAgent(d, id!, ws);
|
|
656
715
|
what = `relaunch ${s.rec.id} from its handoff (${s.why}) in ${ws}`;
|
|
@@ -732,9 +791,16 @@ export async function handleAgentRoutes(
|
|
|
732
791
|
audit(what);
|
|
733
792
|
sendJson(res, { ok: true, removed });
|
|
734
793
|
} else {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
794
|
+
// D28: a due agent under auto relaunches from its handoff instead.
|
|
795
|
+
const r = await resumeOrRelaunch(d, id!, ws);
|
|
796
|
+
if (r.kind === "relaunched") {
|
|
797
|
+
what = `relaunch ${r.s.rec.id} from its handoff (${r.s.why}) in ${ws}, on Resume`;
|
|
798
|
+
audit(what, r.s.early ? "exited at once" : undefined);
|
|
799
|
+
sendStarted(res, r.s, d, { relaunched: true });
|
|
800
|
+
} else {
|
|
801
|
+
audit(what);
|
|
802
|
+
sendStarted(res, r.s, d);
|
|
803
|
+
}
|
|
738
804
|
}
|
|
739
805
|
} catch (e) {
|
|
740
806
|
audit(what, e instanceof Error ? e.message : String(e));
|
|
@@ -1209,8 +1275,86 @@ export function startAgentUpkeep(
|
|
|
1209
1275
|
log(` ⚠ agents.remove_stopped_after: ${(e as Error).message}`);
|
|
1210
1276
|
}
|
|
1211
1277
|
};
|
|
1278
|
+
// D28: due agents under on_due auto, at idle: asked for the handoff,
|
|
1279
|
+
// then relaunched from it. One look at a time.
|
|
1280
|
+
let looking = false;
|
|
1281
|
+
const rotate = async () => {
|
|
1282
|
+
if (looking) return;
|
|
1283
|
+
looking = true;
|
|
1284
|
+
try {
|
|
1285
|
+
for (const e of await rotationTick(d)) {
|
|
1286
|
+
const who = `${e.agent} in ${e.workspace}`;
|
|
1287
|
+
if (e.kind === "relaunched") {
|
|
1288
|
+
const what = `relaunch ${who} from its handoff (${e.why}), by itself; the old conversation ${e.old ?? "?"} is kept`;
|
|
1289
|
+
recordAction(null, { verb: "agents", detail: what });
|
|
1290
|
+
log(`Relaunched ${who} from its handoff after ${e.why}`);
|
|
1291
|
+
} else if (e.kind === "asked")
|
|
1292
|
+
log(`Asked ${who} for its handoff (${e.asked} of 2)`);
|
|
1293
|
+
else if (e.kind === "fell-back")
|
|
1294
|
+
log(
|
|
1295
|
+
` ⚠ ${who} wrote no handoff after 2 asks: its callout asks the person`,
|
|
1296
|
+
);
|
|
1297
|
+
else log(` ⚠ rotation of ${who}: ${e.why}`);
|
|
1298
|
+
}
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
log(` ⚠ agents.rotate: ${(e as Error).message}`);
|
|
1301
|
+
} finally {
|
|
1302
|
+
looking = false;
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1212
1305
|
setTimeout(() => {
|
|
1213
1306
|
void restart().then(prune);
|
|
1214
1307
|
}, deps.delayMs ?? 2000).unref();
|
|
1215
1308
|
setInterval(() => void prune(), PRUNE_EVERY_MS).unref();
|
|
1309
|
+
setInterval(() => void rotate(), ROTATION_TICK_MS).unref();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* D28, `/api/agents/rotation`. GET: the settings (install, overrides,
|
|
1314
|
+
* defaults) and, with `?ws=&agent=`, that agent's effective values and
|
|
1315
|
+
* where each comes from. PUT `{ scope, target?, key, value }` sets one
|
|
1316
|
+
* (value null clears it): a person's only.
|
|
1317
|
+
*/
|
|
1318
|
+
async function rotationRoute(
|
|
1319
|
+
req: http.IncomingMessage,
|
|
1320
|
+
res: http.ServerResponse,
|
|
1321
|
+
d: AgentsResolved,
|
|
1322
|
+
): Promise<boolean> {
|
|
1323
|
+
const method = req.method ?? "GET";
|
|
1324
|
+
try {
|
|
1325
|
+
if (method === "GET" || method === "HEAD") {
|
|
1326
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
1327
|
+
const ws = q.get("ws") ?? undefined;
|
|
1328
|
+
const agent = q.get("agent") ?? undefined;
|
|
1329
|
+
if ((ws && !NAME_RE.test(ws)) || (agent && !NAME_RE.test(agent)))
|
|
1330
|
+
throw new AgentError("not a name", { usage: true });
|
|
1331
|
+
sendJson(res, rotationSettings(d, agent, ws));
|
|
1332
|
+
return true;
|
|
1333
|
+
}
|
|
1334
|
+
if (method !== "PUT") return methodNotAllowed(res, "GET, PUT");
|
|
1335
|
+
if (agentPrincipalOf(req) || auditActorOf(req))
|
|
1336
|
+
throw new AgentError("only a person can change rotation settings", {
|
|
1337
|
+
status: 403,
|
|
1338
|
+
});
|
|
1339
|
+
const body = await jsonBody(req);
|
|
1340
|
+
for (const k of Object.keys(body))
|
|
1341
|
+
if (!["scope", "target", "key", "value"].includes(k))
|
|
1342
|
+
throw new AgentError(
|
|
1343
|
+
`${k}: unknown field (takes scope, target, key, value)`,
|
|
1344
|
+
{ usage: true },
|
|
1345
|
+
);
|
|
1346
|
+
const out = setRotationSetting(d, body);
|
|
1347
|
+
recordAction(
|
|
1348
|
+
null,
|
|
1349
|
+
{
|
|
1350
|
+
verb: "agents",
|
|
1351
|
+
detail: `rotation ${String(body.scope)}${body.target ? ` ${String(body.target)}` : ""} ${String(body.key)} = ${body.value === null ? "(unset)" : JSON.stringify(body.value)}`,
|
|
1352
|
+
},
|
|
1353
|
+
principalOf(req),
|
|
1354
|
+
);
|
|
1355
|
+
sendJson(res, { ok: true, ...out });
|
|
1356
|
+
} catch (e) {
|
|
1357
|
+
sendError(res, e);
|
|
1358
|
+
}
|
|
1359
|
+
return true;
|
|
1216
1360
|
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -868,7 +868,12 @@ import {
|
|
|
868
868
|
import { heldActions } from "./held-actions.js";
|
|
869
869
|
import type { ApprovalQueue } from "./approvals.js";
|
|
870
870
|
import { processPathRules } from "../cli/onboard-reads.js";
|
|
871
|
-
import {
|
|
871
|
+
import {
|
|
872
|
+
handleAgentRoutes,
|
|
873
|
+
startAgentUpkeep,
|
|
874
|
+
webDeps as agentWebDeps,
|
|
875
|
+
} from "./agent-routes.js";
|
|
876
|
+
import { policyResolver } from "../cli/agent-ops.js";
|
|
872
877
|
import { handleServerAgentRoutes } from "./server-agent-routes.js";
|
|
873
878
|
import { freshDen } from "../cli/server-agent-ops.js";
|
|
874
879
|
import {
|
|
@@ -4748,7 +4753,10 @@ async function handleRequest(
|
|
|
4748
4753
|
res,
|
|
4749
4754
|
pathname,
|
|
4750
4755
|
guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
|
|
4751
|
-
{
|
|
4756
|
+
{
|
|
4757
|
+
waitingAgents: waitingAgentIds,
|
|
4758
|
+
rotationPolicy: () => policyResolver(agentWebDeps()),
|
|
4759
|
+
},
|
|
4752
4760
|
)
|
|
4753
4761
|
)
|
|
4754
4762
|
return;
|
|
@@ -5579,9 +5587,9 @@ server.on("error", (err: NodeJS.ErrnoException) => {
|
|
|
5579
5587
|
if (err.code === "EADDRINUSE") {
|
|
5580
5588
|
console.error(
|
|
5581
5589
|
`\n ✗ Port ${PORT} is already in use.\n` +
|
|
5582
|
-
`\n Another ${product().cli}
|
|
5583
|
-
`\n
|
|
5584
|
-
`\n
|
|
5590
|
+
`\n Another ${product().cli} server may be running, or its service holds the port.` +
|
|
5591
|
+
`\n ${product().cli} service status # is the service running it?` +
|
|
5592
|
+
`\n ${product().cli} web restart # restarts the service, or the plain server\n`,
|
|
5585
5593
|
);
|
|
5586
5594
|
process.exit(1);
|
|
5587
5595
|
}
|
|
@@ -368,10 +368,98 @@ function whereTag(r, button = false) {
|
|
|
368
368
|
|
|
369
369
|
/** Its rotation, when one is due: "ready" or "due" (den#4). */
|
|
370
370
|
function rotationOf(r) {
|
|
371
|
-
|
|
371
|
+
// D28: on_due off: nothing will happen, so nothing is shown.
|
|
372
|
+
if (!r.rotation?.due || r.rotation.onDue === "off") return "";
|
|
372
373
|
return r.rotation.ready ? "ready" : "due";
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
/** D28: Settings › Agents (its Rotation row). */
|
|
377
|
+
function settingsHref() {
|
|
378
|
+
const nav = window.denShell?.nav?.() ?? [];
|
|
379
|
+
const base = nav.find((n) => n.id === "settings")?.href ?? "/settings";
|
|
380
|
+
return `${base}?q=rotation#agents`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* D28: what happens to a due agent, in words, from its on_due (auto: Den
|
|
385
|
+
* asks for the handoff at idle and relaunches it; ask; auto that gave up
|
|
386
|
+
* asking). Older servers send no on_due: as ask.
|
|
387
|
+
*/
|
|
388
|
+
function rotMessage(r) {
|
|
389
|
+
const rot = r.rotation;
|
|
390
|
+
const why = rotationWhy(rot);
|
|
391
|
+
const auto = rot.onDue === "auto" && !rot.fellBack;
|
|
392
|
+
if (rot.relaunchWanted && !rot.ready) return t("ag.rc.wanted", { why });
|
|
393
|
+
if (auto && !isStopped(r))
|
|
394
|
+
return t(rot.ready ? "ag.rc.autoReady" : "ag.rc.autoAsk", { why });
|
|
395
|
+
if (rot.fellBack && !rot.ready)
|
|
396
|
+
return t(
|
|
397
|
+
rot.handoff === "invalid" ? "ag.rc.fellBackBad" : "ag.rc.fellBack",
|
|
398
|
+
{ why },
|
|
399
|
+
);
|
|
400
|
+
return t(
|
|
401
|
+
rot.ready
|
|
402
|
+
? "ag.rc.ready"
|
|
403
|
+
: rot.handoff === "invalid"
|
|
404
|
+
? "ag.rc.bad"
|
|
405
|
+
: "ag.rc.missing",
|
|
406
|
+
{ why },
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* D28: the rotation callout's buttons, wherever it shows (a row, the pane
|
|
412
|
+
* bar): Relaunch now (from the handoff, or asking for it first), Write
|
|
413
|
+
* handoff (a running agent without one), Keep this conversation with
|
|
414
|
+
* "don't ask again", and Settings. "" when nothing is due or on_due is off.
|
|
415
|
+
*/
|
|
416
|
+
function rotActionsHtml(r) {
|
|
417
|
+
const rot = r.rotation;
|
|
418
|
+
if (!rot?.due || rot.onDue === "off") return "";
|
|
419
|
+
const id = r.agent;
|
|
420
|
+
const now = `<button type="button" class="den-btn sm primary" data-act="relaunch" aria-label="${esc(t("ag.relaunchName", { id }))}">${esc(t("ag.relaunch"))}</button>`;
|
|
421
|
+
const write =
|
|
422
|
+
!isStopped(r) && !rot.ready && !rot.relaunchWanted
|
|
423
|
+
? `<button type="button" class="den-btn sm" data-act="rot-write" aria-label="${esc(t("ag.rc.writeName", { id }))}">${esc(t("ag.rc.write"))}</button>`
|
|
424
|
+
: "";
|
|
425
|
+
const keep = `<button type="button" class="den-btn sm" data-act="rot-keep" aria-label="${esc(t("ag.rc.keepName", { id }))}">${esc(t("ag.rc.keep"))}</button>`;
|
|
426
|
+
const never = `<label class="agnever"><input type="checkbox" data-rot-never> ${esc(t("ag.rc.never"))}</label>`;
|
|
427
|
+
const settings = `<a class="lnk" data-rot-settings href="${esc(settingsHref())}">${esc(t("ag.rc.settings"))}</a>`;
|
|
428
|
+
return now + write + keep + never + settings;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** D28: the effective settings and where each comes from, in words. */
|
|
432
|
+
function rotFromText(rot) {
|
|
433
|
+
if (!rot?.from) return "";
|
|
434
|
+
return t(
|
|
435
|
+
rot.after === 0
|
|
436
|
+
? "ag.rc.fromNever"
|
|
437
|
+
: rot.after === 1
|
|
438
|
+
? "ag.rc.from.one"
|
|
439
|
+
: "ag.rc.from",
|
|
440
|
+
{
|
|
441
|
+
n: rot.after,
|
|
442
|
+
src: rot.from.after_compactions,
|
|
443
|
+
mode: t(`ag.rc.mode.${rot.onDue}`),
|
|
444
|
+
src2: rot.from.on_due,
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** D28: the callout under a row: the message, its line of settings, the buttons. */
|
|
450
|
+
function rotCalloutHtml(r) {
|
|
451
|
+
const acts = rotActionsHtml(r);
|
|
452
|
+
if (!acts) return "";
|
|
453
|
+
const from = rotFromText(r.rotation);
|
|
454
|
+
return (
|
|
455
|
+
`<span class="agcant" data-rot-msg data-handoff="${esc(r.rotation.handoff)}">${esc(rotMessage(r))}</span>` +
|
|
456
|
+
(from
|
|
457
|
+
? `<span class="agcant agfrom" data-rot-from>${esc(from)}</span>`
|
|
458
|
+
: "") +
|
|
459
|
+
acts
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
375
463
|
/** A stopped agent's row: its button (it opens an ended pane) and actions. */
|
|
376
464
|
function stoppedHtml(r) {
|
|
377
465
|
const open = st.open.includes(r.id);
|
|
@@ -380,10 +468,8 @@ function stoppedHtml(r) {
|
|
|
380
468
|
const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
|
|
381
469
|
const who = esc(r.agent);
|
|
382
470
|
const rot = rotationOf(r);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
? `<button type="button" class="den-btn sm primary" data-act="relaunch" aria-label="${esc(t("ag.relaunchName", { id: r.agent }))}">${esc(t("ag.relaunch"))}</button>`
|
|
386
|
-
: "";
|
|
471
|
+
// D28: a due agent's callout (unless on_due is off).
|
|
472
|
+
const callout = rotCalloutHtml(r);
|
|
387
473
|
const resume = r.resumable
|
|
388
474
|
? `<button type="button" class="den-btn sm primary" data-act="resume" aria-label="${esc(t("ag.resumeName", { id: r.agent }))}">${esc(t("ag.resume"))}</button>`
|
|
389
475
|
: "";
|
|
@@ -391,18 +477,6 @@ function stoppedHtml(r) {
|
|
|
391
477
|
const remove = `<button type="button" class="den-btn sm" data-act="remove" aria-label="${esc(t("ag.removeName", { id: r.agent }))}">${esc(t("ag.remove"))}</button>`;
|
|
392
478
|
// D25: an agent with no pane open can be renamed too.
|
|
393
479
|
const rename = `<button type="button" class="den-btn sm" data-act="rename" aria-label="${esc(t("ag.renameName", { id: r.agent }))}">${esc(t("ag.rn.act"))}</button>`;
|
|
394
|
-
// A rotation due without a handoff that passes: say so (den#4).
|
|
395
|
-
const handoff =
|
|
396
|
-
rot === "due"
|
|
397
|
-
? `<span class="agcant" data-handoff="${esc(r.rotation.handoff)}">${esc(
|
|
398
|
-
t(
|
|
399
|
-
r.rotation.handoff === "missing"
|
|
400
|
-
? "ag.rot.noHandoff"
|
|
401
|
-
: "ag.rot.badHandoff",
|
|
402
|
-
{ why: rotationWhy(r.rotation) },
|
|
403
|
-
),
|
|
404
|
-
)}</span>`
|
|
405
|
-
: "";
|
|
406
480
|
const cant =
|
|
407
481
|
!r.resumable && r.noResume && rot !== "ready"
|
|
408
482
|
? `<span class="agcant" data-no-resume>${esc(r.noResume.startsWith(`${r.runtime} `) ? t("ag.noResume", { runtime: r.runtime }) : r.noResume)}</span>`
|
|
@@ -421,7 +495,7 @@ function stoppedHtml(r) {
|
|
|
421
495
|
(meta ? `<span class="meta">${esc(meta)}</span>` : "") +
|
|
422
496
|
`<span class="why">${esc(stopReason(r))}</span>` +
|
|
423
497
|
`</button>` +
|
|
424
|
-
`<div class="agacts">${
|
|
498
|
+
`<div class="agacts">${callout}${cant}${resume}${fresh}${rename}${remove}</div>` +
|
|
425
499
|
`</div>`
|
|
426
500
|
);
|
|
427
501
|
}
|
|
@@ -454,7 +528,7 @@ function rowHtml(r) {
|
|
|
454
528
|
rotTag || revokedTag || ovTags || wTag
|
|
455
529
|
? `<span class="agtags">${revokedTag}${rotTag}${wTag}${ovTags}</span>`
|
|
456
530
|
: "";
|
|
457
|
-
|
|
531
|
+
const button =
|
|
458
532
|
`<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
|
|
459
533
|
` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
|
|
460
534
|
` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${displayName(r)}` }))}">` +
|
|
@@ -462,7 +536,16 @@ function rowHtml(r) {
|
|
|
462
536
|
`<span class="nm">${nm}</span>${right}` +
|
|
463
537
|
(meta ? `<span class="meta">${esc(meta)}</span>` : "") +
|
|
464
538
|
tags +
|
|
465
|
-
`</button
|
|
539
|
+
`</button>`;
|
|
540
|
+
// D28: a running agent whose rotation is due: its callout, with buttons
|
|
541
|
+
// (before, only a tag: nothing to press).
|
|
542
|
+
const callout = r.kind === "agent" ? rotCalloutHtml(r) : "";
|
|
543
|
+
if (!callout) return button;
|
|
544
|
+
return (
|
|
545
|
+
`<div class="agst" data-agent="${esc(r.agent)}" data-state="${esc(r.state)}" data-ws="${esc(r.workspace)}" data-rotation="${rot}">` +
|
|
546
|
+
button +
|
|
547
|
+
`<div class="agacts">${callout}</div>` +
|
|
548
|
+
`</div>`
|
|
466
549
|
);
|
|
467
550
|
}
|
|
468
551
|
|
|
@@ -608,6 +691,7 @@ async function refresh() {
|
|
|
608
691
|
} else if (r.ok) {
|
|
609
692
|
st.data = await r.json();
|
|
610
693
|
st.signedOut = false;
|
|
694
|
+
toastAutoRelaunches(st.data?.sessions ?? []);
|
|
611
695
|
}
|
|
612
696
|
} catch {
|
|
613
697
|
/* the last list stays; the next ask may answer */
|
|
@@ -1011,6 +1095,9 @@ function paintBar(p) {
|
|
|
1011
1095
|
? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
|
|
1012
1096
|
: "") +
|
|
1013
1097
|
whereTag(r, true) +
|
|
1098
|
+
(r?.kind === "agent" && rotActionsHtml(r)
|
|
1099
|
+
? `<span class="agrot" data-rot-bar title="${esc(rotMessage(r))}"><span class="tagx warn" data-rotation="${rotationOf(r)}">${esc(t(rotationOf(r) === "ready" ? "ag.rot.ready" : "ag.rot.due", { why: rotationWhy(r.rotation) }))}</span>${rotActionsHtml(r)}</span>`
|
|
1100
|
+
: "") +
|
|
1014
1101
|
`<span class="x">${reconnect}${stop}${resume}${more}<button type="button" class="den-btn sm" data-act="close" aria-label="${esc(t("ag.detachName", { id: name }))}" title="${esc(t("ag.detach"))}">×</button></span>`;
|
|
1015
1102
|
// Tabs: the tab's × closes it; the pane's bar has none.
|
|
1016
1103
|
const x = bar.querySelector('[data-act="close"]');
|
|
@@ -1518,6 +1605,93 @@ async function restartRow(r, verb, button) {
|
|
|
1518
1605
|
}
|
|
1519
1606
|
}
|
|
1520
1607
|
|
|
1608
|
+
/**
|
|
1609
|
+
* D28 Relaunch now: from the handoff when it passes; else Den asks for it
|
|
1610
|
+
* first (a stopped agent resumes to write it) and relaunches it once it
|
|
1611
|
+
* passes, by itself.
|
|
1612
|
+
*/
|
|
1613
|
+
async function relaunchNow(r, button) {
|
|
1614
|
+
if (r.rotation?.ready) return restartRow(r, "relaunch", button);
|
|
1615
|
+
if (button) button.disabled = true;
|
|
1616
|
+
try {
|
|
1617
|
+
const out = await api("POST", agentPath(r, "handoff"), { relaunch: true });
|
|
1618
|
+
window.denShell?.toast?.(
|
|
1619
|
+
t(out?.resumed ? "ag.rc.askedResumed" : "ag.rc.askedRelaunch", {
|
|
1620
|
+
id: r.agent,
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
} catch (err) {
|
|
1624
|
+
window.denShell?.errorToast?.(err);
|
|
1625
|
+
}
|
|
1626
|
+
await refresh();
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** D28 Write handoff: the prompt, typed into its session at idle. */
|
|
1630
|
+
async function writeHandoff(r, button) {
|
|
1631
|
+
if (button) button.disabled = true;
|
|
1632
|
+
try {
|
|
1633
|
+
await api("POST", agentPath(r, "handoff"), {});
|
|
1634
|
+
window.denShell?.toast?.(t("ag.rc.asked", { id: r.agent }));
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
// Mid-turn is expected, not an error: say when to try again.
|
|
1637
|
+
if (/mid-turn/.test(String(err?.message ?? "")))
|
|
1638
|
+
window.denShell?.toast?.(t("ag.rc.midTurn", { id: r.agent }));
|
|
1639
|
+
else window.denShell?.errorToast?.(err);
|
|
1640
|
+
}
|
|
1641
|
+
await refresh();
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** D28 Keep this conversation (and "don't ask again": never, for it). */
|
|
1645
|
+
async function keepConversation(r, button) {
|
|
1646
|
+
const never = !!button
|
|
1647
|
+
?.closest(".agacts, .agrot")
|
|
1648
|
+
?.querySelector("[data-rot-never]")?.checked;
|
|
1649
|
+
if (button) button.disabled = true;
|
|
1650
|
+
try {
|
|
1651
|
+
await api("POST", agentPath(r, "keep"), never ? { never: true } : {});
|
|
1652
|
+
window.denShell?.toast?.(
|
|
1653
|
+
t(never ? "ag.rc.keptNever" : "ag.rc.kept", { id: r.agent }),
|
|
1654
|
+
);
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
window.denShell?.errorToast?.(err);
|
|
1657
|
+
}
|
|
1658
|
+
await refresh();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* D28: a relaunch Den did by itself since the last look: a toast saying
|
|
1663
|
+
* so, and that the old conversation is kept. The first look only notes
|
|
1664
|
+
* what is there.
|
|
1665
|
+
*/
|
|
1666
|
+
function toastAutoRelaunches(rows) {
|
|
1667
|
+
const seen = (st.rotSeen ??= new Map());
|
|
1668
|
+
const first = !st.rotLooked;
|
|
1669
|
+
st.rotLooked = true;
|
|
1670
|
+
for (const r of rows) {
|
|
1671
|
+
const last = r.kind === "agent" ? r.lastRotation : undefined;
|
|
1672
|
+
if (!last) continue;
|
|
1673
|
+
const key = `${r.workspace}/${r.agent}`;
|
|
1674
|
+
if (seen.get(key) === last.at) continue;
|
|
1675
|
+
seen.set(key, last.at);
|
|
1676
|
+
if (first || !last.auto) continue;
|
|
1677
|
+
window.denShell?.toast?.(
|
|
1678
|
+
t("ag.rc.autoRelaunched", {
|
|
1679
|
+
id: r.agent,
|
|
1680
|
+
why:
|
|
1681
|
+
last.reason === "pr-merged"
|
|
1682
|
+
? t("ag.rot.merged")
|
|
1683
|
+
: t(
|
|
1684
|
+
last.compactions === 1
|
|
1685
|
+
? "ag.rot.compactions.one"
|
|
1686
|
+
: "ag.rot.compactions",
|
|
1687
|
+
{ n: last.compactions },
|
|
1688
|
+
),
|
|
1689
|
+
old: last.conversation ?? "?",
|
|
1690
|
+
}),
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1521
1695
|
/** Undo: the removed agents come back (POST /api/agents/restore). */
|
|
1522
1696
|
async function undoRemove(ids) {
|
|
1523
1697
|
try {
|
|
@@ -2786,7 +2960,10 @@ export async function mountAgentsPage(root) {
|
|
|
2786
2960
|
(x) => x.agent === agent && x.workspace === ws,
|
|
2787
2961
|
);
|
|
2788
2962
|
if (!r) return;
|
|
2789
|
-
if (which === "
|
|
2963
|
+
if (which === "relaunch") void relaunchNow(r, act);
|
|
2964
|
+
else if (which === "rot-write") void writeHandoff(r, act);
|
|
2965
|
+
else if (which === "rot-keep") void keepConversation(r, act);
|
|
2966
|
+
else if (which === "resume" || which === "fresh")
|
|
2790
2967
|
void restartRow(r, which, act);
|
|
2791
2968
|
else if (which === "remove") void removeRow(r, act);
|
|
2792
2969
|
else if (which === "rename") openRename(r, act);
|
|
@@ -2826,7 +3003,14 @@ export async function mountAgentsPage(root) {
|
|
|
2826
3003
|
return;
|
|
2827
3004
|
}
|
|
2828
3005
|
if (b.dataset.act === "close") closePane(id);
|
|
2829
|
-
else if (b.dataset.act === "
|
|
3006
|
+
else if (b.dataset.act === "relaunch" || b.dataset.act.startsWith("rot-")) {
|
|
3007
|
+
// D28: the callout's buttons in the pane bar.
|
|
3008
|
+
const r = rowOf(id);
|
|
3009
|
+
if (!r) return;
|
|
3010
|
+
if (b.dataset.act === "relaunch") void relaunchNow(r, b);
|
|
3011
|
+
else if (b.dataset.act === "rot-write") void writeHandoff(r, b);
|
|
3012
|
+
else if (b.dataset.act === "rot-keep") void keepConversation(r, b);
|
|
3013
|
+
} else if (b.dataset.act === "stop") void stopAgent(p, b);
|
|
2830
3014
|
else if (b.dataset.act === "resume") {
|
|
2831
3015
|
const r = rowOf(id);
|
|
2832
3016
|
if (r) void restartRow(r, "resume", b);
|
|
@@ -2840,6 +3024,14 @@ export async function mountAgentsPage(root) {
|
|
|
2840
3024
|
// D25: a visible Rename, for touch and keyboard (not only a
|
|
2841
3025
|
// double-click on a tab).
|
|
2842
3026
|
{ label: t("ag.rn.item"), onSelect: () => openRename(r, b) },
|
|
3027
|
+
// D28: its rotation settings and where each comes from.
|
|
3028
|
+
{
|
|
3029
|
+
label: t("ag.rc.menu"),
|
|
3030
|
+
onSelect: () =>
|
|
3031
|
+
window.denShell?.toast?.(
|
|
3032
|
+
rotFromText(r.rotation) || t("ag.rc.noState"),
|
|
3033
|
+
),
|
|
3034
|
+
},
|
|
2843
3035
|
...(r.personaChange
|
|
2844
3036
|
? [
|
|
2845
3037
|
{
|
|
@@ -233,6 +233,38 @@
|
|
|
233
233
|
font: 12px/1.4 var(--sans);
|
|
234
234
|
color: var(--faint);
|
|
235
235
|
}
|
|
236
|
+
/* D28: the rotation callout: its message, its settings, its buttons. */
|
|
237
|
+
.agst .agcant[data-rot-msg] {
|
|
238
|
+
color: var(--warn-text, var(--text));
|
|
239
|
+
}
|
|
240
|
+
.agst .agfrom {
|
|
241
|
+
font-size: 11.5px;
|
|
242
|
+
}
|
|
243
|
+
.agnever {
|
|
244
|
+
display: inline-flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
gap: 4px;
|
|
247
|
+
font: 12px/1.4 var(--sans);
|
|
248
|
+
color: var(--muted);
|
|
249
|
+
}
|
|
250
|
+
/* The callout takes a line of its own under the bar's name and
|
|
251
|
+
buttons, so it never runs under Stop (375 px). */
|
|
252
|
+
.pane .bar2:has(.agrot) {
|
|
253
|
+
flex-wrap: wrap;
|
|
254
|
+
row-gap: 4px;
|
|
255
|
+
}
|
|
256
|
+
.bar2 .agrot {
|
|
257
|
+
display: flex;
|
|
258
|
+
flex-wrap: wrap;
|
|
259
|
+
align-items: center;
|
|
260
|
+
gap: 4px;
|
|
261
|
+
min-width: 0;
|
|
262
|
+
order: 10;
|
|
263
|
+
flex: 1 1 100%;
|
|
264
|
+
}
|
|
265
|
+
.bar2 .agrot .agnever {
|
|
266
|
+
display: none;
|
|
267
|
+
}
|
|
236
268
|
.agst[data-review] > .agr {
|
|
237
269
|
border-left-color: var(--warn);
|
|
238
270
|
}
|