@monster-orange/den 0.4.7 → 0.4.9

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
@@ -31,6 +31,24 @@
31
31
  * can't, before Start (agent-start.js readiness). Starting, stopping and
32
32
  * resuming an agent never ask for the admin password: it is for sudo, and
33
33
  * no agent action runs sudo (LK-0).
34
+ *
35
+ * Stopped agents (AG-14) stay listed under their workspace, below the
36
+ * running ones, each with why it stopped ("Stopped by you 19:52",
37
+ * "Interrupted: Den restarted", "Exited (code 1)") and its actions:
38
+ * Resume (the same conversation; its pane opens), Start fresh, Remove (one
39
+ * click, and Undo in the toast for 10 s). A runtime that can't resume
40
+ * shows only Start fresh, saying so. Clear stopped removes every stopped
41
+ * agent in view. After a restart, one line says how many were interrupted,
42
+ * with Resume all and Review.
43
+ *
44
+ * Session rotation (den#4): an agent whose session rotated (after
45
+ * agents.rotate.after_compactions compactions, or its pull request merged)
46
+ * and whose handoff.md passes the check reads "Ready to relaunch (3
47
+ * compactions)" or "(PR merged)", with Relaunch from handoff as its primary
48
+ * action, then Start fresh and Remove; Resume is not offered. With the
49
+ * handoff missing or failing the check, the row says so and keeps Resume
50
+ * and Start fresh. A running one says "Handoff due" or "Ready to relaunch"
51
+ * as a tag.
34
52
  */
35
53
  import { fillStartForm } from "/agent-start.js";
36
54
 
@@ -45,6 +63,9 @@ const esc = (s) =>
45
63
  );
46
64
  const api = (method, url, body) => window.denShell.api(method, url, body);
47
65
  const CLI = "__DEN_CLI__";
66
+ const TITLE = "__DEN_TITLE__";
67
+ /** How long Remove's Undo stays offered (AG-14.1b). */
68
+ export const UNDO_MS = 10_000;
48
69
 
49
70
  /** The most panes at once (the prototype's 2x2). */
50
71
  export const MAX_PANES = 4;
@@ -88,11 +109,17 @@ function writeUrl() {
88
109
 
89
110
  // ── the list ────────────────────────────────────────────────────────────
90
111
 
91
- /** The workspaces to show: ?ws= (when active), else the scope bar's view. */
112
+ /** ?ws=: the one workspace the page was asked for, or null. */
113
+ const urlWorkspace = () => new URLSearchParams(location.search).get("ws");
114
+
115
+ /**
116
+ * The workspaces to show: ?ws= (always that one, even when it isn't active
117
+ * or isn't known: never All in its place), else the scope bar's view.
118
+ */
92
119
  function viewWorkspaces() {
93
120
  const all = st.data?.workspaces ?? [];
94
- const ws = new URLSearchParams(location.search).get("ws");
95
- if (ws && all.includes(ws)) return [ws];
121
+ const ws = urlWorkspace();
122
+ if (ws) return [ws];
96
123
  if (st.scope && st.scope !== "all" && all.includes(st.scope))
97
124
  return [st.scope];
98
125
  return all;
@@ -112,7 +139,9 @@ function stateText(state) {
112
139
  return m[1] === undefined
113
140
  ? t("ag.state.exited")
114
141
  : t("ag.state.exitedCode", { code: m[1] });
115
- return ["running", "stopping", "stopped", "ended"].includes(state)
142
+ return ["running", "stopping", "stopped", "ended", "interrupted"].includes(
143
+ state,
144
+ )
116
145
  ? t(`ag.state.${state}`)
117
146
  : state;
118
147
  }
@@ -127,7 +156,111 @@ function paneName(r) {
127
156
  return r.kind === "agent" ? r.displayName || r.agent || r.label : r.label;
128
157
  }
129
158
 
159
+ /** An agent that isn't running: stopped, interrupted or exited (AG-14). */
160
+ export function isStopped(r) {
161
+ return r.kind === "agent" && r.state !== "running" && r.state !== "stopping";
162
+ }
163
+
164
+ /** "19:52" today, else "Nov 6, 19:52" (the browser's own format). */
165
+ function when(iso) {
166
+ const d = new Date(iso);
167
+ if (Number.isNaN(d.getTime())) return "";
168
+ const time = d.toLocaleTimeString([], {
169
+ hour: "2-digit",
170
+ minute: "2-digit",
171
+ hour12: false,
172
+ });
173
+ const today = new Date().toDateString() === d.toDateString();
174
+ return today
175
+ ? time
176
+ : `${d.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
177
+ }
178
+
179
+ /** Why a stopped agent stopped (AG-14.1). */
180
+ export function stopReason(r) {
181
+ if (r.state === "interrupted")
182
+ return t("ag.why.interrupted", { name: TITLE });
183
+ if (r.state === "stopped")
184
+ return t("ag.why.stopped", { time: when(r.stoppedAt ?? "") }).trim();
185
+ const m = /^exited(?: \((-?\d+)\))?$/.exec(r.state);
186
+ if (m)
187
+ return m[1] === undefined
188
+ ? t("ag.why.exited")
189
+ : t("ag.why.exitedCode", { code: m[1] });
190
+ return stateText(r.state);
191
+ }
192
+
193
+ /** Why it rotates, in words: "3 compactions", "PR merged" (den#4). */
194
+ function rotationWhy(rot) {
195
+ if (rot.due === "pr-merged") return t("ag.rot.merged");
196
+ return t(
197
+ rot.compactions === 1 ? "ag.rot.compactions.one" : "ag.rot.compactions",
198
+ {
199
+ n: rot.compactions,
200
+ },
201
+ );
202
+ }
203
+
204
+ /** Its rotation, when one is due: "ready" or "due" (den#4). */
205
+ function rotationOf(r) {
206
+ if (!r.rotation?.due) return "";
207
+ return r.rotation.ready ? "ready" : "due";
208
+ }
209
+
210
+ /** A stopped agent's row: its button (it opens an ended pane) and actions. */
211
+ function stoppedHtml(r) {
212
+ const open = st.open.includes(r.id);
213
+ const name = displayName(r);
214
+ const nm = `${esc(name)}${r.persona && r.persona !== r.label ? ` <span class="id">${esc(r.label)}</span>` : ""}`;
215
+ const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
216
+ const who = esc(r.agent);
217
+ const rot = rotationOf(r);
218
+ const relaunch =
219
+ rot === "ready"
220
+ ? `<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>`
221
+ : "";
222
+ const resume = r.resumable
223
+ ? `<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>`
224
+ : "";
225
+ const fresh = `<button type="button" class="den-btn sm" data-act="fresh" aria-label="${esc(t("ag.freshName", { id: r.agent }))}">${esc(t("ag.fresh"))}</button>`;
226
+ 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>`;
227
+ // A rotation due without a handoff that passes: say so (den#4).
228
+ const handoff =
229
+ rot === "due"
230
+ ? `<span class="agcant" data-handoff="${esc(r.rotation.handoff)}">${esc(
231
+ t(
232
+ r.rotation.handoff === "missing"
233
+ ? "ag.rot.noHandoff"
234
+ : "ag.rot.badHandoff",
235
+ { why: rotationWhy(r.rotation) },
236
+ ),
237
+ )}</span>`
238
+ : "";
239
+ const cant =
240
+ !r.resumable && r.noResume && rot !== "ready"
241
+ ? `<span class="agcant" data-no-resume>${esc(r.noResume.startsWith(`${r.runtime} `) ? t("ag.noResume", { runtime: r.runtime }) : r.noResume)}</span>`
242
+ : "";
243
+ const stText =
244
+ rot === "ready"
245
+ ? t("ag.rot.ready", { why: rotationWhy(r.rotation) })
246
+ : stateText(r.state);
247
+ return (
248
+ `<div class="agst" data-agent="${who}" data-state="${esc(r.state)}" data-ws="${esc(r.workspace)}"${rot ? ` data-rotation="${rot}"` : ""}>` +
249
+ `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="agent" data-state="${esc(r.state)}"` +
250
+ ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
251
+ ` aria-label="${esc(t("ag.openName", { id: `${r.workspace} / ${name}` }))}">` +
252
+ `<span class="den-dot ${stateTone(r)}" aria-hidden="true"></span>` +
253
+ `<span class="nm">${nm}</span><span class="st">${esc(stText)}</span>` +
254
+ (meta ? `<span class="meta">${esc(meta)}</span>` : "") +
255
+ `<span class="why">${esc(stopReason(r))}</span>` +
256
+ `</button>` +
257
+ `<div class="agacts">${handoff}${cant}${relaunch}${resume}${fresh}${remove}</div>` +
258
+ `</div>`
259
+ );
260
+ }
261
+
130
262
  function rowHtml(r) {
263
+ if (isStopped(r)) return stoppedHtml(r);
131
264
  const open = st.open.includes(r.id);
132
265
  const nm =
133
266
  r.kind === "agent"
@@ -137,9 +270,21 @@ function rowHtml(r) {
137
270
  ? `<span class="wbdg">${esc(t("ag.waiting"))}</span>`
138
271
  : `<span class="st">${esc(stateText(r.state))}</span>`;
139
272
  const meta = [r.runtime, r.cwd].filter(Boolean).join(" · ");
140
- const tags = r.tokenRevoked
141
- ? `<span class="agtags"><span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span></span>`
273
+ const rot = rotationOf(r);
274
+ const rotTag = rot
275
+ ? `<span class="tagx warn" data-rotation="${rot}">${esc(
276
+ t(rot === "ready" ? "ag.rot.ready" : "ag.rot.due", {
277
+ why: rotationWhy(r.rotation),
278
+ }),
279
+ )}</span>`
142
280
  : "";
281
+ const revokedTag = r.tokenRevoked
282
+ ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
283
+ : "";
284
+ const tags =
285
+ rotTag || revokedTag
286
+ ? `<span class="agtags">${revokedTag}${rotTag}</span>`
287
+ : "";
143
288
  return (
144
289
  `<button type="button" class="agr" data-id="${esc(r.id)}" data-kind="${esc(r.kind)}" data-state="${esc(r.state)}"` +
145
290
  ` aria-pressed="${open}"${r.attachable ? "" : ' aria-disabled="true"'}` +
@@ -169,10 +314,40 @@ function paintList() {
169
314
  return;
170
315
  }
171
316
  const shown = viewWorkspaces();
317
+ // ?ws= names a workspace that isn't active: say so, and offer the switch.
318
+ const asked = st.data.requested;
319
+ if (asked && !asked.active)
320
+ notes.push(
321
+ `<div class="agnote" data-note="inactive" role="status">` +
322
+ esc(t("ag.inactive", { ws: asked.workspace })) +
323
+ (asked.known
324
+ ? ` · <button type="button" class="lnk" data-act="switch-ws">${esc(t("ag.switchWs", { ws: asked.workspace }))}</button>`
325
+ : ` ${esc(t("ag.unknownWs"))}`) +
326
+ `</div>`,
327
+ );
328
+ // AG-14.3: one line after a restart, for the agents it interrupted.
329
+ const hit = visibleRows().filter((r) => r.state === "interrupted");
330
+ if (hit.length)
331
+ notes.push(
332
+ `<div class="agnote" data-note="interrupted" role="status">` +
333
+ `${esc(t(hit.length === 1 ? "ag.interrupted.one" : "ag.interrupted", { n: hit.length, name: TITLE }))}` +
334
+ (hit.some((r) => r.resumable)
335
+ ? ` · <button type="button" class="lnk" data-act="resume-all">${esc(t("ag.resumeAll"))}</button>`
336
+ : "") +
337
+ ` · <button type="button" class="lnk" data-act="review">${esc(t("ag.review"))}</button></div>`,
338
+ );
172
339
  const groups = shown
173
340
  .map((ws) => ({
174
341
  ws,
175
- rows: st.data.sessions.filter((s) => s.workspace === ws),
342
+ // Running ones (and plain sessions) first, then the stopped agents.
343
+ rows: st.data.sessions
344
+ .filter((s) => s.workspace === ws)
345
+ .map((s, i) => ({ s, i }))
346
+ .sort(
347
+ (a, b) =>
348
+ Number(isStopped(a.s)) - Number(isStopped(b.s)) || a.i - b.i,
349
+ )
350
+ .map((x) => x.s),
176
351
  }))
177
352
  .filter((g) => g.rows.length > 0);
178
353
  const body = groups.length
@@ -186,6 +361,11 @@ function paintList() {
186
361
  .join("")
187
362
  : `<div class="none">${esc(t("ag.none.scope", { scope: st.scopeLabel }))}</div>`;
188
363
  el.innerHTML = notes.join("") + body;
364
+ const clear = st.root.querySelector("#ag-clear");
365
+ if (clear) clear.hidden = !visibleRows().some(isStopped);
366
+ // No such workspace: nothing to start an agent in.
367
+ const start = st.root.querySelector("#ag-start");
368
+ if (start) start.hidden = st.data.requested?.known === false;
189
369
  }
190
370
 
191
371
  function visibleRows() {
@@ -219,7 +399,7 @@ async function loadScope() {
219
399
  }
220
400
 
221
401
  function scopeLabelFor(shown) {
222
- const ws = new URLSearchParams(location.search).get("ws");
402
+ const ws = urlWorkspace();
223
403
  if (ws && shown.length === 1 && shown[0] === ws) return ws;
224
404
  return st.scope === "all" ? t("ag.all") : st.scope;
225
405
  }
@@ -227,7 +407,11 @@ function scopeLabelFor(shown) {
227
407
  async function refresh() {
228
408
  clearTimeout(st.timer);
229
409
  try {
230
- const r = await fetch("/api/sessions", { headers: { accept: "json" } });
410
+ const ws = urlWorkspace();
411
+ const r = await fetch(
412
+ ws ? `/api/sessions?ws=${encodeURIComponent(ws)}` : "/api/sessions",
413
+ { headers: { accept: "json" } },
414
+ );
231
415
  if (r.status === 401) {
232
416
  st.signedOut = true;
233
417
  } else if (r.ok) {
@@ -325,6 +509,11 @@ function paintBar(p) {
325
509
  r?.kind === "agent" && (r.state === "running" || r.state === "stopping")
326
510
  ? `<button type="button" class="den-btn sm" data-act="stop" aria-label="${esc(t("ag.stopName", { id: r.agent }))}">${esc(t("ag.stop"))}</button>`
327
511
  : "";
512
+ // The mock's pane bar: Resume (primary) on an ended agent that can.
513
+ const resume =
514
+ r && isStopped(r) && r.resumable
515
+ ? `<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>`
516
+ : "";
328
517
  const reconnect = p.lost
329
518
  ? `<button type="button" class="den-btn sm" data-act="reconnect">${esc(t("ag.reconnect"))}</button>`
330
519
  : "";
@@ -334,7 +523,7 @@ function paintBar(p) {
334
523
  (r?.tokenRevoked
335
524
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
336
525
  : "") +
337
- `<span class="x">${reconnect}${stop}<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>`;
526
+ `<span class="x">${reconnect}${stop}${resume}<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>`;
338
527
  }
339
528
 
340
529
  function send(p, obj) {
@@ -610,17 +799,191 @@ async function stopAgent(p, button) {
610
799
  if (!r?.agent) return;
611
800
  button.disabled = true;
612
801
  try {
613
- await api(
802
+ const out = await api(
614
803
  "POST",
615
804
  `/api/workspaces/${encodeURIComponent(r.workspace)}/agents/${encodeURIComponent(r.agent)}/stop`,
616
805
  );
617
- window.denShell?.toast?.(t("ag.stopped", { id: r.agent }));
806
+ window.denShell?.toast?.(
807
+ t(out?.stillStopping ? "ag.stillStopping" : "ag.stopped", {
808
+ id: r.agent,
809
+ }),
810
+ );
811
+ } catch (err) {
812
+ window.denShell?.errorToast?.(err);
813
+ }
814
+ await refresh();
815
+ }
816
+
817
+ // ── Stopped agents (AG-14) ──────────────────────────────────────────────
818
+
819
+ const agentPath = (r, verb) =>
820
+ `/api/workspaces/${encodeURIComponent(r.workspace)}/agents/${encodeURIComponent(r.agent)}/${verb}`;
821
+
822
+ /**
823
+ * Resume (the same conversation), Start fresh, or Relaunch from handoff
824
+ * (den#4: a new conversation that starts from handoff.md); its pane opens
825
+ * on it.
826
+ */
827
+ async function restartRow(r, verb, button) {
828
+ if (button) button.disabled = true;
829
+ try {
830
+ const out = await api("POST", agentPath(r, verb));
831
+ window.denShell?.toast?.(
832
+ t(
833
+ verb === "relaunch"
834
+ ? "ag.relaunched"
835
+ : verb !== "resume"
836
+ ? "ag.startedFresh"
837
+ : out?.fresh
838
+ ? "ag.resumedFresh"
839
+ : "ag.resumed",
840
+ { id: r.agent },
841
+ ),
842
+ );
843
+ if (st.panes.has(r.id)) closePane(r.id, { quiet: true });
844
+ await refresh();
845
+ if (out?.agent?.sessionId) await openPane(out.agent.sessionId);
846
+ } catch (err) {
847
+ window.denShell?.errorToast?.(err);
848
+ await refresh();
849
+ }
850
+ }
851
+
852
+ /** Undo: the removed agents come back (POST /api/agents/restore). */
853
+ async function undoRemove(ids) {
854
+ try {
855
+ const out = await api("POST", "/api/agents/restore", { agents: ids });
856
+ const back = out?.restored ?? [];
857
+ if (back.length)
858
+ window.denShell?.toast?.(t("ag.restored", { ids: back.join(", ") }));
859
+ } catch (err) {
860
+ window.denShell?.errorToast?.(err);
861
+ }
862
+ await refresh();
863
+ }
864
+
865
+ /** The removal's toast, with Undo for UNDO_MS. */
866
+ function removedToast(msg, ids) {
867
+ const shell = window.denShell;
868
+ if (shell?.toastAction)
869
+ shell.toastAction(msg, t("ag.undo"), () => void undoRemove(ids), UNDO_MS);
870
+ else shell?.toast?.(msg);
871
+ }
872
+
873
+ /** Remove: one click, no confirm (AG-14.1b). */
874
+ async function removeRow(r, button) {
875
+ if (button) button.disabled = true;
876
+ try {
877
+ const out = await api("POST", agentPath(r, "remove"));
878
+ if (st.panes.has(r.id)) closePane(r.id, { quiet: true });
879
+ const ids = (out?.removed ?? []).map((x) => x.agent);
880
+ await refresh();
881
+ removedToast(
882
+ t("ag.removed", { id: r.agent }),
883
+ ids.length ? ids : [r.agent],
884
+ );
885
+ } catch (err) {
886
+ window.denShell?.errorToast?.(err);
887
+ await refresh();
888
+ }
889
+ }
890
+
891
+ /** Clear stopped: every stopped agent in view (AG-14.1b). */
892
+ async function clearStopped(button) {
893
+ const workspaces = [
894
+ ...new Set(
895
+ visibleRows()
896
+ .filter(isStopped)
897
+ .map((r) => r.workspace),
898
+ ),
899
+ ];
900
+ if (!workspaces.length) return;
901
+ button.disabled = true;
902
+ try {
903
+ const out = await api("POST", "/api/agents/clear-stopped", { workspaces });
904
+ const ids = (out?.removed ?? []).map((x) => x.agent);
905
+ for (const r of visibleRows())
906
+ if (ids.includes(r.agent) && st.panes.has(r.id))
907
+ closePane(r.id, { quiet: true });
908
+ await refresh();
909
+ if (ids.length)
910
+ removedToast(
911
+ t(ids.length === 1 ? "ag.removedMany.one" : "ag.removedMany", {
912
+ n: ids.length,
913
+ }),
914
+ ids,
915
+ );
916
+ } catch (err) {
917
+ window.denShell?.errorToast?.(err);
918
+ await refresh();
919
+ } finally {
920
+ button.disabled = false;
921
+ }
922
+ }
923
+
924
+ /** Resume all: every interrupted agent in view that can resume (AG-14.3). */
925
+ async function resumeAll(button) {
926
+ const workspaces = [
927
+ ...new Set(
928
+ visibleRows()
929
+ .filter((r) => r.state === "interrupted")
930
+ .map((r) => r.workspace),
931
+ ),
932
+ ];
933
+ if (!workspaces.length) return;
934
+ button.disabled = true;
935
+ try {
936
+ const out = await window.denShell.api(
937
+ "POST",
938
+ "/api/agents/resume-interrupted",
939
+ { workspaces },
940
+ );
941
+ const n = out?.resumed?.length ?? 0;
942
+ window.denShell?.toast?.(
943
+ t(n === 1 ? "ag.resumedAll.one" : "ag.resumedAll", { n }),
944
+ );
618
945
  } catch (err) {
619
946
  window.denShell?.errorToast?.(err);
620
947
  }
621
948
  await refresh();
622
949
  }
623
950
 
951
+ /**
952
+ * Switch to ?ws='s workspace: make it active (as Local Dev's Activate
953
+ * does), then view it (POST /api/scope, the top bar's Workspace switch),
954
+ * then reload, as that switch does.
955
+ */
956
+ async function switchWorkspace(button) {
957
+ const ws = st.data?.requested?.workspace;
958
+ if (!ws) return;
959
+ button.disabled = true;
960
+ try {
961
+ await api("POST", "/api/workspaces", { name: ws, action: "activate" });
962
+ await api("POST", "/api/scope", { scope: ws });
963
+ } catch (err) {
964
+ window.denShell?.errorToast?.(err);
965
+ button.disabled = false;
966
+ return;
967
+ }
968
+ location.reload();
969
+ }
970
+
971
+ /** Review: the first interrupted agent, scrolled to and focused. */
972
+ function review() {
973
+ const first = st.root.querySelector('#agl .agst[data-state="interrupted"]');
974
+ if (!first) return;
975
+ for (const el of st.root.querySelectorAll("#agl .agst[data-review]"))
976
+ el.removeAttribute("data-review");
977
+ for (const el of st.root.querySelectorAll(
978
+ '#agl .agst[data-state="interrupted"]',
979
+ ))
980
+ el.setAttribute("data-review", "");
981
+ first.scrollIntoView({ block: "nearest" });
982
+ (
983
+ first.querySelector('[data-act="resume"]') ?? first.querySelector(".agr")
984
+ )?.focus();
985
+ }
986
+
624
987
  // ── Start agent ─────────────────────────────────────────────────────────
625
988
 
626
989
  // Personas and runtimes, per workspace (AG-0.5: a workspace's own runtime
@@ -761,6 +1124,7 @@ function translateStatic(root) {
761
1124
  };
762
1125
  set("#ag-h", "n.agents");
763
1126
  set("#ag-start", "ag.start");
1127
+ set("#ag-clear", "ag.clear");
764
1128
  set("#ag-dlg-h", "ag.start");
765
1129
  set("#ag-ws-t", "ag.ws");
766
1130
  set("#ag-p-t", "ag.persona");
@@ -782,6 +1146,23 @@ export async function mountAgentsPage(root) {
782
1146
  const startAsked = new URLSearchParams(location.search).get("start") === "1";
783
1147
 
784
1148
  root.querySelector("#agl").addEventListener("click", (e) => {
1149
+ const act = e.target.closest("button[data-act]");
1150
+ if (act) {
1151
+ const which = act.dataset.act;
1152
+ if (which === "resume-all") return void resumeAll(act);
1153
+ if (which === "review") return review();
1154
+ if (which === "switch-ws") return void switchWorkspace(act);
1155
+ const agent = act.closest(".agst")?.dataset.agent;
1156
+ const ws = act.closest(".agst")?.dataset.ws;
1157
+ const r = visibleRows().find(
1158
+ (x) => x.agent === agent && x.workspace === ws,
1159
+ );
1160
+ if (!r) return;
1161
+ if (which === "resume" || which === "fresh" || which === "relaunch")
1162
+ void restartRow(r, which, act);
1163
+ else if (which === "remove") void removeRow(r, act);
1164
+ return;
1165
+ }
785
1166
  const b = e.target.closest(".agr");
786
1167
  if (!b) return;
787
1168
  if (b.getAttribute("aria-disabled") === "true") return;
@@ -797,11 +1178,17 @@ export async function mountAgentsPage(root) {
797
1178
  if (!p) return;
798
1179
  if (b.dataset.act === "close") closePane(id);
799
1180
  else if (b.dataset.act === "stop") void stopAgent(p, b);
800
- else if (b.dataset.act === "reconnect") void connect(p);
1181
+ else if (b.dataset.act === "resume") {
1182
+ const r = rowOf(id);
1183
+ if (r) void restartRow(r, "resume", b);
1184
+ } else if (b.dataset.act === "reconnect") void connect(p);
801
1185
  });
802
1186
  root.querySelector("#ag-start").addEventListener("click", () => {
803
1187
  void openStart();
804
1188
  });
1189
+ root.querySelector("#ag-clear")?.addEventListener("click", (e) => {
1190
+ void clearStopped(e.currentTarget);
1191
+ });
805
1192
  root.querySelector("#ag-ws").addEventListener("change", (e) => {
806
1193
  void fillForWorkspace(e.currentTarget.value);
807
1194
  });
@@ -79,7 +79,9 @@ function stateText(state) {
79
79
  return m[1] === undefined
80
80
  ? t("ag.state.exited")
81
81
  : t("ag.state.exitedCode", { code: m[1] });
82
- return ["running", "stopping", "stopped", "ended"].includes(state)
82
+ return ["running", "stopping", "stopped", "ended", "interrupted"].includes(
83
+ state,
84
+ )
83
85
  ? t(`ag.state.${state}`)
84
86
  : state;
85
87
  }
@@ -219,9 +221,21 @@ async function openForm(sec) {
219
221
  async function act(ws, verb, id, button) {
220
222
  button.disabled = true;
221
223
  try {
222
- await api("POST", `${base(ws)}/${encodeURIComponent(id)}/${verb}`);
224
+ const out = await api(
225
+ "POST",
226
+ `${base(ws)}/${encodeURIComponent(id)}/${verb}`,
227
+ );
223
228
  window.denShell?.toast?.(
224
- t(verb === "stop" ? "ag.stopped" : "ag.resumed", { id }),
229
+ t(
230
+ verb === "stop"
231
+ ? out?.stillStopping
232
+ ? "ag.stillStopping"
233
+ : "ag.stopped"
234
+ : out?.fresh
235
+ ? "ag.resumedFresh"
236
+ : "ag.resumed",
237
+ { id },
238
+ ),
225
239
  );
226
240
  } catch (err) {
227
241
  window.denShell?.errorToast?.(err);
@@ -85,6 +85,9 @@
85
85
  display: flex;
86
86
  gap: 8px;
87
87
  }
88
+ .ph .act [hidden] {
89
+ display: none;
90
+ }
88
91
  .mono {
89
92
  font-family: var(--mono);
90
93
  }
@@ -163,6 +166,44 @@
163
166
  overflow: hidden;
164
167
  text-overflow: ellipsis;
165
168
  }
169
+ /* AG-14: a stopped agent's row. Its why is the row's .meta line; its
170
+ actions are the mock's .btn.sm (den-btn sm), starting under the name
171
+ (the row's 12px padding, its 2px border, the 8px dot with its 5px
172
+ margins (.den-dot), the 8px gap: 40px). */
173
+ .agr .why {
174
+ grid-column: 2 / 4;
175
+ font: 11.5px var(--mono);
176
+ color: var(--faint);
177
+ white-space: nowrap;
178
+ overflow: hidden;
179
+ text-overflow: ellipsis;
180
+ }
181
+ .agst .agacts {
182
+ display: flex;
183
+ flex-wrap: wrap;
184
+ align-items: center;
185
+ gap: 6px;
186
+ padding: 0 12px 8px 40px;
187
+ }
188
+ .agst .agcant {
189
+ flex-basis: 100%;
190
+ font: 12px/1.4 var(--sans);
191
+ color: var(--faint);
192
+ }
193
+ .agst[data-review] > .agr {
194
+ border-left-color: var(--warn);
195
+ }
196
+ /* The mock's .lnk: a button that reads as a link. */
197
+ .lnk {
198
+ color: var(--accent-text);
199
+ cursor: pointer;
200
+ font: inherit;
201
+ font-size: 12.5px;
202
+ text-decoration: underline;
203
+ background: none;
204
+ border: 0;
205
+ padding: 0;
206
+ }
166
207
  .wbdg {
167
208
  font: 10.5px var(--mono);
168
209
  color: var(--warn);
@@ -424,6 +465,9 @@
424
465
  </div>
425
466
  </div>
426
467
  <div class="act">
468
+ <button type="button" class="den-btn" id="ag-clear" hidden>
469
+ Clear stopped
470
+ </button>
427
471
  <button type="button" class="den-btn primary" id="ag-start">
428
472
  + Start agent
429
473
  </button>
@@ -400,7 +400,47 @@
400
400
  "ag.cancel": "Cancelar",
401
401
  "ag.started": "Agente {id} iniciado",
402
402
  "ag.stopped": "Agente {id} detenido",
403
+ "ag.stillStopping": "{id} todavía se está deteniendo",
404
+ "ag.resumedFresh": "{id}: aún no hay nada que reanudar; se inició de nuevo",
405
+ "ag.inactive": "El espacio de trabajo {ws} no está activo",
406
+ "ag.switchWs": "Cambiar a {ws}",
407
+ "ag.unknownWs": "(ningún espacio de trabajo tiene ese nombre)",
403
408
  "ag.resumed": "Agente {id} reanudado",
409
+ "ag.state.interrupted": "interrumpido",
410
+ "ag.why.stopped": "Lo detuviste a las {time}",
411
+ "ag.why.interrupted": "Interrumpido: {name} se reinició",
412
+ "ag.why.exited": "Salió",
413
+ "ag.why.exitedCode": "Salió (código {code})",
414
+ "ag.fresh": "Empezar de cero",
415
+ "ag.freshName": "Empezar {id} de cero",
416
+ "ag.startedFresh": "{id} empezó de cero",
417
+ "ag.rot.ready": "Listo para relanzar ({why})",
418
+ "ag.rot.due": "Falta el traspaso ({why})",
419
+ "ag.rot.compactions": "{n} compactaciones",
420
+ "ag.rot.compactions.one": "{n} compactación",
421
+ "ag.rot.merged": "PR fusionado",
422
+ "ag.rot.noHandoff":
423
+ "Su sesión rotó ({why}), pero no escribió handoff.md. Reanúdalo para que lo escriba, o empieza de cero.",
424
+ "ag.rot.badHandoff":
425
+ "Su sesión rotó ({why}), pero su handoff.md no está completo. Reanúdalo para arreglarlo, o empieza de cero.",
426
+ "ag.relaunch": "Relanzar desde el traspaso",
427
+ "ag.relaunchName": "Relanzar {id} desde el traspaso",
428
+ "ag.relaunched": "{id} relanzado desde su traspaso",
429
+ "ag.remove": "Quitar",
430
+ "ag.removeName": "Quitar {id}",
431
+ "ag.removed": "{id} quitado",
432
+ "ag.removedMany": "{n} agentes detenidos quitados",
433
+ "ag.removedMany.one": "{n} agente detenido quitado",
434
+ "ag.undo": "Deshacer",
435
+ "ag.restored": "De vuelta en la lista: {ids}",
436
+ "ag.clear": "Quitar detenidos",
437
+ "ag.noResume": "{runtime} no puede reanudar una conversación",
438
+ "ag.interrupted": "{n} agentes se interrumpieron al reiniciarse {name}",
439
+ "ag.interrupted.one": "{n} agente se interrumpió al reiniciarse {name}",
440
+ "ag.resumeAll": "Reanudar todos",
441
+ "ag.review": "Revisar",
442
+ "ag.resumedAll": "{n} agentes reanudados",
443
+ "ag.resumedAll.one": "{n} agente reanudado",
404
444
  // The Agents tab (AG-4a-term, agents-page.js).
405
445
  "n.agents": "Agentes",
406
446
  "kind.agent": "Agente",