@monster-orange/den 0.4.6 → 0.4.8

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 (49) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +244 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/skills/install-den/SKILL.md +2 -2
  7. package/src/shared/agents/den-claude.ts +38 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +66 -7
  15. package/src/shared/agents/records.ts +114 -0
  16. package/src/shared/agents/runtime.ts +12 -0
  17. package/src/shared/den-config.ts +97 -6
  18. package/src/shared/dev-metrics/emit.ts +31 -4
  19. package/src/shared/dev-metrics/polling.ts +135 -0
  20. package/src/shared/engine.ts +14 -2
  21. package/src/shared/plugins/manifest.ts +3 -0
  22. package/src/shared/usage/paths.ts +16 -0
  23. package/src/shared/usage/report.ts +283 -0
  24. package/src/shared/usage/sources.ts +127 -0
  25. package/src/shared/usage/transcript.ts +262 -0
  26. package/src/ui/cli/agent-ops.ts +495 -53
  27. package/src/ui/cli/agents-command.ts +170 -14
  28. package/src/ui/cli/doctor-command.ts +20 -0
  29. package/src/ui/cli/flag-parser.ts +1 -0
  30. package/src/ui/cli/index.ts +119 -2
  31. package/src/ui/cli/usage-command.ts +256 -0
  32. package/src/ui/cli/web-command.ts +3 -1
  33. package/src/ui/mcp/api.ts +10 -1
  34. package/src/ui/mcp/server.ts +34 -0
  35. package/src/ui/mcp/tools.ts +50 -0
  36. package/src/ui/server/agent-resume-grant.ts +53 -0
  37. package/src/ui/server/agent-routes.ts +230 -9
  38. package/src/ui/server/audit.ts +68 -1
  39. package/src/ui/server/index.ts +16 -5
  40. package/src/ui/server/public/agents-page.js +333 -11
  41. package/src/ui/server/public/agents-section.js +17 -3
  42. package/src/ui/server/public/agents.html +44 -0
  43. package/src/ui/server/public/i18n/es.js +28 -0
  44. package/src/ui/server/public/i18n/ja.js +25 -0
  45. package/src/ui/server/public/shell.js +66 -0
  46. package/src/ui/server/request-guard.ts +21 -1
  47. package/src/ui/server/route-classes.ts +23 -0
  48. package/src/ui/server/session-routes.ts +52 -4
  49. package/src/ui/server/sudo-store.ts +36 -7
@@ -511,7 +511,35 @@
511
511
  "ag.cancel": "Cancel",
512
512
  "ag.started": "Started agent {id}",
513
513
  "ag.stopped": "Stopped agent {id}",
514
+ "ag.stillStopping": "{id} is still stopping",
515
+ "ag.resumedFresh": "{id}: nothing to resume yet; started fresh",
516
+ "ag.inactive": "Workspace {ws} isn't active",
517
+ "ag.switchWs": "Switch to {ws}",
518
+ "ag.unknownWs": "(no workspace has that name)",
514
519
  "ag.resumed": "Resumed agent {id}",
520
+ "ag.state.interrupted": "interrupted",
521
+ "ag.why.stopped": "Stopped by you {time}",
522
+ "ag.why.interrupted": "Interrupted: {name} restarted",
523
+ "ag.why.exited": "Exited",
524
+ "ag.why.exitedCode": "Exited (code {code})",
525
+ "ag.fresh": "Start fresh",
526
+ "ag.freshName": "Start {id} fresh",
527
+ "ag.startedFresh": "Started {id} fresh",
528
+ "ag.remove": "Remove",
529
+ "ag.removeName": "Remove {id}",
530
+ "ag.removed": "Removed {id}",
531
+ "ag.removedMany": "Removed {n} stopped agents",
532
+ "ag.removedMany.one": "Removed {n} stopped agent",
533
+ "ag.undo": "Undo",
534
+ "ag.restored": "Back in the list: {ids}",
535
+ "ag.clear": "Clear stopped",
536
+ "ag.noResume": "{runtime} can't resume a conversation",
537
+ "ag.interrupted": "{n} agents were interrupted when {name} restarted",
538
+ "ag.interrupted.one": "{n} agent was interrupted when {name} restarted",
539
+ "ag.resumeAll": "Resume all",
540
+ "ag.review": "Review",
541
+ "ag.resumedAll": "Resumed {n} agents",
542
+ "ag.resumedAll.one": "Resumed {n} agent",
515
543
  // The Agents tab (AG-4a-term, agents-page.js).
516
544
  "n.agents": "Agents",
517
545
  "kind.agent": "Agent",
@@ -1116,6 +1144,42 @@
1116
1144
  if (el.remove) el.remove();
1117
1145
  }, 2200);
1118
1146
  }
1147
+ /**
1148
+ * A toast with one action (AG-14.1b: Remove's Undo): the message and a
1149
+ * small button, for `ms` (default 10 s). The action runs once, and the
1150
+ * toast goes.
1151
+ */
1152
+ function toastAction(msg, label, onAction, ms) {
1153
+ if (!document.body || !document.createElement) return null;
1154
+ var old = document.querySelector && document.querySelector(".den-toast");
1155
+ if (old && old.remove) old.remove();
1156
+ var el = document.createElement("div");
1157
+ el.className = "den-toast den-toast-act";
1158
+ el.setAttribute("role", "status");
1159
+ var text = document.createElement("span");
1160
+ text.textContent = msg;
1161
+ var b = document.createElement("button");
1162
+ b.type = "button";
1163
+ b.className = "den-tbtn";
1164
+ b.textContent = label;
1165
+ var done = false;
1166
+ b.addEventListener("click", function () {
1167
+ if (done) return;
1168
+ done = true;
1169
+ clearTimeout(toastTimer);
1170
+ el.remove();
1171
+ onAction();
1172
+ });
1173
+ el.appendChild(text);
1174
+ el.appendChild(b);
1175
+ document.body.appendChild(el);
1176
+ clearTimeout(toastTimer);
1177
+ toastTimer = setTimeout(function () {
1178
+ done = true;
1179
+ if (el.remove) el.remove();
1180
+ }, ms || 10000);
1181
+ return el;
1182
+ }
1119
1183
 
1120
1184
  // ── menus (.pop, R18): the scope parts' popovers ─────────────────────
1121
1185
  // A menu is { label, items: [{ heading } | { hr: true } | { label, tag?,
@@ -3776,6 +3840,7 @@
3776
3840
  unpin: unpin,
3777
3841
  isPinned: isPinned,
3778
3842
  toast: toast,
3843
+ toastAction: toastAction,
3779
3844
  /** R26c: a call that resolves with its data or throws a DenApiError. */
3780
3845
  api: api,
3781
3846
  /** R26c: the error toast for a DenApiError (what an uncaught one gets). */
@@ -4168,6 +4233,7 @@
4168
4233
  // the prototype's small buttons (.btn.sm).
4169
4234
  ".den-toast-err { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px));",
4170
4235
  " border-color: color-mix(in srgb, var(--bad) 45%, var(--border-strong)); }",
4236
+ ".den-toast-act { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px)); }",
4171
4237
  ".den-toast-err .den-te-ic { color: var(--bad); }",
4172
4238
  ".den-toast-err .den-te-msg { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }",
4173
4239
  ".den-toast-err .den-te-id { font: 11px var(--mono); color: var(--faint); }",
@@ -17,7 +17,9 @@
17
17
  * and sign-in (/api/web/*, /auth, /api/session/logout), the lock (R8:
18
18
  * /api/sudo-password*), or a shell-grade route other than its own
19
19
  * session's /api/sessions/<id>/*: agent start and resume mint tokens.
20
- * 403 `agent_forbidden`. WS-3a (0.4.2) adds the workspace check at
20
+ * 403 `agent_forbidden`. One exception (AG-14.4): MCP agent_resume,
21
+ * when mcp.actions lists it, for another agent of its own workspace
22
+ * or a granted one (agent-resume-grant.ts). WS-3a (0.4.2) adds the workspace check at
21
23
  * agentRefusal's seam.
22
24
  * - Sign-in (§4, route-classes.ts): beyond loopback (a LAN name, a peer
23
25
  * that isn't loopback, or the edge hostname for a client on another
@@ -58,6 +60,8 @@
58
60
  */
59
61
  import { randomBytes, timingSafeEqual, createHash } from "crypto";
60
62
  import type * as http from "http";
63
+ import { readMcpConfig } from "../../shared/den-config.js";
64
+ import { agentResumeRefusal, agentResumeTarget } from "./agent-resume-grant.js";
61
65
  import * as net from "net";
62
66
  import * as os from "os";
63
67
  import {
@@ -707,11 +711,27 @@ export function agentRefusal(
707
711
  if (id === p.session.id || id === p.session.name) return null;
708
712
  return `${p.actor} can use its own session only, not ${id}.`;
709
713
  }
714
+ // AG-14.4: MCP agent_resume, opted in, inside the agent's scope.
715
+ const target = method === "POST" ? agentResumeTarget(pathname) : null;
716
+ if (target)
717
+ return (
718
+ agentResumeRefusal(p, target.ws, target.id, mcpActions()) ??
719
+ workspaceRefusal(p, pathname)
720
+ );
710
721
  if (cls === "shell-grade")
711
722
  return `${p.actor} can't ${method === "GET" ? "open" : "use"} ${pathname}: starting or resuming an agent, and the browser sessions, are a person's.`;
712
723
  return workspaceRefusal(p, pathname);
713
724
  }
714
725
 
726
+ /** The org config's mcp.actions; none when it can't be read. */
727
+ function mcpActions(): readonly string[] {
728
+ try {
729
+ return readMcpConfig().actions;
730
+ } catch {
731
+ return [];
732
+ }
733
+ }
734
+
715
735
  /** WS-3a's seam (0.4.2): an agent's route outside its workspace. Not yet. */
716
736
  function workspaceRefusal(_p: AgentPrincipal, _pathname: string): null {
717
737
  return null;
@@ -302,6 +302,27 @@ export const ROUTES: readonly RouteEntry[] = [
302
302
  M(["POST", "DELETE"], "/api/workspaces/services"),
303
303
  M(["POST"], "/api/workspaces/settings"),
304
304
  M(["POST"], "/api/workspaces/:ws/agents/:id/stop", "TERM to its session"),
305
+ M(
306
+ ["POST"],
307
+ "/api/workspaces/:ws/agents/:id/remove",
308
+ "forgets a stopped agent (AG-14.1b); its record waits for Undo",
309
+ ),
310
+ M(
311
+ ["POST"],
312
+ "/api/agents/clear-stopped",
313
+ "forgets every stopped agent of the workspaces named (AG-14.1b)",
314
+ ),
315
+ M(["POST"], "/api/agents/restore", "Undo: brings removed agents back"),
316
+ S(
317
+ ["POST"],
318
+ "/api/workspaces/:ws/agents/:id/fresh",
319
+ "starts an agent afresh: a runtime in a PTY",
320
+ ),
321
+ S(
322
+ ["POST"],
323
+ "/api/agents/resume-interrupted",
324
+ "resumes interrupted agents: runtimes in PTYs (AG-14.3)",
325
+ ),
305
326
  S(
306
327
  ["POST"],
307
328
  "/api/workspaces/:ws/agents",
@@ -378,6 +399,8 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
378
399
  "/api/workspaces/:ws/agents",
379
400
  "/api/workspaces/:ws/agents/:id/stop",
380
401
  "/api/workspaces/:ws/agents/:id/resume",
402
+ "/api/workspaces/:ws/agents/:id/fresh",
403
+ "/api/workspaces/:ws/agents/:id/remove",
381
404
  ],
382
405
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
383
406
  "session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
@@ -35,7 +35,10 @@
35
35
  */
36
36
  import type * as http from "http";
37
37
  import type { Duplex } from "stream";
38
- import { getActiveWorkspaces } from "../../shared/environment-manager/workspaces.js";
38
+ import {
39
+ getActiveWorkspaces,
40
+ readWorkspacesFile,
41
+ } from "../../shared/environment-manager/workspaces.js";
39
42
  import { getStateDir } from "../../shared/paths.js";
40
43
  import { product } from "../../shared/product.js";
41
44
  import { revokedAgentIds } from "../../shared/actor-token.js";
@@ -49,7 +52,7 @@ import { parseSessionName } from "../../shared/sessions/names.js";
49
52
  import { sessionPaths } from "../../shared/sessions/paths.js";
50
53
  import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
51
54
  import type { SessionInfo } from "../../shared/sessions/types.js";
52
- import { agentRows } from "../cli/agent-ops.js";
55
+ import { agentRows, noteEnded } from "../cli/agent-ops.js";
53
56
  import { state as sessionState } from "../cli/sessions-command.js";
54
57
  import { recordAction } from "./audit.js";
55
58
  import { sendJson } from "./http-helpers.js";
@@ -68,6 +71,8 @@ export interface SessionRouteDeps {
68
71
  /** A client of the running host, or null (never starts one). */
69
72
  connectExisting?: () => Promise<HostClient | null>;
70
73
  activeWorkspaces?: () => string[];
74
+ /** Whether workspaces.yml names `ws` (active or not). */
75
+ workspaceExists?: (ws: string) => boolean;
71
76
  pty?: () => PtyLoad;
72
77
  }
73
78
 
@@ -76,9 +81,13 @@ interface Resolved {
76
81
  dist: () => string;
77
82
  connectExisting: () => Promise<HostClient | null>;
78
83
  activeWorkspaces: () => string[];
84
+ workspaceExists: (ws: string) => boolean;
79
85
  pty: () => PtyLoad;
80
86
  }
81
87
 
88
+ /** A workspace name as `?ws=` may give it. */
89
+ const WS_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
90
+
82
91
  function resolve(d: SessionRouteDeps = {}): Resolved {
83
92
  const stateDir = d.stateDir ?? (() => getStateDir());
84
93
  return {
@@ -95,6 +104,13 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
95
104
  }
96
105
  }),
97
106
  activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
107
+ workspaceExists:
108
+ d.workspaceExists ??
109
+ ((ws) =>
110
+ Object.prototype.hasOwnProperty.call(
111
+ readWorkspacesFile().workspaces ?? {},
112
+ ws,
113
+ )),
98
114
  pty: d.pty ?? (() => loadPty()),
99
115
  };
100
116
  }
@@ -119,7 +135,7 @@ export interface ListedSession {
119
135
  runtime?: string;
120
136
  model?: string;
121
137
  cwd?: string;
122
- /** running, stopping, exited (<code>), stopped, ended. */
138
+ /** running, stopping, exited (<code>), stopped, interrupted (AG-14). */
123
139
  state: string;
124
140
  exited: boolean;
125
141
  exitCode?: number;
@@ -127,6 +143,12 @@ export interface ListedSession {
127
143
  /** The host has it (running, or ended with its scrollback): a pane can attach. */
128
144
  attachable: boolean;
129
145
  resumable?: boolean;
146
+ /** Why only Start fresh applies (AG-14.2): "<runtime> can't resume a conversation". */
147
+ noResume?: string;
148
+ /** When it was stopped, seen to end, or found interrupted (AG-14.1). */
149
+ stoppedAt?: string;
150
+ endedAt?: string;
151
+ interruptedAt?: string;
130
152
  /** Its actor token was revoked (`web logout --all`): restart or resume it. */
131
153
  tokenRevoked?: boolean;
132
154
  /** An approval is pending (AG-6a; only when the host knows). */
@@ -137,9 +159,12 @@ export interface ListedSession {
137
159
  export function listedSessions(
138
160
  sessions: SessionInfo[],
139
161
  d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
162
+ /** A workspace asked for by name (`?ws=`), listed even when inactive. */
163
+ also?: string,
140
164
  ): ListedSession[] {
141
165
  const dist = d.dist();
142
166
  const active = new Set(d.activeWorkspaces());
167
+ if (also) active.add(also);
143
168
  const mine = sessions.filter((s) => {
144
169
  const p = parseSessionName(s.name, dist);
145
170
  return p !== null && active.has(p.workspace);
@@ -150,6 +175,7 @@ export function listedSessions(
150
175
  const revoked = revokedAgentIds(d.stateDir());
151
176
  const out: ListedSession[] = [];
152
177
  const agentSessions = new Set<string>();
178
+ noteEnded(d.stateDir(), mine, records);
153
179
  for (const r of agentRows(mine, records)) {
154
180
  const rec = records.find((x) => x.id === r.agent)!;
155
181
  const s = r.session;
@@ -171,6 +197,10 @@ export function listedSessions(
171
197
  ...(r.startedAt ? { startedAt: r.startedAt } : {}),
172
198
  attachable: Boolean(s),
173
199
  resumable: r.resumable,
200
+ ...(r.noResume ? { noResume: r.noResume } : {}),
201
+ ...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
202
+ ...(r.endedAt ? { endedAt: r.endedAt } : {}),
203
+ ...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
174
204
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
175
205
  });
176
206
  }
@@ -235,9 +265,27 @@ export async function handleSessionList(
235
265
  } catch (e) {
236
266
  hostError = (e as Error).message;
237
267
  }
268
+ // `?ws=<name>`: that workspace's too, active or not, and whether it is
269
+ // active and known (the page says so rather than show all of them).
270
+ const asked = new URL(req.url ?? "/", "http://localhost").searchParams.get(
271
+ "ws",
272
+ );
273
+ const wanted =
274
+ asked && WS_NAME.test(asked)
275
+ ? {
276
+ workspace: asked,
277
+ active: d.activeWorkspaces().includes(asked),
278
+ known: d.workspaceExists(asked),
279
+ }
280
+ : null;
238
281
  sendJson(res, {
239
- sessions: listedSessions(sessions, d),
282
+ sessions: listedSessions(
283
+ sessions,
284
+ d,
285
+ wanted?.known ? wanted.workspace : undefined,
286
+ ),
240
287
  workspaces: d.activeWorkspaces(),
288
+ ...(wanted ? { requested: wanted } : {}),
241
289
  host,
242
290
  ...(hostError ? { hostError } : {}),
243
291
  backend: pty.available
@@ -32,7 +32,7 @@ import { AsyncLocalStorage } from "async_hooks";
32
32
  import * as fs from "fs";
33
33
  import * as os from "os";
34
34
  import * as path from "path";
35
- import { execSync } from "child_process";
35
+ import { execFileSync } from "child_process";
36
36
  import { CLI_SPAWN_TIMEOUT_MS } from "../../shared/constants.js";
37
37
 
38
38
  import { product } from "../../shared/product.js";
@@ -62,11 +62,40 @@ function shimDirPrefix(): string {
62
62
  return path.join(os.tmpdir(), `${product().cli}-sudo-`);
63
63
  }
64
64
 
65
+ /** The system's sudo: what Den runs whenever it is there. */
66
+ export const SYSTEM_SUDO = "/usr/bin/sudo";
67
+
68
+ /**
69
+ * Where the system's sudo is: /usr/bin/sudo, or DEN_SYSTEM_SUDO when set
70
+ * to an absolute path (a machine whose sudo lives elsewhere, and the
71
+ * tests' fake sudo). Never a name looked up on PATH.
72
+ */
73
+ export function systemSudo(env: NodeJS.ProcessEnv = process.env): string {
74
+ const v = env.DEN_SYSTEM_SUDO;
75
+ return v && path.isAbsolute(v) ? v : SYSTEM_SUDO;
76
+ }
77
+
78
+ function isExecutableFile(file: string): boolean {
79
+ try {
80
+ fs.accessSync(file, fs.constants.X_OK);
81
+ return fs.statSync(file).isFile();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
65
87
  /**
66
- * The real sudo: the first `sudo` on PATH that isn't one of Den's shims,
67
- * else /usr/bin/sudo. Read when a shim is written.
88
+ * The real sudo: /usr/bin/sudo (systemSudo), pinned, so a `sudo` earlier
89
+ * on PATH (a directory a user or a tool can write to) never receives the
90
+ * admin password. Only where there is none there: the first `sudo` on
91
+ * PATH that isn't one of Den's shims. Read when a shim is written, and by
92
+ * every check Den runs itself.
68
93
  */
69
- export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
94
+ export function realSudoPath(
95
+ envPath: string = process.env.PATH ?? "",
96
+ system: string = systemSudo(),
97
+ ): string {
98
+ if (isExecutableFile(system)) return system;
70
99
  const prefix = shimDirPrefix();
71
100
  for (const dir of envPath.split(path.delimiter)) {
72
101
  if (!dir || dir.startsWith(prefix)) continue;
@@ -78,7 +107,7 @@ export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
78
107
  /* not here */
79
108
  }
80
109
  }
81
- return "/usr/bin/sudo";
110
+ return system;
82
111
  }
83
112
 
84
113
  /** A path inside single quotes, for a shim. */
@@ -254,7 +283,7 @@ export function watchSudo(
254
283
  export function prewarmSudo(): void {
255
284
  if (!_sudoPassword || lockClosedHere()) return;
256
285
  try {
257
- execSync("sudo -S -v", {
286
+ execFileSync(realSudoPath(), ["-S", "-v"], {
258
287
  input: _sudoPassword + "\n",
259
288
  stdio: ["pipe", "pipe", "pipe"],
260
289
  timeout: CLI_SPAWN_TIMEOUT_MS,
@@ -288,7 +317,7 @@ export function hasPasswordlessSudo(): boolean {
288
317
  }
289
318
  let value = false;
290
319
  try {
291
- execSync("sudo -n true", {
320
+ execFileSync(realSudoPath(), ["-n", "true"], {
292
321
  stdio: ["ignore", "ignore", "ignore"],
293
322
  timeout: CLI_SPAWN_TIMEOUT_MS,
294
323
  });