@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,199 @@
1
+ /**
2
+ * The runtime contract (strategy kind `runtime`, AG spec §4): which CLI an
3
+ * agent runs. A runtime turns a persona and a workspace into argv, env and
4
+ * files (a per-agent MCP config), and says how to resume. It knows nothing
5
+ * about sessions, tmux or the browser; Den joins it to a session backend.
6
+ *
7
+ * Built-ins register here exactly as a runtime plugin would (AG-8a):
8
+ * den-claude and generic.
9
+ */
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import {
13
+ getStrategy,
14
+ registerStrategy,
15
+ strategyIds,
16
+ type StrategyEntry,
17
+ } from "../strategies/registry.js";
18
+ import type { Persona } from "./personas.js";
19
+
20
+ export const RUNTIME_KIND = "runtime";
21
+
22
+ /** The runtime an agent gets when neither it nor its persona names one. */
23
+ export const DEFAULT_RUNTIME_ID = "den-claude";
24
+
25
+ export interface RuntimeDetect {
26
+ available: boolean;
27
+ version?: string;
28
+ /** Why it is unavailable. */
29
+ why?: string;
30
+ }
31
+
32
+ /** The MCP server an agent acts through: Den's, scoped to its workspace. */
33
+ export interface AgentMcp {
34
+ /** The server's name in an MCP config (the distribution's CLI). */
35
+ name: string;
36
+ /** `<cli> mcp --workspace <ws> --actor agent:<id> [--grant <ws>…]`. */
37
+ command: string[];
38
+ /** The server's own environment (PATH pinned), never a secret. */
39
+ env: Record<string, string>;
40
+ /** The workspace it is pinned to. */
41
+ scope: string;
42
+ }
43
+
44
+ export interface LaunchInput {
45
+ persona: Persona;
46
+ workspace: { name: string; dir: string };
47
+ agentId: string;
48
+ /** Where the agent works: the workspace's dir (a worktree from AG-9a). */
49
+ worktree: string;
50
+ model?: string;
51
+ mcp: AgentMcp;
52
+ /** A directory of this agent's own (0700) for the files a runtime writes. */
53
+ filesDir: string;
54
+ /**
55
+ * Den's own state dir (web.secret, the session records). A runtime adds
56
+ * deny rules for it to the agent's own permission config (SEC-1 §6c.1):
57
+ * that binds a cooperating agent only, since an agent runs as the same OS
58
+ * user and could read the files itself.
59
+ */
60
+ stateDir?: string;
61
+ /**
62
+ * The caller's environment, for finding the runtime's CLI on PATH. The
63
+ * agent does not get it: it starts from an allowlist (sessions/env.ts),
64
+ * plus envPassthrough and LaunchOutput.env.
65
+ */
66
+ env: NodeJS.ProcessEnv;
67
+ /**
68
+ * A conversation id to start under instead of a new one: a resume of a
69
+ * run that was never prompted starts afresh with the id it had.
70
+ */
71
+ resumeToken?: string;
72
+ }
73
+
74
+ export interface LaunchOutput {
75
+ argv: string[];
76
+ /** Added to the session's environment (after the allowlist). */
77
+ env: Record<string, string>;
78
+ /** Absolute path (inside filesDir) → content; Den writes each 0600. */
79
+ files?: Record<string, string>;
80
+ /** What resume needs to continue this run (kept in the agent record). */
81
+ resumeToken?: string;
82
+ /** One line each, printed when the agent starts (never secret). */
83
+ warnings?: string[];
84
+ }
85
+
86
+ export type ResumeInput = LaunchInput;
87
+
88
+ export interface Runtime {
89
+ /** "den-claude", "generic". */
90
+ id: string;
91
+ detect(env?: NodeJS.ProcessEnv): RuntimeDetect;
92
+ /** Throws LaunchError when this persona or this machine can't run it. */
93
+ launch(input: LaunchInput): LaunchOutput;
94
+ /** A new run that continues the last one; null when there is nothing to resume. */
95
+ resume?(input: ResumeInput): Omit<LaunchOutput, "resumeToken"> | null;
96
+ /** Where permission prompts surface (Q-AG-2). */
97
+ approvals?: "terminal" | "hook";
98
+ /**
99
+ * Caller variables this runtime needs beyond the allowlist (PATH, HOME,
100
+ * USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ, TMPDIR). Named
101
+ * one by one; Claude Code's session plumbing never passes.
102
+ */
103
+ envPassthrough?: readonly string[];
104
+ /**
105
+ * Names this runtime owns that must never reach a session Den starts
106
+ * (an exact name, or a prefix ending in `*`): den-claude's CLAUDECODE and
107
+ * CLAUDE_CODE_*. Den removes them after building any session's env,
108
+ * whatever put them there (sessions/env.ts).
109
+ */
110
+ scrubEnv: readonly string[];
111
+ }
112
+
113
+ /** A runtime cannot launch this agent here: a refusal, not a crash. */
114
+ export class LaunchError extends Error {
115
+ constructor(message: string) {
116
+ super(message);
117
+ this.name = "LaunchError";
118
+ }
119
+ }
120
+
121
+ /** Register a runtime as strategy kind `runtime` (doctor lists it). */
122
+ export function registerRuntime(rt: Runtime): void {
123
+ const entry: StrategyEntry<typeof RUNTIME_KIND, Runtime> = {
124
+ id: rt.id,
125
+ kind: RUNTIME_KIND,
126
+ detect(env) {
127
+ const d = rt.detect(env);
128
+ if (!d.available)
129
+ return { available: false, reason: d.why ?? "unavailable" };
130
+ return d.version
131
+ ? { available: true, reason: `version ${d.version}` }
132
+ : { available: true };
133
+ },
134
+ capabilities: rt,
135
+ };
136
+ registerStrategy(entry);
137
+ }
138
+
139
+ export function getRuntime(id: string): Runtime | undefined {
140
+ return getStrategy<typeof RUNTIME_KIND, Runtime>(RUNTIME_KIND, id)
141
+ ?.capabilities;
142
+ }
143
+
144
+ export function runtimeIds(): string[] {
145
+ return strategyIds(RUNTIME_KIND);
146
+ }
147
+
148
+ /** Every registered runtime's scrubEnv: applied to every session Den starts. */
149
+ export function allScrubEnv(): string[] {
150
+ return [
151
+ ...new Set(runtimeIds().flatMap((id) => getRuntime(id)?.scrubEnv ?? [])),
152
+ ];
153
+ }
154
+
155
+ /** The first executable `bin` on `env.PATH`, or null. Runs nothing. */
156
+ export function whichBin(
157
+ bin: string,
158
+ env: NodeJS.ProcessEnv = process.env,
159
+ ): string | null {
160
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
161
+ if (!dir) continue;
162
+ const file = path.join(dir, bin);
163
+ try {
164
+ fs.accessSync(file, fs.constants.X_OK);
165
+ if (fs.statSync(file).isFile()) return file;
166
+ } catch {
167
+ /* not here */
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * A standard MCP config (`mcpServers`, the shape most MCP clients read)
175
+ * with Den's server as the only entry.
176
+ */
177
+ export function mcpConfigJson(mcp: AgentMcp): string {
178
+ const [command, ...args] = mcp.command;
179
+ return (
180
+ JSON.stringify(
181
+ {
182
+ mcpServers: {
183
+ [mcp.name]: {
184
+ command,
185
+ args,
186
+ ...(Object.keys(mcp.env).length ? { env: mcp.env } : {}),
187
+ },
188
+ },
189
+ },
190
+ null,
191
+ 2,
192
+ ) + "\n"
193
+ );
194
+ }
195
+
196
+ /** Where a runtime puts an agent's MCP config. */
197
+ export function mcpConfigPath(input: Pick<LaunchInput, "filesDir">): string {
198
+ return path.join(input.filesDir, "mcp.json");
199
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Den's built-in runtimes, registered as strategy kind `runtime`: import
3
+ * this module to have them. den-claude first: it is the default.
4
+ */
5
+ import { denClaude } from "./den-claude.js";
6
+ import { generic } from "./generic.js";
7
+ import { allScrubEnv, registerRuntime, type Runtime } from "./runtime.js";
8
+ import { ACTOR_TOKEN_ENV } from "../actor-token.js";
9
+ import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
10
+
11
+ registerRuntime(denClaude);
12
+ registerRuntime(generic);
13
+
14
+ export {
15
+ allScrubEnv,
16
+ DEFAULT_RUNTIME_ID,
17
+ getRuntime,
18
+ LaunchError,
19
+ RUNTIME_KIND,
20
+ runtimeIds,
21
+ type AgentMcp,
22
+ type LaunchInput,
23
+ type LaunchOutput,
24
+ type ResumeInput,
25
+ type Runtime,
26
+ } from "./runtime.js";
27
+
28
+ /**
29
+ * An agent's session env, as `agents start` builds it and `doctor --probe`
30
+ * checks it: the allowlist, the runtime's passthrough and launch env, Den's
31
+ * markers (DEN_AGENT=agent:<id>, and SEC-1c's DEN_ACTOR_TOKEN when one was
32
+ * minted), then every runtime's scrubEnv and any person credential.
33
+ */
34
+ export function agentSessionEnv(
35
+ caller: NodeJS.ProcessEnv,
36
+ rt: Pick<Runtime, "envPassthrough">,
37
+ launch: Record<string, string>,
38
+ agentId: string,
39
+ /** SEC-1c: the agent's actor token (`agents start` mints it; the probe has none). */
40
+ actorToken?: string,
41
+ ): Record<string, string> {
42
+ return buildSessionEnv({
43
+ caller,
44
+ passthrough: rt.envPassthrough,
45
+ launch,
46
+ // The seam for a persona's or runtime's declared env: none yet.
47
+ extra: {},
48
+ den: {
49
+ [AGENT_ACTOR_ENV]: `agent:${agentId}`,
50
+ ...(actorToken ? { [ACTOR_TOKEN_ENV]: actorToken } : {}),
51
+ },
52
+ scrub: allScrubEnv(),
53
+ });
54
+ }
@@ -243,6 +243,11 @@ export interface DenConfig {
243
243
  * only; `actions` opts in to named actions. See readMcpConfig.
244
244
  */
245
245
  mcp?: McpConfig;
246
+ /**
247
+ * Agents: `max_concurrent` caps the sessions running at once in this
248
+ * distribution (default 4). See readAgentsConfig.
249
+ */
250
+ agents?: AgentsConfig;
246
251
  /**
247
252
  * The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
248
253
  * the launchd service `<cli> service` installs. See readWebServiceConfig.
@@ -552,6 +557,7 @@ export function readDenConfig(): DenConfig {
552
557
  env_health_urls: parsed?.env_health_urls,
553
558
  security: parsed?.security,
554
559
  mcp: parsed?.mcp,
560
+ agents: parsed?.agents,
555
561
  web: parsed?.web,
556
562
  test_accounts: parsed?.test_accounts,
557
563
  defaults: parsed?.defaults,
@@ -729,6 +735,116 @@ export function readMcpConfig(
729
735
  };
730
736
  }
731
737
 
738
+ // ── Agents (`<cli> sessions`, `<cli> agents`) ─────────────────────────────────
739
+
740
+ /** `agents:` in the org config. */
741
+ export interface AgentsConfig {
742
+ /** Sessions that may run at once (default 4). */
743
+ max_concurrent?: number;
744
+ /**
745
+ * Persona packs (AG-2a): directories of persona files, relative to the
746
+ * distribution root. Den's default pack is always read first; a pack's
747
+ * persona replaces one of the same id.
748
+ */
749
+ persona_packs?: string[];
750
+ /**
751
+ * Any other key is an agent id: `agents.<id>.grants` widens that agent's
752
+ * MCP scope to other workspaces (AG spec §5b). Config only: an agent
753
+ * never grants itself one.
754
+ */
755
+ [agentId: string]: unknown;
756
+ }
757
+
758
+ export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
759
+
760
+ /** The keys of `agents:` that are not an agent id. */
761
+ export const AGENTS_RESERVED_KEYS = ["max_concurrent", "persona_packs"];
762
+
763
+ /** One agent's own block, `agents.<id>`. */
764
+ export interface AgentGrantsConfig {
765
+ /** Workspaces beyond its own that its MCP serves. */
766
+ grants: string[];
767
+ }
768
+
769
+ /** `agents:` with its defaults applied. */
770
+ export interface ResolvedAgentsConfig {
771
+ maxConcurrent: number;
772
+ /** As configured (relative to the distribution root). */
773
+ personaPacks: string[];
774
+ /** `agents.<id>` blocks, by agent id. */
775
+ agents: Record<string, AgentGrantsConfig>;
776
+ }
777
+
778
+ /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
779
+ const AGENT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
780
+
781
+ const isMapping = (v: unknown): v is Record<string, unknown> =>
782
+ v !== null && typeof v === "object" && !Array.isArray(v);
783
+
784
+ /**
785
+ * The effective agents config. Anything malformed throws, naming the key:
786
+ * a typo must not lift the cap, drop a pack or widen a grant.
787
+ */
788
+ export function readAgentsConfig(
789
+ config: DenConfig = readDenConfig(),
790
+ ): ResolvedAgentsConfig {
791
+ const raw = (config.agents ?? {}) as unknown;
792
+ if (!isMapping(raw))
793
+ throw new Error(`agents: must be a mapping (got ${JSON.stringify(raw)})`);
794
+ const out: ResolvedAgentsConfig = {
795
+ maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
796
+ personaPacks: [],
797
+ agents: {},
798
+ };
799
+ const max = raw.max_concurrent;
800
+ if (max !== undefined) {
801
+ if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
802
+ throw new Error(
803
+ `agents.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
804
+ );
805
+ out.maxConcurrent = max;
806
+ }
807
+ const packs = raw.persona_packs;
808
+ if (packs !== undefined && packs !== null) {
809
+ if (!Array.isArray(packs))
810
+ throw new Error(
811
+ `agents.persona_packs: must be a list of paths (got ${JSON.stringify(packs)})`,
812
+ );
813
+ packs.forEach((p, i) => {
814
+ if (typeof p !== "string" || !p.trim())
815
+ throw new Error(
816
+ `agents.persona_packs[${i}]: must be a non-empty path (got ${JSON.stringify(p)})`,
817
+ );
818
+ });
819
+ out.personaPacks = packs as string[];
820
+ }
821
+ for (const [id, block] of Object.entries(raw)) {
822
+ if (AGENTS_RESERVED_KEYS.includes(id)) continue;
823
+ const where = `agents.${id}`;
824
+ if (!AGENT_KEY_RE.test(id) || !isMapping(block))
825
+ throw new Error(
826
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
827
+ `or an agent id mapping to { grants: [...] })`,
828
+ );
829
+ for (const k of Object.keys(block))
830
+ if (k !== "grants")
831
+ throw new Error(`${where}.${k}: unknown key (an agent takes grants)`);
832
+ const grants = block.grants ?? [];
833
+ if (!Array.isArray(grants))
834
+ throw new Error(
835
+ `${where}.grants: must be a list of workspace names (got ${JSON.stringify(grants)})`,
836
+ );
837
+ grants.forEach((g, i) => {
838
+ if (typeof g !== "string" || !AGENT_KEY_RE.test(g))
839
+ throw new Error(
840
+ `${where}.grants[${i}]: ${JSON.stringify(g)} is not a workspace name`,
841
+ );
842
+ });
843
+ out.agents[id] = { grants: [...new Set(grants as string[])] };
844
+ }
845
+ return out;
846
+ }
847
+
732
848
  // ── Web UI service (`<cli> service`) ──────────────────────────────────────────
733
849
 
734
850
  /** `web:` in the org config. */
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { pathToFileURL } from "node:url";
16
17
  import { getDistRoot, getEngineRoot } from "./paths.js";
17
18
 
18
19
  /** An absolute path inside Den's own package. */
@@ -83,3 +84,25 @@ export function distPath(...parts: string[]): string {
83
84
  export function playwrightCommand(...args: string[]): EngineCommand {
84
85
  return { command: engineBin("playwright"), args };
85
86
  }
87
+
88
+ /**
89
+ * How to run Den's CLI as a background process of its own, whatever process
90
+ * asks (the CLI, the web server): one node process, never the tsx CLI (a
91
+ * parent that relays signals). Live TypeScript source with tsx's loader in
92
+ * a checkout or a package install; the compiled build when tsx is absent,
93
+ * or with DEN_USE_DIST=1. Mirrors the web server's resolveServerEntrypoint.
94
+ */
95
+ export function cliEntrypoint(engine: string = getEngineRoot()): EngineCommand {
96
+ const built = path.join(engine, "dist/src/ui/cli/index.js");
97
+ if (process.env.DEN_USE_DIST === "1" && fs.existsSync(built))
98
+ return { command: process.execPath, args: [built] };
99
+ const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
100
+ const src = path.join(engine, "src/ui/cli/index.ts");
101
+ if (loader && fs.existsSync(src))
102
+ return {
103
+ command: process.execPath,
104
+ args: ["--import", pathToFileURL(loader).href, src],
105
+ };
106
+ if (fs.existsSync(built)) return { command: process.execPath, args: [built] };
107
+ return { command: engineBin("tsx", engine), args: [src] };
108
+ }
@@ -82,6 +82,28 @@ export function scopeView(scope?: ViewScope): ScopeView {
82
82
  };
83
83
  }
84
84
 
85
+ /**
86
+ * WS-6.4: what health covers, whatever is in view: every active workspace,
87
+ * focused first. Scope only filters what is displayed (inViewMarker); it
88
+ * never narrows what is polled, so a workspace outside it still shows its
89
+ * real health on the Workspaces page.
90
+ */
91
+ export function pollView(): ScopeView {
92
+ return scopeView(ALL_ACTIVE);
93
+ }
94
+
95
+ /**
96
+ * Whether a health row is in view: one of the workspaces that define it
97
+ * (`workspaces`, its owners) is in `view`. A row without owners counts as
98
+ * in view.
99
+ */
100
+ export function inViewMarker(
101
+ view: ScopeView,
102
+ ): (owners: readonly string[] | undefined) => boolean {
103
+ const names = new Set(view.workspaces.map((w) => w.name));
104
+ return (owners) => !owners?.length || owners.some((o) => names.has(o));
105
+ }
106
+
85
107
  /** A cache key for what is in view (the scope's workspaces, in order). */
86
108
  export function scopeKey(view: ScopeView): string {
87
109
  return view.workspaces.map((w) => w.name).join(",");
@@ -400,6 +400,70 @@ export function findDaemonByAddress(address: string): DaemonRecord | undefined {
400
400
  return _daemonRegistry.get(address);
401
401
  }
402
402
 
403
+ /**
404
+ * The other spelling of a single-service repo's daemon: "<repo>" and
405
+ * "<repo>/<svc>" name the same process (0.4.1-dev.5, a consumer's dev.4
406
+ * finding: `up <repo>` recorded "<repo>", `restart <repo>/<svc>` found no
407
+ * record, adopted the running process instead of restarting it, and left
408
+ * two records on one process group). Null for a repo with several
409
+ * services: each of its services is its own daemon.
410
+ */
411
+ function singleServiceAlias(
412
+ resolved: Pick<ResolvedService, "address" | "repoName" | "subsystem"> & {
413
+ repoPath: string;
414
+ },
415
+ ): string | null {
416
+ const keys = Object.keys(readManifest(resolved.repoPath)?.services ?? {});
417
+ if (keys.length !== 1 || keys[0] !== resolved.subsystem) return null;
418
+ const full = `${resolved.repoName}/${resolved.subsystem}`;
419
+ if (resolved.address === resolved.repoName) return full;
420
+ if (resolved.address === full) return resolved.repoName;
421
+ return null;
422
+ }
423
+
424
+ /**
425
+ * Bring a single-service repo's record to the address this command uses,
426
+ * so every lookup by `resolved.address` finds it whichever spelling wrote
427
+ * it. A record only under the other spelling moves; a duplicate (two
428
+ * records on one process group, the dev.4 bug's leftover, or one of them
429
+ * dead or without a group) is merged into one, owners and containers
430
+ * unioned. Two records on two different live groups are left alone.
431
+ */
432
+ export function reconcileDaemonAddress(resolved: ResolvedService): void {
433
+ const alias = singleServiceAlias(resolved);
434
+ if (!alias) return;
435
+ loadDaemonsFromFile();
436
+ const other = _daemonRegistry.get(alias);
437
+ if (!other || other.repoPath !== resolved.repoPath) return;
438
+ const own = _daemonRegistry.get(resolved.address);
439
+ if (own) {
440
+ const sameGroup =
441
+ own.pgid === undefined ||
442
+ other.pgid === undefined ||
443
+ own.pgid === other.pgid ||
444
+ isDaemonVerifiablyDead(other);
445
+ if (!sameGroup) return;
446
+ }
447
+ // The record carrying a process group is the one `down` can stop.
448
+ const keep =
449
+ own && (own.pgid !== undefined || other.pgid === undefined) ? own : other;
450
+ const drop = keep === own ? other : own;
451
+ const merged: DaemonRecord = {
452
+ ...keep,
453
+ address: resolved.address,
454
+ workspaces: [
455
+ ...new Set([...(keep.workspaces ?? []), ...(drop?.workspaces ?? [])]),
456
+ ],
457
+ };
458
+ const containers = [
459
+ ...new Set([...(keep.containers ?? []), ...(drop?.containers ?? [])]),
460
+ ];
461
+ if (containers.length > 0) merged.containers = containers;
462
+ _daemonRegistry.delete(alias);
463
+ _daemonRegistry.set(resolved.address, merged);
464
+ saveDaemonsFile(getDaemonsFile());
465
+ }
466
+
403
467
  /** Add a workspace owner to a running daemon (share / attach). */
404
468
  export function attachDaemonOwner(address: string, workspace: string): void {
405
469
  loadDaemonsFromFile();
@@ -1344,6 +1408,12 @@ export interface RunCommandOptions {
1344
1408
  explicit?: boolean;
1345
1409
  /** For `down`: what it found to stop (see DownOutcome). */
1346
1410
  onDownOutcome?: (outcome: DownOutcome) => void;
1411
+ /**
1412
+ * Internal: this `up` is a restart's up step ("up"), or the up step of
1413
+ * the restart a restart ran after adopting a process ("after-adopt").
1414
+ * A restart never ends on an adoption: what it adopts it restarts.
1415
+ */
1416
+ restartStep?: "up" | "after-adopt";
1347
1417
  }
1348
1418
 
1349
1419
  /**
@@ -1506,6 +1576,7 @@ function settleDaemonRecord(
1506
1576
  record: DaemonRecord | undefined;
1507
1577
  outcome: "adopted" | "dropped" | "busy" | null;
1508
1578
  } {
1579
+ reconcileDaemonAddress(resolved);
1509
1580
  const record = findDaemonByAddress(resolved.address);
1510
1581
  if (!record || !isLegacyRecord(record)) return { record, outcome: null };
1511
1582
  const outcome = settleLegacyRecord(record, resolved, workspace, onLine);
@@ -1674,7 +1745,12 @@ function restartAsDownThenUp(
1674
1745
  options.onClose(code);
1675
1746
  return;
1676
1747
  }
1677
- const up = runServiceCommand({ ...options, command: "up" });
1748
+ const up = runServiceCommand({
1749
+ ...options,
1750
+ command: "up",
1751
+ restartStep:
1752
+ options.restartStep === "after-adopt" ? "after-adopt" : "up",
1753
+ });
1678
1754
  tail = up.proc;
1679
1755
  if (stopTail) {
1680
1756
  try {
@@ -2052,6 +2128,9 @@ export function runServiceCommand(
2052
2128
  }
2053
2129
 
2054
2130
  const { repoPath, subsystem } = resolved;
2131
+ // A single-service repo's record answers to "<repo>" and "<repo>/<svc>":
2132
+ // every decision below reads it under this command's spelling.
2133
+ reconcileDaemonAddress(resolved);
2055
2134
 
2056
2135
  const explicit = options.explicit ?? true;
2057
2136
  const reportDown = (outcome: DownOutcome): void =>
@@ -2217,6 +2296,16 @@ export function runServiceCommand(
2217
2296
  `${resolved.address} not started`,
2218
2297
  );
2219
2298
  if (holder === "adopted") {
2299
+ // A restart's up step (its down found nothing recorded): the
2300
+ // adopted process is the old instance, so it is restarted now,
2301
+ // never left running as if restarted (0.4.1-dev.5).
2302
+ if (options.restartStep === "up") {
2303
+ onLine(`restarting the adopted process for ${resolved.address}`);
2304
+ return restartAsDownThenUp(
2305
+ { ...options, restartStep: "after-adopt" },
2306
+ getServiceLogPath(resolved.repoName, resolved.subsystem),
2307
+ );
2308
+ }
2220
2309
  closeAsync(0);
2221
2310
  return dummy();
2222
2311
  }
@@ -2355,7 +2444,10 @@ export function runServiceCommand(
2355
2444
  resolved.repoName,
2356
2445
  workspace,
2357
2446
  );
2358
- let cmdDef = svcCommands[command];
2447
+ // Own properties only (SEC-1 §6b.2): `constructor` is no command.
2448
+ let cmdDef = Object.prototype.hasOwnProperty.call(svcCommands, command)
2449
+ ? svcCommands[command]
2450
+ : undefined;
2359
2451
  const requestedCmdDef = cmdDef;
2360
2452
 
2361
2453
  // `restart` fallback: synthesise `make down && make up` when no explicit
@@ -325,6 +325,25 @@ export function writeWorkspacesFile(file: WorkspacesFile): void {
325
325
  }
326
326
  }
327
327
 
328
+ /**
329
+ * A workspace's checkout directory: its `directory` (relative to the
330
+ * distribution root), else den-workspaces/<name>, else the legacy
331
+ * <reposRoot>/<name>, else the repos root itself. What /api/workspaces
332
+ * reports as resolvedDirs, and where an agent of the workspace runs.
333
+ */
334
+ export function resolveWorkspaceDir(
335
+ name: string,
336
+ ws: Pick<WorkspaceConfig, "directory">,
337
+ reposRoot: string,
338
+ ): string {
339
+ if (ws.directory) return path.resolve(ROOT, ws.directory);
340
+ const gwDir = path.join(DEN_WORKSPACES_DIR, name);
341
+ if (fs.existsSync(gwDir)) return gwDir;
342
+ const legacyDir = path.join(reposRoot, name);
343
+ if (fs.existsSync(legacyDir)) return legacyDir;
344
+ return reposRoot;
345
+ }
346
+
328
347
  export function getWorkspaceByName(name: string): {
329
348
  name: string;
330
349
  config: WorkspaceConfig;
@@ -70,8 +70,16 @@ const expandHome = (p: string): string =>
70
70
  ? path.join(os.homedir(), p.slice(2))
71
71
  : p;
72
72
 
73
- /** A profile from a `product:` block; unset fields derive from the name. */
74
- export function profileFrom(raw: RawProduct = {}): ProductProfile {
73
+ /**
74
+ * A profile from a `product:` block; unset fields derive from the name. A
75
+ * relative `state_dir` resolves against `baseDir`: the directory holding
76
+ * den.yml (the distribution root), never the cwd, so an agent's CLI or MCP
77
+ * server started elsewhere reads the same state.
78
+ */
79
+ export function profileFrom(
80
+ raw: RawProduct = {},
81
+ baseDir: string = process.cwd(),
82
+ ): ProductProfile {
75
83
  const name = str(raw.name) ?? "den";
76
84
  if (!SLUG.test(name))
77
85
  throw new Error(
@@ -91,7 +99,10 @@ export function profileFrom(raw: RawProduct = {}): ProductProfile {
91
99
  envPrefix,
92
100
  configFile: str(raw.config) ?? `config/${name}.yml`,
93
101
  manifestFiles: json === manifest ? [manifest] : [manifest, json],
94
- stateDir: path.resolve(expandHome(str(raw.state_dir) ?? `~/.${name}`)),
102
+ stateDir: path.resolve(
103
+ baseDir,
104
+ expandHome(str(raw.state_dir) ?? `~/.${name}`),
105
+ ),
95
106
  servicesDir: str(raw.services_dir) ?? `.${name}-services`,
96
107
  workspacesDir: str(raw.workspaces_dir) ?? `${name}-workspaces`,
97
108
  };
@@ -106,7 +117,7 @@ export function readProfileFile(file: string): ProductProfile {
106
117
  if ((e as NodeJS.ErrnoException).code === "ENOENT") return profileFrom();
107
118
  throw new Error(`${file}: ${(e as Error).message}`);
108
119
  }
109
- return profileFrom(raw?.product ?? {});
120
+ return profileFrom(raw?.product ?? {}, path.dirname(path.resolve(file)));
110
121
  }
111
122
 
112
123
  let cached: { key: string; profile: ProductProfile } | null = null;