@monster-orange/den 0.3.0 → 0.4.1

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 (79) hide show
  1. package/CHANGELOG.md +593 -0
  2. package/docs/distributions.md +429 -18
  3. package/package.json +6 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/default-pack/be-dev.md +20 -0
  7. package/src/shared/agents/default-pack/devops.md +14 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  10. package/src/shared/agents/default-pack/planner.md +17 -0
  11. package/src/shared/agents/default-pack/reviewer.md +14 -0
  12. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  13. package/src/shared/agents/den-claude.ts +271 -0
  14. package/src/shared/agents/generic.ts +52 -0
  15. package/src/shared/agents/personas.ts +218 -0
  16. package/src/shared/agents/records.ts +118 -0
  17. package/src/shared/agents/runtime.ts +199 -0
  18. package/src/shared/agents/runtimes.ts +54 -0
  19. package/src/shared/den-config.ts +116 -0
  20. package/src/shared/engine.ts +23 -0
  21. package/src/shared/environment-manager/scope.ts +22 -0
  22. package/src/shared/environment-manager/service-runner.ts +94 -2
  23. package/src/shared/environment-manager/workspaces.ts +19 -0
  24. package/src/shared/profile.ts +15 -4
  25. package/src/shared/secret-env.ts +70 -0
  26. package/src/shared/sessions/client.ts +284 -0
  27. package/src/shared/sessions/den-pty.ts +97 -0
  28. package/src/shared/sessions/env.ts +89 -0
  29. package/src/shared/sessions/host.ts +690 -0
  30. package/src/shared/sessions/names.ts +85 -0
  31. package/src/shared/sessions/paths.ts +27 -0
  32. package/src/shared/sessions/protocol.ts +72 -0
  33. package/src/shared/sessions/pty.ts +141 -0
  34. package/src/shared/sessions/ring-buffer.ts +160 -0
  35. package/src/shared/sessions/types.ts +81 -0
  36. package/src/shared/web-secret.ts +181 -0
  37. package/src/shared/web-session.ts +491 -0
  38. package/src/ui/cli/agent-ops.ts +843 -0
  39. package/src/ui/cli/agents-command.ts +294 -0
  40. package/src/ui/cli/doctor-command.ts +138 -9
  41. package/src/ui/cli/env-probe.ts +266 -0
  42. package/src/ui/cli/flag-parser.ts +32 -3
  43. package/src/ui/cli/index.ts +128 -12
  44. package/src/ui/cli/sessions-command.ts +750 -0
  45. package/src/ui/cli/target-workspace.ts +105 -0
  46. package/src/ui/cli/web-command.ts +536 -14
  47. package/src/ui/mcp/api.ts +7 -0
  48. package/src/ui/mcp/scope.ts +123 -19
  49. package/src/ui/mcp/server.ts +100 -7
  50. package/src/ui/mcp/tools.ts +44 -5
  51. package/src/ui/server/agent-routes.ts +323 -0
  52. package/src/ui/server/audit.ts +111 -22
  53. package/src/ui/server/http-errors.ts +21 -3
  54. package/src/ui/server/index.ts +820 -65
  55. package/src/ui/server/public/agents-page.js +648 -0
  56. package/src/ui/server/public/agents-section.js +299 -0
  57. package/src/ui/server/public/agents.html +439 -0
  58. package/src/ui/server/public/appearance.js +3 -0
  59. package/src/ui/server/public/error-page.js +103 -0
  60. package/src/ui/server/public/header.js +45 -11
  61. package/src/ui/server/public/i18n/es.js +90 -1
  62. package/src/ui/server/public/i18n/ja.js +86 -1
  63. package/src/ui/server/public/local-dev.html +53 -0
  64. package/src/ui/server/public/logs.html +3 -0
  65. package/src/ui/server/public/panel.js +1 -1
  66. package/src/ui/server/public/release.html +8 -0
  67. package/src/ui/server/public/settings-page.js +5 -0
  68. package/src/ui/server/public/settings-registry.js +33 -0
  69. package/src/ui/server/public/shell.js +248 -5
  70. package/src/ui/server/public/topbar.js +51 -2
  71. package/src/ui/server/readonly-routes.ts +25 -2
  72. package/src/ui/server/request-guard.ts +670 -0
  73. package/src/ui/server/route-classes.ts +317 -0
  74. package/src/ui/server/session-routes.ts +445 -0
  75. package/src/ui/server/shutdown-guard.ts +91 -0
  76. package/src/ui/server/sudo-store.ts +29 -3
  77. package/src/ui/server/ui-assets.ts +10 -1
  78. package/src/ui/server/websocket.ts +326 -0
  79. package/src/ui/server/workspace-routes.ts +16 -22
@@ -0,0 +1,266 @@
1
+ /**
2
+ * `<cli> doctor --probe` (AG spec §5a-env): check, through the real session
3
+ * host and backend, what environment a session actually gets. Opt-in: plain
4
+ * `doctor` never spawns anything.
5
+ *
6
+ * For a plain session (as `sessions start` builds it, no --keep-env) and
7
+ * for each available runtime's agent env (as `agents start` builds it, the
8
+ * runtime's launch env included, but running the probe instead of the
9
+ * runtime's CLI), it starts a short session running a tiny node script that
10
+ * prints each key name and one flag: whether the name or value is
11
+ * credential-shaped (secret-env.ts, with the redactor's patterns). Values
12
+ * never leave the probe process.
13
+ *
14
+ * A finding is any runtime's scrubEnv name, or any credential-shaped
15
+ * variable, in either env.
16
+ */
17
+ import * as fs from "fs";
18
+ import * as os from "os";
19
+ import * as path from "path";
20
+ import {
21
+ agentSessionEnv,
22
+ allScrubEnv,
23
+ getRuntime,
24
+ LaunchError,
25
+ runtimeIds,
26
+ type LaunchInput,
27
+ type Runtime,
28
+ } from "../../shared/agents/runtimes.js";
29
+ import { credentialPatternData } from "../../shared/secret-env.js";
30
+ import type { HostClient } from "../../shared/sessions/client.js";
31
+ import { matchesScrub } from "../../shared/sessions/env.js";
32
+ import { sessionName } from "../../shared/sessions/names.js";
33
+ import type { PtyLoad } from "../../shared/sessions/pty.js";
34
+
35
+ const MARK = "DENPROBE";
36
+ const PROBE_ID = "env-probe";
37
+ /** How long one probe session gets to print and exit. */
38
+ const PROBE_TIMEOUT_MS = 10_000;
39
+
40
+ /** The probe: key names and a 0/1 flag per key, then an end mark. No values. */
41
+ export function probeScript(): string {
42
+ const p = JSON.stringify(credentialPatternData());
43
+ return [
44
+ `const P = ${p};`,
45
+ `const re = (l) => l.map((r) => new RegExp(r.source, r.flags));`,
46
+ `const N = re(P.name), V = re(P.value);`,
47
+ `for (const k of Object.keys(process.env).sort()) {`,
48
+ ` const v = process.env[k] || "";`,
49
+ ` const c = N.some((r) => r.test(k)) || (v !== "" && V.some((r) => r.test(v)));`,
50
+ ` process.stdout.write("${MARK} " + k + " " + (c ? 1 : 0) + "\\n");`,
51
+ `}`,
52
+ `process.stdout.write("${MARK}-END\\n");`,
53
+ ].join("\n");
54
+ }
55
+
56
+ export interface ProbeKey {
57
+ name: string;
58
+ credentialShaped: boolean;
59
+ }
60
+
61
+ /** The probe's report from its output; null when it did not finish. */
62
+ export function parseProbe(output: string): ProbeKey[] | null {
63
+ const keys: ProbeKey[] = [];
64
+ let ended = false;
65
+ for (const raw of output.split(/\r?\n/)) {
66
+ const line = raw.replace(/\r/g, "").trim();
67
+ if (line === `${MARK}-END`) ended = true;
68
+ const m = new RegExp(`^${MARK} ([A-Za-z_][A-Za-z0-9_]*) ([01])$`).exec(
69
+ line,
70
+ );
71
+ if (m) keys.push({ name: m[1], credentialShaped: m[2] === "1" });
72
+ }
73
+ return ended ? keys : null;
74
+ }
75
+
76
+ export interface ProbeFinding {
77
+ key: string;
78
+ why: string;
79
+ }
80
+
81
+ export interface ProbeResult {
82
+ /** "plain session", or "agent (<runtime>)". */
83
+ target: string;
84
+ /** False: not probed (the reason says why); never a failure. */
85
+ ran: boolean;
86
+ reason?: string;
87
+ keys?: number;
88
+ findings: ProbeFinding[];
89
+ }
90
+
91
+ export interface ProbeDeps {
92
+ dist: string;
93
+ cli: string;
94
+ env: NodeJS.ProcessEnv;
95
+ pty: () => PtyLoad;
96
+ connect: () => Promise<HostClient>;
97
+ /** A plain session's env, as `sessions start` builds it. */
98
+ plainEnv: (caller: NodeJS.ProcessEnv) => Record<string, string>;
99
+ /** An agent's env, as `agents start` builds it. */
100
+ agentEnv?: (
101
+ caller: NodeJS.ProcessEnv,
102
+ rt: Runtime,
103
+ launch: Record<string, string>,
104
+ agentId: string,
105
+ ) => Record<string, string>;
106
+ /** Runtimes to probe (default: every registered one that is available). */
107
+ runtimes?: () => Runtime[];
108
+ }
109
+
110
+ /** A launch input for the probe: nothing is written, nothing it names runs. */
111
+ function probeInput(d: ProbeDeps, dir: string): LaunchInput {
112
+ return {
113
+ persona: {
114
+ id: PROBE_ID,
115
+ role: "",
116
+ prompt: "",
117
+ standing: false,
118
+ tools: [],
119
+ context: [],
120
+ argv: [PROBE_ID],
121
+ source: "doctor --probe",
122
+ },
123
+ workspace: { name: "doctor", dir },
124
+ agentId: PROBE_ID,
125
+ worktree: dir,
126
+ mcp: { name: d.cli, command: [d.cli], env: {}, scope: "doctor" },
127
+ // Never written: the probe uses only the launch's env.
128
+ filesDir: path.join(dir, PROBE_ID),
129
+ env: d.env,
130
+ };
131
+ }
132
+
133
+ function findings(keys: ProbeKey[]): ProbeFinding[] {
134
+ const scrub = allScrubEnv();
135
+ const out: ProbeFinding[] = [];
136
+ for (const k of keys) {
137
+ if (matchesScrub(k.name, scrub))
138
+ out.push({ key: k.name, why: "a runtime's scrubEnv name" });
139
+ else if (k.credentialShaped)
140
+ out.push({ key: k.name, why: "credential-shaped" });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** Run the probe in one session with `env`; its report, or why not. */
146
+ async function probeOnce(
147
+ d: ProbeDeps,
148
+ c: HostClient,
149
+ target: string,
150
+ slug: string,
151
+ env: Record<string, string>,
152
+ cwd: string,
153
+ ): Promise<ProbeResult> {
154
+ const info = await c.start({
155
+ name: sessionName(d.dist, "doctor", `${PROBE_ID}-${slug}`),
156
+ cwd,
157
+ argv: [process.execPath, "-e", probeScript()],
158
+ env,
159
+ cols: 200,
160
+ rows: 24,
161
+ });
162
+ try {
163
+ const deadline = Date.now() + PROBE_TIMEOUT_MS;
164
+ for (;;) {
165
+ const s = (await c.list()).find((x) => x.ref.id === info.ref.id);
166
+ if (!s || s.exited) break;
167
+ if (Date.now() > deadline) {
168
+ await c.kill(info.ref.id, "KILL").catch(() => undefined);
169
+ await new Promise((r) => setTimeout(r, 200));
170
+ break;
171
+ }
172
+ await new Promise((r) => setTimeout(r, 50));
173
+ }
174
+ const keys = parseProbe(
175
+ (await c.scrollback(info.ref.id, 1024 * 1024)).toString("utf-8"),
176
+ );
177
+ if (!keys)
178
+ return {
179
+ target,
180
+ ran: true,
181
+ findings: [{ key: "-", why: "the probe did not finish" }],
182
+ };
183
+ return { target, ran: true, keys: keys.length, findings: findings(keys) };
184
+ } finally {
185
+ await c.forget(info.ref.id).catch(() => undefined);
186
+ }
187
+ }
188
+
189
+ /** Probe a plain session and each available runtime's agent env. */
190
+ export async function runEnvProbe(d: ProbeDeps): Promise<ProbeResult[]> {
191
+ const pty = d.pty();
192
+ if (!pty.available)
193
+ return [
194
+ {
195
+ target: "sessions",
196
+ ran: false,
197
+ reason: `den-pty is unavailable: ${pty.reason}`,
198
+ findings: [],
199
+ },
200
+ ];
201
+ const runtimes =
202
+ d.runtimes?.() ??
203
+ runtimeIds()
204
+ .map((id) => getRuntime(id))
205
+ .filter((rt): rt is Runtime => !!rt);
206
+ const agentEnv = d.agentEnv ?? agentSessionEnv;
207
+ // The probe writes nothing: its cwd is the temp dir, as it is.
208
+ const dir = fs.realpathSync(os.tmpdir());
209
+ const c = await d.connect();
210
+ try {
211
+ const out: ProbeResult[] = [
212
+ await probeOnce(d, c, "plain session", "plain", d.plainEnv(d.env), dir),
213
+ ];
214
+ for (const rt of runtimes) {
215
+ const target = `agent (${rt.id})`;
216
+ const det = rt.detect(d.env);
217
+ if (!det.available) {
218
+ out.push({
219
+ target,
220
+ ran: false,
221
+ reason: `${rt.id} is unavailable: ${det.why ?? "unknown"}`,
222
+ findings: [],
223
+ });
224
+ continue;
225
+ }
226
+ let launch: Record<string, string>;
227
+ try {
228
+ launch = rt.launch(probeInput(d, dir)).env;
229
+ } catch (e) {
230
+ if (!(e instanceof LaunchError)) throw e;
231
+ out.push({ target, ran: false, reason: e.message, findings: [] });
232
+ continue;
233
+ }
234
+ out.push(
235
+ await probeOnce(
236
+ d,
237
+ c,
238
+ target,
239
+ rt.id,
240
+ agentEnv(d.env, rt, launch, PROBE_ID),
241
+ dir,
242
+ ),
243
+ );
244
+ }
245
+ return out;
246
+ } finally {
247
+ c.close();
248
+ }
249
+ }
250
+
251
+ /** One line per result, one per finding; key names only. */
252
+ export function formatProbe(results: ProbeResult[]): string[] {
253
+ const lines: string[] = [];
254
+ const width = Math.max(...results.map((r) => r.target.length));
255
+ for (const r of results) {
256
+ const t = r.target.padEnd(width);
257
+ if (!r.ran)
258
+ lines.push(` ${t} - not probed: ${r.reason ?? "unavailable"}`);
259
+ else if (!r.findings.length)
260
+ lines.push(` ${t} ✓ clean (${r.keys ?? 0} variables)`);
261
+ else
262
+ for (const f of r.findings)
263
+ lines.push(` ${t} ✗ ${f.key} reaches it (${f.why})`);
264
+ }
265
+ return lines;
266
+ }
@@ -77,10 +77,18 @@ const WORKSPACE_COMMANDS = new Set([
77
77
 
78
78
  /**
79
79
  * Commands that read `-w`/`--workspace` themselves, so it is left in their
80
- * args: `setup --plan -w a -w b` takes several, and `mcp --workspace <ws>`
81
- * scopes the MCP server (it never sets the CLI's workspace override).
80
+ * args: `setup --plan -w a -w b` takes several, `mcp --workspace <ws>`
81
+ * scopes the MCP server, and `sessions`/`agents` name a session's
82
+ * workspace. None of them sets the CLI's workspace override.
82
83
  */
83
- const OWN_WORKSPACE_FLAG_COMMANDS = new Set(["setup", "mcp"]);
84
+ const OWN_WORKSPACE_FLAG_COMMANDS = new Set([
85
+ "setup",
86
+ "mcp",
87
+ // A session's workspace is part of its name (AG-3a); -w never sets the
88
+ // CLI's workspace override here.
89
+ "sessions",
90
+ "agents",
91
+ ]);
84
92
 
85
93
  /** Whether `-w <name>` on this command requires `<name>` to be active. */
86
94
  export function workspaceFlagRequiresActive(command: string): boolean {
@@ -162,6 +170,27 @@ function validateFlagScopes(parsed: ParsedCliArgs): void {
162
170
  }
163
171
  }
164
172
 
173
+ /**
174
+ * Subcommands that print their own `--help`: the dispatcher's
175
+ * `<command> --help` must not answer for them (`web sessions --help` printed
176
+ * the general web help). web-command.ts's WEB_SUBCOMMANDS_WITH_HELP and
177
+ * mcp-connect.ts's MCP_CONNECT_SUBCOMMANDS are the same lists (a test
178
+ * checks).
179
+ */
180
+ export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
181
+ {
182
+ web: ["token", "login", "sessions", "logout"],
183
+ mcp: ["connect", "disconnect", "clients", "config"],
184
+ };
185
+
186
+ /** Whether `<command> <sub> --help` is the subcommand's own to answer. */
187
+ export function ownsSubcommandHelp(
188
+ command: string,
189
+ sub: string | undefined,
190
+ ): boolean {
191
+ return !!sub && (SUBCOMMAND_OWN_HELP[command] ?? []).includes(sub);
192
+ }
193
+
165
194
  // Dash-flags each mutation subcommand accepts (global flags — -w/--workspace,
166
195
  // -t/--tabs/--no-tabs, --skip-secrets — are consumed by parseCliArgs before
167
196
  // dispatch, and -h/--help is handled by the dispatcher). Commands not listed
@@ -193,6 +193,8 @@ import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
193
193
  import { cmdConfig } from "./config-command.js";
194
194
  import { cmdEdge } from "./edge-command.js";
195
195
  import { cmdDoctor } from "./doctor-command.js";
196
+ import { cmdSessions, SESSIONS_HELP } from "./sessions-command.js";
197
+ import { AGENTS_HELP, cmdAgents } from "./agents-command.js";
196
198
  import { cmdService } from "./service-command.js";
197
199
  import { cmdStack } from "./stack-command.js";
198
200
  import { cmdTheme } from "./theme-command.js";
@@ -213,10 +215,12 @@ import {
213
215
  import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
214
216
  import { cmdWeb, displayWebHelp } from "./web-command.js";
215
217
  import { cmdGraph } from "./graph-command.js";
218
+ import { resolveTargetWorkspace } from "./target-workspace.js";
216
219
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
217
220
  import {
218
221
  parseCliArgs,
219
222
  takeWorkspaceFlags,
223
+ ownsSubcommandHelp,
220
224
  unknownCommandFlags,
221
225
  workspaceFlagRequiresActive,
222
226
  type ParsedCliArgs,
@@ -309,19 +313,20 @@ const WORKSPACE_SUBCOMMANDS: {
309
313
 
310
314
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
311
315
  down: {
312
- usage: "down [repo] [flags]",
316
+ usage: "down [repo] [-w <workspace>] [flags]",
313
317
  lines: [
314
318
  "Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
315
319
  "derived closure, in reverse order), naming anything else that uses it.",
316
320
  "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
317
321
  "every down script runs — no already-stopped skip).",
318
- "Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
322
+ "Flags: -w/--workspace <ws> (that active workspace, not the focused one; focus stays) · -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
319
323
  ],
320
324
  },
321
325
  restart: {
322
- usage: "restart [service...] [-A <app>] [-n|--dry-run]",
326
+ usage: "restart [service...] [-w <workspace>] [-A <app>] [-n|--dry-run]",
323
327
  lines: [
324
328
  `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
329
+ "-w/--workspace <ws> acts in that active workspace instead of the focused one; focus stays.",
325
330
  ],
326
331
  },
327
332
  status: {
@@ -366,7 +371,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
366
371
  },
367
372
  setup: {
368
373
  usage:
369
- "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
374
+ "setup [service...] [-w <workspace>] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
370
375
  lines: [
371
376
  "Run 'make setup' once per system (clones missing managed repos first).",
372
377
  "Default targets MANAGED clones only — setup's codegen/installs",
@@ -376,6 +381,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
376
381
  "cloned, seeded or written (on a fresh machine: the blueprint's plan).",
377
382
  "It plans the focused workspace; -w <workspace> (repeatable) plans those,",
378
383
  "--all-active every active one. Repos only other workspaces hold are named.",
384
+ "Without --plan, -w <workspace> sets up that workspace's systems instead of the focused one's.",
379
385
  ],
380
386
  },
381
387
  org: {
@@ -459,10 +465,14 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
459
465
  ],
460
466
  },
461
467
  doctor: {
462
- usage: "doctor",
468
+ usage: "doctor [--probe]",
463
469
  lines: [
464
470
  "Each strategy kind (secrets, tls): the one selected, whether it was implicit,",
465
471
  "and whether it can work here (a PATH check; nothing is run or prompted).",
472
+ "--probe: start a short probe session (through the session host) for a",
473
+ "plain session and each available runtime's agent env; key names only.",
474
+ "Exit 1 when a runtime's scrubEnv name or a credential-shaped variable",
475
+ "reaches one. Without the PTY module it says unavailable, not failed.",
466
476
  ],
467
477
  },
468
478
  plugin: {
@@ -508,6 +518,8 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
508
518
  usage: "menu",
509
519
  lines: ["Open the interactive menu (pillar/module/env picker)."],
510
520
  },
521
+ sessions: SESSIONS_HELP,
522
+ agents: AGENTS_HELP,
511
523
  };
512
524
 
513
525
  function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
@@ -558,6 +570,8 @@ const TELEMETRY_COMMANDS = new Set<string>([
558
570
  "apps",
559
571
  "applications",
560
572
  "mcp",
573
+ "sessions",
574
+ "agents",
561
575
  "service",
562
576
  "theme",
563
577
  "plugin",
@@ -581,6 +595,10 @@ const CONFIG_FREE_COMMANDS = new Set<string>([
581
595
  "local-dev",
582
596
  "view-results",
583
597
  "down",
598
+ // The session host and its clients read no org data, and stopping or
599
+ // listing a session is never stranded by a config that fails (AG-3a).
600
+ "sessions",
601
+ "agents",
584
602
  ]);
585
603
 
586
604
  function configCheckedCommand(command: string): boolean {
@@ -780,10 +798,13 @@ class DenCLI {
780
798
 
781
799
  // `<command> -h/--help` → that command's help (every command supports it).
782
800
  // (`-h`/`--help` AS the command falls through to the switch → global help.)
801
+ // A subcommand that prints its own help (`web sessions --help`, `mcp
802
+ // connect --help`) is dispatched instead: its help wins.
783
803
  if (
784
804
  command &&
785
805
  !["-h", "--help", "-v", "--version"].includes(command) &&
786
- (ownArgs.includes("-h") || ownArgs.includes("--help"))
806
+ (ownArgs.includes("-h") || ownArgs.includes("--help")) &&
807
+ !ownsSubcommandHelp(command, ownArgs[1])
787
808
  ) {
788
809
  this.printCommandHelp(command, ownArgs[1]);
789
810
  return;
@@ -964,7 +985,7 @@ class DenCLI {
964
985
  break;
965
986
 
966
987
  case "doctor":
967
- process.exitCode = cmdDoctor(args.slice(1));
988
+ process.exitCode = await cmdDoctor(args.slice(1));
968
989
  break;
969
990
 
970
991
  case "service":
@@ -989,6 +1010,14 @@ class DenCLI {
989
1010
  cmdApps(args.slice(1));
990
1011
  break;
991
1012
 
1013
+ case "sessions":
1014
+ process.exitCode = await cmdSessions(args.slice(1));
1015
+ break;
1016
+
1017
+ case "agents":
1018
+ process.exitCode = await cmdAgents(args.slice(1));
1019
+ break;
1020
+
992
1021
  case "mcp": {
993
1022
  // Setup subcommands: register the server with agents.
994
1023
  const { MCP_CONNECT_SUBCOMMANDS, runMcpConnect } =
@@ -2709,6 +2738,10 @@ class DenCLI {
2709
2738
  "--manifest",
2710
2739
  `use legacy ${product().manifestYml} depends_on instead of the derived graph`,
2711
2740
  );
2741
+ row(
2742
+ "-w, --workspace <ws>",
2743
+ "act in that (active) workspace instead of the focused one; focus stays",
2744
+ );
2712
2745
  row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2713
2746
  row("-h, --help", "show this help");
2714
2747
  console.log();
@@ -2954,7 +2987,44 @@ class DenCLI {
2954
2987
  }
2955
2988
  }
2956
2989
 
2990
+ /**
2991
+ * With no `-w`: the workspace the named services are in, when it isn't
2992
+ * the focus (target-workspace.ts), set as this run's workspace. False
2993
+ * (exit 1, said why) when a name is in no active workspace, in several,
2994
+ * or the names are in different ones.
2995
+ */
2996
+ private pickTargetWorkspace(command: string, args: string[]): boolean {
2997
+ if (process.env.DEN_WORKSPACE_OVERRIDE) return true;
2998
+ const terminator = args.indexOf("--");
2999
+ const own = terminator === -1 ? args : args.slice(0, terminator);
3000
+ let rest: string[];
3001
+ try {
3002
+ const taken = takeAppFlag(own);
3003
+ if (taken.app) return true; // -A names an application, not services
3004
+ rest = taken.rest;
3005
+ } catch {
3006
+ return true; // the command itself reports a bad -A
3007
+ }
3008
+ const names = rest.filter((a) => !a.startsWith("-"));
3009
+ const r = resolveTargetWorkspace(names, product().cli, command);
3010
+ if (!r.ok) {
3011
+ console.error(chalk.red(`\n ✗ ${r.error}\n`));
3012
+ process.exitCode = 1;
3013
+ return false;
3014
+ }
3015
+ if (r.workspace) {
3016
+ process.env.DEN_WORKSPACE_OVERRIDE = r.workspace;
3017
+ console.log(
3018
+ chalk.gray(
3019
+ `\n ${names.join(", ")}: in workspace ${r.workspace} (not the focus)`,
3020
+ ),
3021
+ );
3022
+ }
3023
+ return true;
3024
+ }
3025
+
2957
3026
  private async cmdServiceUp(args: string[]): Promise<void> {
3027
+ if (!this.pickTargetWorkspace("up", args)) return;
2958
3028
  // Tokens after a `--` terminator belong to a child command — they must
2959
3029
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2960
3030
  const terminator = args.indexOf("--");
@@ -3079,6 +3149,7 @@ class DenCLI {
3079
3149
  }
3080
3150
 
3081
3151
  private async cmdServiceDown(args: string[]): Promise<void> {
3152
+ if (!this.pickTargetWorkspace("down", args)) return;
3082
3153
  // Tokens after a `--` terminator belong to a child command — they must
3083
3154
  // never be read as down's own flags (`den down -- -a` must NOT tear
3084
3155
  // down the whole workspace).
@@ -3149,6 +3220,7 @@ class DenCLI {
3149
3220
  }
3150
3221
 
3151
3222
  private async cmdServiceRestart(argv: string[]): Promise<void> {
3223
+ if (!this.pickTargetWorkspace("restart", argv)) return;
3152
3224
  const { app, rest: args } = takeAppFlag(argv);
3153
3225
  const dryRun = args.includes("--dry-run") || args.includes("-n");
3154
3226
  if (!dryRun) preflightSecrets();
@@ -4046,15 +4118,33 @@ class DenCLI {
4046
4118
  console.log();
4047
4119
  return;
4048
4120
  }
4049
- if (planWs.length || allActive) {
4121
+ if (planWs.length > 1 || allActive) {
4050
4122
  console.error(
4051
4123
  chalk.red(
4052
- `\n -w and --all-active go with --plan: setup itself works on the focused workspace.\n`,
4124
+ `\n More than one -w, and --all-active, go with --plan: setup itself works on one workspace (-w <workspace>, or the one the named repos are in, else the focus).\n`,
4053
4125
  ),
4054
4126
  );
4055
4127
  process.exitCode = 1;
4056
4128
  return;
4057
4129
  }
4130
+ if (planWs.length === 1) {
4131
+ const ws = planWs[0];
4132
+ if (!readWorkspacesFile().workspaces[ws]) {
4133
+ console.error(chalk.red(`\n Workspace "${ws}" does not exist\n`));
4134
+ process.exitCode = 1;
4135
+ return;
4136
+ }
4137
+ if (!isWorkspaceActive(ws)) {
4138
+ console.error(
4139
+ chalk.red(
4140
+ `\n Workspace "${ws}" is not active — run \`${product().cli} workspace activate ${ws}\` first\n`,
4141
+ ),
4142
+ );
4143
+ process.exitCode = 1;
4144
+ return;
4145
+ }
4146
+ process.env.DEN_WORKSPACE_OVERRIDE = ws;
4147
+ } else if (!this.pickTargetWorkspace("setup", args)) return;
4058
4148
 
4059
4149
  // Clone any managed repos that aren't on disk yet before reading manifests.
4060
4150
  this.cloneMissingManagedRepos();
@@ -4069,11 +4159,27 @@ class DenCLI {
4069
4159
  return;
4070
4160
  }
4071
4161
 
4162
+ // A name the listing doesn't spell the same way (an alias, a bare repo
4163
+ // listed only by subsystem) is matched as resolveService resolves it.
4164
+ const matches = (name: string, s: (typeof services)[number]): boolean => {
4165
+ if (name === s.address || name === s.repoName) return true;
4166
+ const r = resolveService(name);
4167
+ return !!r && (r.address === s.address || r.repoName === s.address);
4168
+ };
4169
+ const unmatched = names.filter((n) => !services.some((s) => matches(n, s)));
4170
+ if (unmatched.length > 0) {
4171
+ const ws = getActiveWorkspace()?.name ?? "default";
4172
+ console.error(
4173
+ chalk.red(
4174
+ `\n ✗ Not in workspace ${ws}: ${unmatched.join(", ")}. Nothing was set up.\n`,
4175
+ ),
4176
+ );
4177
+ process.exitCode = 1;
4178
+ return;
4179
+ }
4072
4180
  let targets =
4073
4181
  names.length > 0
4074
- ? services.filter(
4075
- (s) => names.includes(s.address) || names.includes(s.repoName),
4076
- )
4182
+ ? services.filter((s) => names.some((n) => matches(n, s)))
4077
4183
  : services;
4078
4184
 
4079
4185
  // Workspace-wide default: MANAGED clones only. `make setup` runs
@@ -4805,6 +4911,16 @@ class DenCLI {
4805
4911
  console.log(
4806
4912
  " plugin list Local and added plugins, checked (validate, add, update, remove)",
4807
4913
  );
4914
+ console.log(
4915
+ " sessions start -w <ws> --name <n> -- <argv…> A terminal that outlives the web server",
4916
+ );
4917
+ console.log(
4918
+ " sessions list|attach|kill List, attach to (ctrl-] detaches) or stop sessions",
4919
+ );
4920
+ console.log(" agents list -w <ws> The workspace's agents");
4921
+ console.log(
4922
+ " agents start -w <ws> --persona <p> Run a persona in the workspace (stop, resume, personas)",
4923
+ );
4808
4924
  console.log();
4809
4925
  console.log(
4810
4926
  " workspace list List workspaces (active set + focus)",