@monster-orange/den 0.2.5 → 0.2.7

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 (46) hide show
  1. package/CHANGELOG.md +215 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +202 -22
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/pillars/security/discover.ts +1 -1
  7. package/src/pillars/smoke/setup-global.ts +2 -1
  8. package/src/scripts/configure-aws-profiles.ts +2 -1
  9. package/src/scripts/register-repos.ts +4 -3
  10. package/src/shared/den-config.ts +38 -1
  11. package/src/shared/environment-manager/catalog.ts +9 -2
  12. package/src/shared/environment-manager/default-workspace.ts +92 -14
  13. package/src/shared/environment-manager/health.ts +96 -17
  14. package/src/shared/environment-manager/service-runner.ts +51 -5
  15. package/src/shared/environment-manager/workspace-manager.ts +2 -1
  16. package/src/shared/environment-manager/workspace-org.ts +264 -0
  17. package/src/shared/manifest/placement.ts +1 -1
  18. package/src/shared/manifest/schema.ts +47 -0
  19. package/src/shared/manifest/service-links.ts +1 -1
  20. package/src/shared/manifest/validate-manifest.ts +1 -1
  21. package/src/shared/plural.ts +13 -0
  22. package/src/shared/service-url.ts +2 -5
  23. package/src/shared/stacks/compose-health.ts +106 -14
  24. package/src/shared/stacks/runtime.ts +2 -0
  25. package/src/shared/types/index.ts +20 -1
  26. package/src/ui/cli/clone-plan.ts +243 -0
  27. package/src/ui/cli/create.ts +2 -1
  28. package/src/ui/cli/flag-parser.ts +1 -1
  29. package/src/ui/cli/index.ts +274 -61
  30. package/src/ui/cli/mcp-connect.ts +36 -5
  31. package/src/ui/cli/org-command.ts +162 -0
  32. package/src/ui/cli/results-viewer.ts +5 -5
  33. package/src/ui/cli/server-identity.ts +233 -0
  34. package/src/ui/cli/service-command.ts +136 -12
  35. package/src/ui/cli/spinner.ts +48 -0
  36. package/src/ui/cli/table.ts +20 -0
  37. package/src/ui/cli/validate-all.ts +171 -0
  38. package/src/ui/cli/web-command.ts +76 -25
  39. package/src/ui/mcp/api.ts +94 -1
  40. package/src/ui/mcp/server.ts +30 -2
  41. package/src/ui/mcp/tools.ts +6 -2
  42. package/src/ui/server/daemon-reaper.ts +22 -4
  43. package/src/ui/server/health-row.ts +6 -3
  44. package/src/ui/server/index.ts +63 -18
  45. package/src/ui/server/release-service.ts +16 -3
  46. package/src/ui/server/workspace-routes.ts +4 -1
package/src/ui/mcp/api.ts CHANGED
@@ -3,8 +3,19 @@
3
3
  * server holds no state of its own: agents and the web UI share the server's
4
4
  * state and its guards. This client can reach only the routes below, and it
5
5
  * sends the agent's audit headers (see shared/agent-actor.ts) on every call.
6
+ *
7
+ * With `identity`, it first makes sure the server on the port is this
8
+ * distribution's (server-identity.ts): another distribution's server (or an
9
+ * older Den from another root) on the configured port gets no request, and
10
+ * every tool says whose server it is instead of serving its data.
6
11
  */
7
12
  import { product } from "../../shared/product.js";
13
+ import {
14
+ foreignServerMessage,
15
+ identifyServer,
16
+ type OwnServer,
17
+ type ServerIdentity,
18
+ } from "../cli/server-identity.js";
8
19
  import { LIFECYCLE_CMDS, Refusal } from "./guards.js";
9
20
 
10
21
  export type Query = Record<string, string | string[] | undefined>;
@@ -20,6 +31,47 @@ export class DenWebDown extends Error {
20
31
  }
21
32
  }
22
33
 
34
+ /** Another distribution's server answers on the port: no data from it. */
35
+ export class ForeignWebServer extends Error {
36
+ constructor(
37
+ readonly port: number,
38
+ readonly who: string,
39
+ ) {
40
+ super(
41
+ `${foreignServerMessage(port, who)}; not this distribution's server. ` +
42
+ `Nothing was read from it. Point web.port (or --port) at this distribution's server.`,
43
+ );
44
+ this.name = "ForeignWebServer";
45
+ }
46
+ }
47
+
48
+ /** Something answers on the port but would not say whose server it is. */
49
+ export class UnidentifiedWebServer extends Error {
50
+ constructor(readonly port: number) {
51
+ super(
52
+ `the server on port ${port} did not answer /api/server-info in time, ` +
53
+ `so it is not known to be this distribution's; nothing was read from it. Try again.`,
54
+ );
55
+ this.name = "UnidentifiedWebServer";
56
+ }
57
+ }
58
+
59
+ /** How long a positive identity check is trusted. */
60
+ export const IDENTITY_TTL_MS = 30_000;
61
+ /** The identity probe's budget: longer than the CLI's, a busy server is ours too. */
62
+ export const IDENTITY_TIMEOUT_MS = 5_000;
63
+
64
+ export interface IdentityCheck {
65
+ /** This distribution: its root and product. */
66
+ own: OwnServer;
67
+ /** Default identifyServer. */
68
+ identify?: (port: number, own: OwnServer) => Promise<ServerIdentity>;
69
+ /** Default IDENTITY_TTL_MS. */
70
+ ttlMs?: number;
71
+ /** Default Date.now. */
72
+ now?: () => number;
73
+ }
74
+
23
75
  /** The web server answered with an error status. */
24
76
  export class DenHttpError extends Error {
25
77
  constructor(
@@ -157,8 +209,44 @@ export function createDenApi(opts: {
157
209
  baseUrl: string;
158
210
  cli: string;
159
211
  headers: () => Record<string, string>;
212
+ /** Check whose server it is before any request (the MCP server does). */
213
+ identity?: IdentityCheck;
160
214
  }): DenApi {
161
215
  const { baseUrl, cli } = opts;
216
+ const { hostname } = new URL(baseUrl);
217
+ const port = Number(new URL(baseUrl).port) || 80;
218
+ let vouchedAt: number | null = null;
219
+ let checking: Promise<void> | null = null;
220
+
221
+ /** Throws unless the server on the port is ours (cached briefly). */
222
+ async function ensureOurs(): Promise<void> {
223
+ const check = opts.identity;
224
+ if (!check) return;
225
+ const now = check.now ?? Date.now;
226
+ if (
227
+ vouchedAt !== null &&
228
+ now() - vouchedAt < (check.ttlMs ?? IDENTITY_TTL_MS)
229
+ )
230
+ return;
231
+ checking ??= (async () => {
232
+ const id = await (
233
+ check.identify ??
234
+ ((p: number, o: OwnServer) =>
235
+ identifyServer(p, o, IDENTITY_TIMEOUT_MS, hostname))
236
+ )(port, check.own);
237
+ if (id.kind === "ours") {
238
+ vouchedAt = now();
239
+ return;
240
+ }
241
+ vouchedAt = null;
242
+ if (id.kind === "foreign") throw new ForeignWebServer(port, id.who);
243
+ if (id.kind === "none") throw new DenWebDown(baseUrl, cli);
244
+ throw new UnidentifiedWebServer(port);
245
+ })().finally(() => {
246
+ checking = null;
247
+ });
248
+ return checking;
249
+ }
162
250
 
163
251
  async function request(
164
252
  method: string,
@@ -168,6 +256,7 @@ export function createDenApi(opts: {
168
256
  body?: unknown,
169
257
  ): Promise<Response> {
170
258
  assertAllowedRequest(method, path, query);
259
+ await ensureOurs();
171
260
  try {
172
261
  const res = await fetch(buildUrl(baseUrl, path, query), {
173
262
  method,
@@ -184,7 +273,11 @@ export function createDenApi(opts: {
184
273
  }
185
274
  return res;
186
275
  } catch (e) {
187
- if (isConnectionError(e)) throw new DenWebDown(baseUrl, cli);
276
+ if (isConnectionError(e)) {
277
+ // Whatever answers next on the port is checked again.
278
+ vouchedAt = null;
279
+ throw new DenWebDown(baseUrl, cli);
280
+ }
188
281
  throw e;
189
282
  }
190
283
  }
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * stdout carries the protocol only: diagnostics go to stderr.
7
7
  */
8
+ import { z } from "zod";
8
9
  import { randomUUID } from "crypto";
9
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -18,7 +19,9 @@ import { engineVersion } from "../../shared/paths.js";
18
19
  import { product } from "../../shared/product.js";
19
20
  import { DEFAULT_PORT } from "../../shared/constants.js";
20
21
  import { createDenApi, type DenApi } from "./api.js";
22
+ import { ownServer } from "../cli/server-identity.js";
21
23
  import { callTool, toolsFor, type ToolContext } from "./tools.js";
24
+ import { plural } from "../../shared/plural.js";
22
25
 
23
26
  export interface DenMcpOptions {
24
27
  cfg: ResolvedMcpConfig;
@@ -56,6 +59,8 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
56
59
  createDenApi({
57
60
  baseUrl: opts.baseUrl,
58
61
  cli: opts.cli,
62
+ // Serve nothing from another distribution's server on the port.
63
+ identity: { own: ownServer() },
59
64
  headers: () =>
60
65
  agentAuditHeaders({
61
66
  client: client()?.name ?? "unknown",
@@ -181,7 +186,29 @@ export async function runOneShot(
181
186
  return 0;
182
187
  }
183
188
  const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
184
- const r = await callTool(shot.tool, shot.args, ctx);
189
+ // The stdio server validates arguments against each tool's schema (the
190
+ // SDK does it); a one-shot must too, and strictly: it is typed by hand, so
191
+ // an unknown or misspelt key is an error, not silently dropped.
192
+ let args = shot.args;
193
+ const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
194
+ if (tool) {
195
+ const parsed = z.object(tool.shape).strict().safeParse(args);
196
+ if (!parsed.success) {
197
+ const err = opts.err ?? ((t: string) => process.stderr.write(t));
198
+ const lines = parsed.error.issues.map((i) => {
199
+ const at = i.path.length ? i.path.join(".") : "(arguments)";
200
+ return ` ${at}: ${i.message}`;
201
+ });
202
+ const keys = Object.keys(tool.shape);
203
+ err(
204
+ `--call ${shot.tool}: invalid arguments\n${lines.join("\n")}\n` +
205
+ ` takes: ${keys.length ? keys.join(", ") : "no arguments"}\n`,
206
+ );
207
+ return 2;
208
+ }
209
+ args = parsed.data as Record<string, unknown>;
210
+ }
211
+ const r = await callTool(shot.tool, args, ctx);
185
212
  out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
186
213
  return r.isError ? 1 : 0;
187
214
  }
@@ -210,6 +237,7 @@ export async function runMcp(argv: string[]): Promise<void> {
210
237
  const api = createDenApi({
211
238
  baseUrl,
212
239
  cli,
240
+ identity: { own: ownServer() },
213
241
  headers: () =>
214
242
  agentAuditHeaders({
215
243
  client: `${cli} mcp --call`,
@@ -224,7 +252,7 @@ export async function runMcp(argv: string[]): Promise<void> {
224
252
  const server = createDenMcpServer({ cfg, baseUrl, cli });
225
253
  await server.connect(new StdioServerTransport());
226
254
  process.stderr.write(
227
- `${cli} mcp: ${toolsFor(cfg).length} tools ` +
255
+ `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
228
256
  `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
229
257
  );
230
258
  }
@@ -14,6 +14,8 @@ import { redactSecrets } from "../../shared/redact.js";
14
14
  import {
15
15
  DenHttpError,
16
16
  DenWebDown,
17
+ ForeignWebServer,
18
+ UnidentifiedWebServer,
17
19
  type DenApi,
18
20
  type Query,
19
21
  type StreamResult,
@@ -154,7 +156,7 @@ const READ_TOOLS: DenTool[] = [
154
156
  read(
155
157
  "status",
156
158
  "Service health",
157
- "Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command.",
159
+ "Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command or a container check (`container <project>/<service>`).",
158
160
  { env: envArg, workspace: workspaceArg },
159
161
  (a, { api }) =>
160
162
  api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
@@ -636,7 +638,9 @@ export async function callTool(
636
638
  const msg =
637
639
  e instanceof Refusal
638
640
  ? `Refused: ${e.message}`
639
- : e instanceof DenWebDown
641
+ : e instanceof DenWebDown ||
642
+ e instanceof ForeignWebServer ||
643
+ e instanceof UnidentifiedWebServer
640
644
  ? e.message
641
645
  : `Error: ${(e as Error).message}`;
642
646
  return { text: redactSecrets(msg), isError: true };
@@ -11,9 +11,11 @@
11
11
  * so an "error" health result is only acted on after a SECOND
12
12
  * consecutive "error" check separated by a retry delay. "unknown"
13
13
  * results and thrown checks always keep the entry (err on the side of
14
- * keeping). A daemon whose address no longer resolves to any
15
- * workspace/catalog service is process-death-equivalent evidence and is
16
- * removed directly.
14
+ * keeping). A daemon this root registered whose address no longer resolves
15
+ * to any workspace/catalog service is process-death-equivalent evidence and
16
+ * is removed directly. Records another root registered (all roots of a
17
+ * product share the file) are never touched, and an unstamped legacy record
18
+ * is not removed for being unresolvable.
17
19
  */
18
20
 
19
21
  import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
@@ -41,6 +43,12 @@ export interface DaemonReaperDeps {
41
43
  delay: (ms: number) => Promise<void>;
42
44
  /** Gap between the first "error" result and the confirming re-check. */
43
45
  retryDelayMs: number;
46
+ /**
47
+ * Which root wrote the record (`daemonRootOf`). Every root of a product
48
+ * shares one daemons file: another root's records are never swept here,
49
+ * and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
50
+ */
51
+ rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
44
52
  }
45
53
 
46
54
  export interface DaemonReapResult {
@@ -92,9 +100,19 @@ export async function reapDeadDaemons(
92
100
  await Promise.allSettled(
93
101
  deps.listDaemons().map(async (daemon) => {
94
102
  try {
103
+ const root = deps.rootOf(daemon);
104
+ if (root === "other") {
105
+ kept.push(daemon.address);
106
+ return;
107
+ }
95
108
  const service = resolveForDaemon(deps, daemon);
96
109
  if (!service) {
97
- // No longer in any owning workspace/catalog — nothing could restart it.
110
+ // No longer in any owning workspace/catalog — nothing could restart
111
+ // it. An unstamped record may be another root's, so it stays.
112
+ if (root === "unstamped") {
113
+ kept.push(daemon.address);
114
+ return;
115
+ }
98
116
  deps.unregisterDaemon(daemon.address);
99
117
  removed.push(daemon.address);
100
118
  return;
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * The fields of a /api/health row that say what was checked. `url` is only
3
3
  * ever a URL (or null): a shell check's command (a compose stack's
4
- * `<cli> stack health …`, or any declared shell check) goes in `check`.
4
+ * `<cli> stack health …`, or any declared shell check) goes in `check`, as
5
+ * does a container check's `container <project>/<service>` (or
6
+ * `container <name>`).
5
7
  */
6
- export type HealthCheckType = "shell" | "http" | "none";
8
+ export type HealthCheckType = "shell" | "http" | "container" | "none";
7
9
 
8
10
  export function healthCheckFields(
9
11
  checkType: HealthCheckType,
10
12
  target: string,
11
13
  ): { url: string | null; check?: string } {
12
- if (checkType === "shell") return { url: null, check: target };
14
+ if (checkType === "shell" || checkType === "container")
15
+ return { url: null, check: target };
13
16
  if (checkType === "http") return { url: target };
14
17
  return { url: null };
15
18
  }
@@ -12,9 +12,11 @@
12
12
 
13
13
  import {
14
14
  composeHealthTarget,
15
+ containerCheckLabel,
15
16
  type ComposeHealthTarget,
17
+ type ContainerHealthTarget,
16
18
  } from "../../shared/stacks/compose-health.js";
17
- import { healthCheckFields } from "./health-row.js";
19
+ import { healthCheckFields, type HealthCheckType } from "./health-row.js";
18
20
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
19
21
  import * as http from "http";
20
22
  import * as fs from "fs";
@@ -62,6 +64,8 @@ import {
62
64
  isSafeServiceName,
63
65
  } from "./log-history.js";
64
66
  import {
67
+ containerTarget,
68
+ pingContainerCheck,
65
69
  pingHttp,
66
70
  pingShellCheck,
67
71
  TimeoutStreaks,
@@ -76,6 +80,7 @@ import {
76
80
  checkServiceHealth,
77
81
  loadDaemonsFromFile,
78
82
  pruneDeadDaemonsAsync,
83
+ daemonRootOf,
79
84
  isInteractiveServiceCommand,
80
85
  rewriteLegacyOpctlKillCommandForContainer,
81
86
  shouldDetachServiceCommand,
@@ -432,6 +437,11 @@ import {
432
437
  send404,
433
438
  } from "./http-helpers.js";
434
439
  import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
440
+ import {
441
+ readOrgContext,
442
+ resolveAllWorkspaceOrgs,
443
+ withOrgMembership,
444
+ } from "../../shared/environment-manager/workspace-org.js";
435
445
  import {
436
446
  readLatestSummary,
437
447
  securityReportsDir,
@@ -680,9 +690,18 @@ async function handleRequest(
680
690
 
681
691
  // ── API: environments ──────────────────────────────────────────────────────
682
692
  // Orgs the catalog's repos belong to, with config/den.yml display names.
693
+ // Each entry also says whether it is the default org (`github_org`) and
694
+ // which workspaces on this machine belong to it (ORG-1a). Nothing else
695
+ // changes: same entries, order and fields (ORG-SPEC C3).
683
696
  if (pathname === "/api/orgs") {
684
- const catalog = readCatalog();
685
- const orgs = listOrgs(catalog, readDenConfig().orgs ?? {});
697
+ const ctx = readOrgContext();
698
+ const catalog = ctx.catalog;
699
+ const orgs = withOrgMembership(
700
+ listOrgs(catalog, ctx.config.orgs ?? {}),
701
+ resolveAllWorkspaceOrgs(),
702
+ ctx.file,
703
+ ctx.config,
704
+ );
686
705
  if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
687
706
  const repos = Object.fromEntries(
688
707
  Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
@@ -926,7 +945,7 @@ async function handleRequest(
926
945
  repo: string;
927
946
  service: string;
928
947
  label: string;
929
- checkType: "http" | "shell" | "none";
948
+ checkType: HealthCheckType;
930
949
  target: string;
931
950
  repoPath: string;
932
951
  mode: "dev-managed" | "managed";
@@ -941,6 +960,8 @@ async function handleRequest(
941
960
  expect?: Expect;
942
961
  /** pingShell options (Den's own `stack health` can report unknown). */
943
962
  compose: ComposeHealthTarget | null;
963
+ /** A `type: container` check: answered from the shared listing. */
964
+ container: ContainerHealthTarget | null;
944
965
  meta: Pick<HealthResult, "runs" | "target">;
945
966
  }> = [];
946
967
  let edgeDomain: string | null = null;
@@ -974,6 +995,7 @@ async function handleRequest(
974
995
  links: [],
975
996
  org: orgOf(resolved.repoName, catalog),
976
997
  compose: null,
998
+ container: null,
977
999
  meta: { runs: "none", target: null },
978
1000
  });
979
1001
  continue;
@@ -1020,14 +1042,16 @@ async function handleRequest(
1020
1042
  service: resolved.subsystem,
1021
1043
  label: plan.label ?? healthCfg?.label ?? resolved.subsystem,
1022
1044
  checkType: healthCfg
1023
- ? healthCfg.type === "shell"
1024
- ? "shell"
1045
+ ? healthCfg.type === "shell" || healthCfg.type === "container"
1046
+ ? healthCfg.type
1025
1047
  : "http"
1026
1048
  : "none",
1027
1049
  target: healthCfg
1028
1050
  ? healthCfg.type === "shell"
1029
1051
  ? healthCfg.cmd
1030
- : healthCfg.url
1052
+ : healthCfg.type === "container"
1053
+ ? containerCheckLabel(containerTarget(healthCfg))
1054
+ : healthCfg.url
1031
1055
  : "",
1032
1056
  repoPath: resolved.repoPath,
1033
1057
  mode: resolved.mode,
@@ -1048,14 +1072,15 @@ async function handleRequest(
1048
1072
  subsystem: resolved.subsystem,
1049
1073
  repoName: resolved.repoName,
1050
1074
  env,
1051
- healthUrl:
1052
- healthCfg && healthCfg.type !== "shell" ? healthCfg.url : null,
1075
+ healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
1053
1076
  edgeDomain,
1054
1077
  }),
1055
1078
  links: linksFor(manifest, resolved.subsystem, env),
1056
1079
  org: orgOf(resolved.repoName, catalog),
1057
1080
  ...(plan.expect ? { expect: plan.expect } : {}),
1058
1081
  compose,
1082
+ container:
1083
+ healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
1059
1084
  meta: { runs: plan.placement.runs, target: plan.placement.target },
1060
1085
  });
1061
1086
  }
@@ -1086,6 +1111,7 @@ async function handleRequest(
1086
1111
  meta,
1087
1112
  expect,
1088
1113
  compose,
1114
+ container,
1089
1115
  }) => {
1090
1116
  if (checkType !== "none") {
1091
1117
  const started = Date.now();
@@ -1097,13 +1123,15 @@ async function handleRequest(
1097
1123
  repoPath,
1098
1124
  compose,
1099
1125
  )
1100
- : await pingHttp(
1101
- target,
1102
- env === "local" && meta.runs !== "remote"
1103
- ? HEALTH_CHECK_TIMEOUT_MS
1104
- : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
1105
- expect,
1106
- );
1126
+ : checkType === "container" && container
1127
+ ? await pingContainerCheck(container)
1128
+ : await pingHttp(
1129
+ target,
1130
+ env === "local" && meta.runs !== "remote"
1131
+ ? HEALTH_CHECK_TIMEOUT_MS
1132
+ : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
1133
+ expect,
1134
+ );
1107
1135
  return {
1108
1136
  repo,
1109
1137
  service,
@@ -2362,7 +2390,14 @@ async function handleRequest(
2362
2390
  // ── API: den config ────────────────────────────────────────────────────
2363
2391
  if (pathname === `/api/${product().cli}/config` && req.method === "GET") {
2364
2392
  // `org` resolved (id defaults to edge.org) for the header's org label.
2365
- return sendJson(res, { ...readDenConfig(), org: readOrg() });
2393
+ const config = readDenConfig();
2394
+ // `default_org` (ORG-1a) is the default work org, `github_org`; `org`
2395
+ // stays the instance org (readOrg).
2396
+ return sendJson(res, {
2397
+ ...config,
2398
+ org: readOrg(),
2399
+ default_org: config.github_org?.trim() || null,
2400
+ });
2366
2401
  }
2367
2402
 
2368
2403
  // ── API: server info + staleness (drives the header "stale server" pill) ────
@@ -2373,6 +2408,11 @@ async function handleRequest(
2373
2408
  port: PORT,
2374
2409
  // Den's version: the MCP server compares it with the routes it needs.
2375
2410
  version: engineVersion() ?? null,
2411
+ // Whose server this is: `<cli> web up|down`, `mcp connect` and
2412
+ // `service install` compare both with their own and never act on
2413
+ // another distribution's server (see cli/server-identity.ts).
2414
+ root: ROOT,
2415
+ product: product().name,
2376
2416
  startedAt: new Date(SERVER_STARTED_AT).toISOString(),
2377
2417
  uptimeMs: Date.now() - SERVER_STARTED_AT,
2378
2418
  stale,
@@ -3166,7 +3206,7 @@ void (async () => {
3166
3206
  await new Promise<void>((resolve) =>
3167
3207
  setTimeout(resolve, DAEMON_SHUTDOWN_GRACE_MS),
3168
3208
  );
3169
- await reapDeadDaemons({
3209
+ const reaped = await reapDeadDaemons({
3170
3210
  listDaemons,
3171
3211
  resolveService,
3172
3212
  getActiveWorkspaces,
@@ -3174,7 +3214,12 @@ void (async () => {
3174
3214
  unregisterDaemon,
3175
3215
  delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
3176
3216
  retryDelayMs: DAEMON_RETRY_DELAY_MS,
3217
+ rootOf: daemonRootOf,
3177
3218
  });
3219
+ if (reaped.removed.length > 0)
3220
+ console.error(
3221
+ `Reaped ${reaped.removed.length} stopped daemon(s): ${reaped.removed.join(", ")}`,
3222
+ );
3178
3223
  })();
3179
3224
 
3180
3225
  const prunedSnapshots = pruneDeadSnapshots();
@@ -19,13 +19,16 @@ import { execFile } from "child_process";
19
19
  import { promisify } from "util";
20
20
  import { GH_API_TIMEOUT_MS } from "../../shared/constants.js";
21
21
  import {
22
+ readExplicitReleaseOrg,
22
23
  readReleaseConfig,
24
+ releaseOwnerFor,
23
25
  type ReleaseConfig,
24
26
  type ReleaseRepo,
25
27
  type ResolvedReleaseTarget,
26
28
  resolveReleaseTarget,
27
29
  } from "../../shared/den-config.js";
28
30
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
31
+ import { orgOf } from "../../shared/manifest/service-links.js";
29
32
  import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
30
33
 
31
34
  import { product } from "../../shared/product.js";
@@ -262,13 +265,23 @@ export class ReleaseService {
262
265
  };
263
266
  }
264
267
 
265
- private org(cfg = this.config()): string {
266
- return cfg.org ?? "";
268
+ /**
269
+ * The owner a repo is released from (REL-1): an explicit `release.org`
270
+ * (den.yml or user.yml) wins; otherwise the repo's own non-empty catalog
271
+ * owner, so a client org's repo releases from its own org; `github_org`
272
+ * (readReleaseConfig's fallback for `cfg.org`) last.
273
+ */
274
+ private ownerFor(repo: string, cfg: ReleaseConfig): string {
275
+ return releaseOwnerFor({
276
+ explicitOrg: readExplicitReleaseOrg(),
277
+ catalogOwner: orgOf(repo, readCatalog()),
278
+ githubOrg: cfg.org,
279
+ });
267
280
  }
268
281
 
269
282
  slug(repo: string, cfg = this.config()): string {
270
283
  const gh = cfg.repos.find((r) => r.name === repo)?.gh;
271
- return gh && gh.includes("/") ? gh : `${this.org(cfg)}/${repo}`;
284
+ return gh && gh.includes("/") ? gh : `${this.ownerFor(repo, cfg)}/${repo}`;
272
285
  }
273
286
 
274
287
  /**
@@ -65,6 +65,7 @@ import {
65
65
  getWaitDeps,
66
66
  } from "../../shared/environment-manager/dag.js";
67
67
  import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
68
+ import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
68
69
  import {
69
70
  deriveStartTargets,
70
71
  filterClosureForStart,
@@ -2158,7 +2159,9 @@ export async function handleWorkspaceRoutes(
2158
2159
  // flow writes one), which used to read as "not in the catalog" and
2159
2160
  // skip the clone entirely. Derive the conventional slug instead, and
2160
2161
  // persist it so every other flow (CLI up/setup) stops hitting this.
2161
- const ghSlug = resolveRepoGhSlug(svc.repoName);
2162
+ const ghSlug = resolveRepoGhSlug(svc.repoName, readCatalog(), () =>
2163
+ githubOrgFor(ws.name),
2164
+ );
2162
2165
  if (!readCatalog()[svc.repoName]?.gh) {
2163
2166
  upsertCatalogEntry(svc.repoName, { gh: ghSlug });
2164
2167
  }