@monster-orange/den 0.4.18 → 0.4.20

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 (53) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +148 -30
  4. package/docs/reference.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/install-den/SKILL.md +35 -75
  7. package/src/shared/agents/den-claude.ts +40 -6
  8. package/src/shared/agents/guardrails/stop-hook.ts +30 -4
  9. package/src/shared/agents/guardrails.ts +40 -4
  10. package/src/shared/agents/handback.ts +22 -4
  11. package/src/shared/agents/personas.ts +32 -5
  12. package/src/shared/agents/runtime.ts +8 -0
  13. package/src/shared/agents/server-agent.ts +57 -0
  14. package/src/shared/agents/server-pack/server.md +45 -0
  15. package/src/shared/environment-manager/workspaces.ts +4 -0
  16. package/src/shared/secrets/index.ts +71 -3
  17. package/src/shared/secrets/onepassword.ts +87 -14
  18. package/src/shared/secrets/op-token.ts +270 -0
  19. package/src/shared/secrets/secret-env.ts +19 -6
  20. package/src/shared/secrets/strategies.ts +6 -0
  21. package/src/shared/secrets/types.ts +8 -0
  22. package/src/ui/cli/agent-ops.ts +41 -6
  23. package/src/ui/cli/create.ts +2 -2
  24. package/src/ui/cli/doctor-command.ts +54 -2
  25. package/src/ui/cli/index.ts +1 -1
  26. package/src/ui/cli/repo-command.ts +8 -1
  27. package/src/ui/cli/repo-detect.ts +58 -0
  28. package/src/ui/cli/server-agent-ops.ts +411 -0
  29. package/src/ui/mcp/server.ts +29 -12
  30. package/src/ui/mcp/tools.ts +4 -2
  31. package/src/ui/server/agent-routes.ts +1 -1
  32. package/src/ui/server/held-actions.ts +35 -7
  33. package/src/ui/server/index.ts +25 -6
  34. package/src/ui/server/public/agents-page.js +10 -1
  35. package/src/ui/server/public/error-page.js +39 -1
  36. package/src/ui/server/public/graph.css +21 -0
  37. package/src/ui/server/public/graph.js +49 -6
  38. package/src/ui/server/public/header.js +29 -7
  39. package/src/ui/server/public/i18n/es.js +38 -0
  40. package/src/ui/server/public/i18n/ja.js +40 -0
  41. package/src/ui/server/public/local-dev.html +22 -1
  42. package/src/ui/server/public/persona-ui.css +6 -0
  43. package/src/ui/server/public/server-agent-page.js +415 -0
  44. package/src/ui/server/public/server-agent.html +222 -0
  45. package/src/ui/server/public/settings-registry.js +22 -0
  46. package/src/ui/server/public/shell.js +67 -6
  47. package/src/ui/server/public/svc-panel.js +27 -14
  48. package/src/ui/server/public/topbar.js +13 -1
  49. package/src/ui/server/route-classes.ts +21 -0
  50. package/src/ui/server/server-agent-routes.ts +176 -0
  51. package/src/ui/server/session-routes.ts +12 -1
  52. package/src/ui/server/ui-assets.ts +2 -0
  53. package/src/ui/server/workspace-routes.ts +3 -0
@@ -78,6 +78,7 @@ import { envKey } from "../../shared/product.js";
78
78
  import { redactSecrets } from "../../shared/redact.js";
79
79
  import {
80
80
  loadPersonas,
81
+ serverPersona,
81
82
  resolvePackDirs,
82
83
  type Persona,
83
84
  } from "../../shared/agents/personas.js";
@@ -167,6 +168,11 @@ import {
167
168
  type BinSource,
168
169
  } from "../../shared/agents/runtime-search.js";
169
170
  import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
171
+ import {
172
+ SERVER_PERSONA_ID,
173
+ isServerScope,
174
+ serverAgentDir,
175
+ } from "../../shared/agents/server-agent.js";
170
176
  import {
171
177
  bindActorSession,
172
178
  mintActorToken,
@@ -333,7 +339,7 @@ function defaultMcpServer(cli: string): {
333
339
 
334
340
  export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
335
341
  const base = resolveDeps(d);
336
- return {
342
+ return withServerScope({
337
343
  ...base,
338
344
  agentsConfig: d.agentsConfig ?? (() => readAgentsConfig()),
339
345
  distRoot: d.distRoot ?? getDistRoot,
@@ -365,6 +371,26 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
365
371
  ? setWorkspaceRuntimeBin(ws, id, bin)
366
372
  : setUserRuntimeBin(id, bin)),
367
373
  userConfigFile: d.userConfigFile ?? userConfigPath,
374
+ });
375
+ }
376
+
377
+ /**
378
+ * ON-6: the server agent's scope is no workspace, but its agent is a
379
+ * normal one: it "exists", and its folder is an empty one under the state
380
+ * dir (serverAgentDir), with no workspace runtime paths.
381
+ */
382
+ function withServerScope(r: AgentsResolved): AgentsResolved {
383
+ return {
384
+ ...r,
385
+ workspaceExists: (ws) => isServerScope(ws) || r.workspaceExists(ws),
386
+ workspaceDir: (ws) =>
387
+ isServerScope(ws) ? serverAgentDir(r.stateDir()) : r.workspaceDir(ws),
388
+ workspaceFolders: (ws) =>
389
+ isServerScope(ws)
390
+ ? { under: [serverAgentDir(r.stateDir())] }
391
+ : r.workspaceFolders(ws),
392
+ workspaceRuntimeBin: (ws, id) =>
393
+ isServerScope(ws) ? undefined : r.workspaceRuntimeBin(ws, id),
368
394
  };
369
395
  }
370
396
 
@@ -685,14 +711,18 @@ function agentsConfig(d: AgentsResolved): ResolvedAgentsConfig {
685
711
  function personas(
686
712
  d: AgentsResolved,
687
713
  cfg: ResolvedAgentsConfig,
714
+ /** ON-6: the server scope adds Den's own server persona (only there). */
715
+ ws?: string,
688
716
  ): Map<string, Persona> {
689
717
  try {
690
- return loadPersonas(
718
+ const all = loadPersonas(
691
719
  resolvePackDirs(cfg.personaPacks, d.distRoot()),
692
720
  undefined,
693
721
  { defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
694
722
  localPersonasDir(d.stateDir()),
695
723
  );
724
+ if (isServerScope(ws)) all.set(SERVER_PERSONA_ID, serverPersona());
725
+ return all;
696
726
  } catch (e) {
697
727
  throw refused((e as Error).message, [], 422);
698
728
  }
@@ -1337,6 +1367,9 @@ function launchInput(
1337
1367
  rotateAfter: after,
1338
1368
  ...(perms.permission ? { permission: perms.permission } : {}),
1339
1369
  ...(perms.allow?.length ? { allow: [...perms.allow] } : {}),
1370
+ // ON-6: the server agent runs Den's own tools without the runtime's
1371
+ // prompt; its writes wait for the person's Allow in the browser.
1372
+ ...(isServerScope(ws) ? { denToolsAllowed: true } : {}),
1340
1373
  };
1341
1374
  }
1342
1375
 
@@ -1573,7 +1606,7 @@ export async function startAgent(
1573
1606
  if (!d.workspaceExists(ws))
1574
1607
  throw usage(`${where}: workspace "${ws}" does not exist`, 404);
1575
1608
  const cfg = agentsConfig(d);
1576
- const all = personas(d, cfg);
1609
+ const all = personas(d, cfg, ws);
1577
1610
  const persona = all.get(req.persona);
1578
1611
  if (!persona)
1579
1612
  throw usage(
@@ -2108,7 +2141,7 @@ export async function resumeAgent(
2108
2141
  );
2109
2142
  const cfg = agentsConfig(d);
2110
2143
  const grants = resumeGrants(rec, cfg);
2111
- const persona = personas(d, cfg).get(rec.persona);
2144
+ const persona = personas(d, cfg, rec.workspace).get(rec.persona);
2112
2145
  if (!persona)
2113
2146
  throw refused(`agent ${rec.id}'s persona "${rec.persona}" is gone`);
2114
2147
  const det = await availableOrRefuse(d, rt, rec.workspace);
@@ -2312,7 +2345,7 @@ export async function changePersona(
2312
2345
  const where = "agents persona";
2313
2346
  const rec = agentRecord(d, id, ws);
2314
2347
  const cfg = agentsConfig(d);
2315
- const all = personas(d, cfg);
2348
+ const all = personas(d, cfg, rec.workspace);
2316
2349
  if (!all.get(req.persona))
2317
2350
  throw usage(
2318
2351
  `${where}: no persona "${req.persona}" (personas: ${[...all.keys()].sort().join(", ")})`,
@@ -2747,7 +2780,9 @@ export async function markInterrupted(
2747
2780
  return found;
2748
2781
  }
2749
2782
  for (const rec of marked) {
2750
- const p = all.get(rec.persona);
2783
+ const p = isServerScope(rec.workspace)
2784
+ ? serverPersona()
2785
+ : all.get(rec.persona);
2751
2786
  if (!p || !(p.standing || p.resumeOnRestart)) continue;
2752
2787
  if (resumeRefusal(rec)) continue;
2753
2788
  try {
@@ -545,7 +545,7 @@ export async function createResult(
545
545
  return {
546
546
  ok: false,
547
547
  error,
548
- fix: `npx ${ENGINE_PACKAGE} create ${args.name} ${path.join(path.dirname(args.dir), `${args.name}-den`)} --yes --json`,
548
+ fix: `npx ${ENGINE_PACKAGE}@latest create ${args.name} ${path.join(path.dirname(args.dir), `${args.name}-den`)} --yes --json`,
549
549
  ask: `${args.dir} already has files that are not a ${args.name} distribution. Which new or empty folder should the distribution go in?`,
550
550
  };
551
551
  return {
@@ -576,7 +576,7 @@ export async function runCreate(
576
576
  JSON.stringify({
577
577
  ok: false,
578
578
  error: (e as Error).message.split("\n")[0],
579
- fix: `npx ${ENGINE_PACKAGE} create <name> [dir] --yes --json`,
579
+ fix: `npx ${ENGINE_PACKAGE}@latest create <name> [dir] --yes --json`,
580
580
  } satisfies CreateJson),
581
581
  );
582
582
  } else console.error(`create: ${(e as Error).message}`);
@@ -37,8 +37,13 @@ import { readBrand } from "../../shared/brand.js";
37
37
  import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
38
38
  import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
39
39
  import {
40
+ describeOpTokenSource,
41
+ opTokenSourceProblem,
42
+ parseOpTokenSource,
40
43
  readSecretsConfig,
44
+ resolveProviderName,
41
45
  secretsStatus,
46
+ type SecretsConfig,
42
47
  } from "../../shared/secrets/index.js";
43
48
  import {
44
49
  DEN_PTY_ID,
@@ -77,7 +82,22 @@ type Selector = (env: NodeJS.ProcessEnv) => DoctorRow;
77
82
 
78
83
  const SELECTORS: Record<string, Selector> = {
79
84
  secrets(env) {
80
- const st = secretsStatus(readSecretsConfig(), env);
85
+ let cfg: SecretsConfig;
86
+ try {
87
+ cfg = readSecretsConfig();
88
+ } catch (err) {
89
+ // A bad secrets.* key (naming the key): a problem, not a crash.
90
+ return {
91
+ kind: "secrets",
92
+ selected: "",
93
+ implicit: false,
94
+ available: false,
95
+ reason: (err as Error).message,
96
+ problem: true,
97
+ warnings: [],
98
+ };
99
+ }
100
+ const st = secretsStatus(cfg, env);
81
101
  return {
82
102
  kind: "secrets",
83
103
  selected: st.backend,
@@ -172,7 +192,7 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
172
192
  ...Object.keys(SELECTORS).filter((k) => kinds.includes(k)),
173
193
  ...kinds.filter((k) => !(k in SELECTORS)),
174
194
  ];
175
- return ordered.map(
195
+ const rows = ordered.map(
176
196
  (kind) =>
177
197
  SELECTORS[kind]?.(env) ?? {
178
198
  kind,
@@ -184,6 +204,38 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
184
204
  warnings: [],
185
205
  },
186
206
  );
207
+ const op = onePasswordTokenRow(env);
208
+ if (op) rows.splice(rows.findIndex((r) => r.kind === "secrets") + 1, 0, op);
209
+ return rows;
210
+ }
211
+
212
+ /**
213
+ * The 1password row: shown only when 1Password is the configured provider
214
+ * (secret_env refs and the engine's own secrets resolve through it) and the
215
+ * configured token source yields no token. A warning, not a failure: a
216
+ * signed-in desktop app needs none. Checks the source without reading a
217
+ * keychain secret, so nothing prompts.
218
+ */
219
+ function onePasswordTokenRow(env: NodeJS.ProcessEnv): DoctorRow | null {
220
+ try {
221
+ const cfg = readSecretsConfig();
222
+ if (resolveProviderName(cfg, env) !== "1password") return null;
223
+ const src = parseOpTokenSource(cfg.onepassword?.token);
224
+ const why = opTokenSourceProblem(src, { env });
225
+ if (!why) return null;
226
+ return {
227
+ kind: "1password",
228
+ selected: describeOpTokenSource(src),
229
+ implicit: false,
230
+ available: false,
231
+ reason:
232
+ "this process has no service-account token. Set secrets.onepassword.token.",
233
+ problem: false,
234
+ warnings: [why],
235
+ };
236
+ } catch {
237
+ return null;
238
+ }
187
239
  }
188
240
 
189
241
  export function formatDoctor(rows: DoctorRow[], title: string): string[] {
@@ -446,7 +446,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
446
446
  "create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>]",
447
447
  lines: [
448
448
  "Scaffold a new distribution (den.yml, config, bin shim, Makefile) in [dir].",
449
- "Also: npx @monster-orange/den create <name>. See docs/distributions.md.",
449
+ "Also: npx @monster-orange/den@latest create <name>. See docs/distributions.md.",
450
450
  ],
451
451
  },
452
452
  validate: {
@@ -60,6 +60,7 @@ import { ensureManagedServiceClone } from "../../shared/environment-manager/work
60
60
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
61
61
  import {
62
62
  detectRunCommands,
63
+ draftHealthUrl,
63
64
  repoNameFrom,
64
65
  sidecarManifest,
65
66
  type DetectedCommands,
@@ -358,7 +359,13 @@ export function repoAdd(
358
359
  error: `cannot name a repo from the folder "${key}"`,
359
360
  fix: `${bin} repo add ${a.spec} --name <repo> --json`,
360
361
  };
361
- const manifest = sidecarManifest(mName, d.commands, a.health);
362
+ // No --health: the dev server's own port, when its script names one,
363
+ // so the drafted service has a health check and a Visit link.
364
+ const manifest = sidecarManifest(
365
+ mName,
366
+ d.commands,
367
+ a.health ?? draftHealthUrl(repoPath, d.commands),
368
+ );
362
369
  const v = validate(manifest);
363
370
  const dry = a.dryRun
364
371
  ? {
@@ -157,6 +157,64 @@ export function needsDocker(manifest: unknown, repoPath?: string): boolean {
157
157
  return repoPath ? composeFileOf(repoPath) !== undefined : false;
158
158
  }
159
159
 
160
+ /**
161
+ * Dev servers' own default ports, by the command a package script runs.
162
+ * Only tools whose default is fixed and documented; anything else gets no
163
+ * port, and so no health URL.
164
+ */
165
+ const DEV_SERVER_PORTS: ReadonlyArray<[RegExp, number]> = [
166
+ [/^next(\s+dev)?\b/, 3000],
167
+ [/^(vite|vite\s+dev|vite\s+serve)\b/, 5173],
168
+ [/^(nuxt|nuxi)\s+dev\b/, 3000],
169
+ [/^react-scripts\s+start\b/, 3000],
170
+ [/^ng\s+serve\b/, 4200],
171
+ [/^gatsby\s+develop\b/, 8000],
172
+ [/^remix\s+vite:dev\b/, 5173],
173
+ [/^webpack(-dev-server|\s+serve)\b/, 8080],
174
+ ];
175
+
176
+ /** A port the command names itself: --port N, -p N, PORT=N. */
177
+ function explicitPort(cmd: string): number | undefined {
178
+ const m =
179
+ /(?:^|\s)--port[=\s]+(\d{2,5})\b/.exec(cmd) ??
180
+ /(?:^|\s)-p\s+(\d{2,5})\b/.exec(cmd) ??
181
+ /(?:^|\s)PORT=(\d{2,5})\b/.exec(cmd);
182
+ const n = m ? Number(m[1]) : NaN;
183
+ return n > 0 && n < 65536 ? n : undefined;
184
+ }
185
+
186
+ /**
187
+ * The port a repo's dev server listens on, when its start command is a
188
+ * package script that names one or runs a dev server with a known default.
189
+ * Read-only: the script's text, never the running server.
190
+ */
191
+ export function detectDevPort(
192
+ repoPath: string,
193
+ c: Pick<DetectedCommands, "up">,
194
+ ): number | undefined {
195
+ const scripts = readScripts(repoPath);
196
+ const m = /^npm\s+(?:run\s+(\S+)|(start))$/.exec(c.up.trim());
197
+ const script = m ? scripts[m[1] ?? "start"] : c.up;
198
+ if (!script) return undefined;
199
+ const own = explicitPort(script);
200
+ if (own) return own;
201
+ // The tool the script runs, after any VAR=value prefixes and npx.
202
+ const tool = script
203
+ .trim()
204
+ .replace(/^(?:[A-Z_][A-Z0-9_]*=\S*\s+)+/, "")
205
+ .replace(/^npx\s+(?:--yes\s+)?/, "");
206
+ return DEV_SERVER_PORTS.find(([re]) => re.test(tool))?.[1];
207
+ }
208
+
209
+ /** The health URL to draft: the dev server's own port, at `/`. */
210
+ export function draftHealthUrl(
211
+ repoPath: string,
212
+ c: Pick<DetectedCommands, "up">,
213
+ ): string | undefined {
214
+ const port = detectDevPort(repoPath, c);
215
+ return port ? `http://localhost:${port}/` : undefined;
216
+ }
217
+
160
218
  /** The side-car manifest for a repo Den detected. Validate before writing. */
161
219
  export function sidecarManifest(
162
220
  name: string,