@monster-orange/den 0.4.12 → 0.4.14

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 +151 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +189 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +227 -0
  6. package/src/shared/agents/default-pack/be-dev.md +1 -0
  7. package/src/shared/agents/default-pack/devops.md +1 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  10. package/src/shared/agents/default-pack/planner.md +1 -0
  11. package/src/shared/agents/default-pack/reviewer.md +1 -0
  12. package/src/shared/agents/default-pack/tester.md +16 -0
  13. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  14. package/src/shared/agents/den-claude.ts +128 -13
  15. package/src/shared/agents/permissions.ts +108 -0
  16. package/src/shared/agents/persona-store.ts +286 -0
  17. package/src/shared/agents/personas.ts +116 -12
  18. package/src/shared/agents/records.ts +33 -0
  19. package/src/shared/agents/roles.ts +288 -0
  20. package/src/shared/agents/runtime.ts +50 -0
  21. package/src/shared/den-config.ts +22 -1
  22. package/src/shared/environment-manager/service-runner.ts +31 -12
  23. package/src/shared/environment-manager/workspaces.ts +11 -0
  24. package/src/ui/cli/agent-ops.ts +796 -29
  25. package/src/ui/cli/agents-command.ts +97 -3
  26. package/src/ui/cli/env-probe.ts +4 -1
  27. package/src/ui/mcp/api.ts +12 -1
  28. package/src/ui/mcp/tools.ts +60 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +412 -22
  31. package/src/ui/server/public/agent-start.js +3 -2
  32. package/src/ui/server/public/agents-page.js +693 -49
  33. package/src/ui/server/public/agents.html +76 -15
  34. package/src/ui/server/public/i18n/es.js +143 -0
  35. package/src/ui/server/public/i18n/ja.js +144 -0
  36. package/src/ui/server/public/persona-ui.css +563 -0
  37. package/src/ui/server/public/persona-ui.js +796 -0
  38. package/src/ui/server/public/settings-page.js +4 -9
  39. package/src/ui/server/public/settings-registry.js +22 -0
  40. package/src/ui/server/public/settings.html +1 -0
  41. package/src/ui/server/public/shell.js +140 -0
  42. package/src/ui/server/public/topbar.js +2 -2
  43. package/src/ui/server/request-guard.ts +17 -4
  44. package/src/ui/server/route-classes.ts +31 -0
  45. package/src/ui/server/session-routes.ts +21 -5
  46. package/src/ui/server/ui-assets.ts +3 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Roles and persona categories (AG-10.5, AG-11): two small registries.
3
+ *
4
+ * A role is a template and a dispatch key. Den owns planner, orchestrator,
5
+ * sde, reviewer, tester and designer. Each has default instructions, a
6
+ * permission mode and a command preset (plain command prefixes the agent
7
+ * may run without asking). Picking a role in New persona fills the form in
8
+ * (never over text already there), and the role adds nothing hidden to an
9
+ * agent's prompt: what it receives is the persona's instructions.
10
+ *
11
+ * A distribution adds roles in its config (`agents.roles`), and may extend
12
+ * a Den role's preset there, never redefine it:
13
+ *
14
+ * agents:
15
+ * roles:
16
+ * copywriter: { description: …, instructions: …, mode: ask, preset: [], category: marketing }
17
+ * reviewer: { preset: [semgrep] } # adds to Den's preset only
18
+ *
19
+ * Categories group personas in the Start list and Settings › Personas.
20
+ * Den's are Engineering, Design, Product, Marketing and Operations; a
21
+ * distribution adds more (`agents.categories: [{ id, label }]`).
22
+ */
23
+ import { checkCommands, PERMISSION_INTENTS } from "./permissions.js";
24
+ import type { PermissionIntent } from "./permissions.js";
25
+
26
+ /** Where a role or category came from (hub: later, once Den Hub exists). */
27
+ export type RegistrySource = "den" | "distribution" | "project" | "hub";
28
+
29
+ export interface RoleTemplate {
30
+ id: string;
31
+ source: RegistrySource;
32
+ /** One line: what a persona of this role does. */
33
+ description: string;
34
+ /** The starting instructions New persona fills in. */
35
+ instructions: string;
36
+ /** The starting permission mode. */
37
+ mode: PermissionIntent;
38
+ /** Plain command prefixes allowed without asking (AG-13.3). */
39
+ preset: string[];
40
+ /** The category a new persona of this role starts in. */
41
+ category: string;
42
+ /** Set when a distribution extended a Den role's preset. */
43
+ extendedBy?: RegistrySource;
44
+ /** The commands that extension added (shown apart from Den's). */
45
+ extension?: string[];
46
+ }
47
+
48
+ export interface Category {
49
+ id: string;
50
+ label: string;
51
+ source: RegistrySource;
52
+ }
53
+
54
+ /** A role or category id: lowercase letters, digits and "-", 1-32 long. */
55
+ export const REGISTRY_ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
56
+
57
+ /** Den's categories, in the order lists show them. */
58
+ export const DEN_CATEGORIES: readonly Category[] = [
59
+ { id: "engineering", label: "Engineering", source: "den" },
60
+ { id: "design", label: "Design", source: "den" },
61
+ { id: "product", label: "Product", source: "den" },
62
+ { id: "marketing", label: "Marketing", source: "den" },
63
+ { id: "operations", label: "Operations", source: "den" },
64
+ ];
65
+
66
+ /**
67
+ * Den's roles. The presets are read-only commands only: a preset is
68
+ * allowed without asking, so Den's defaults never let an agent change
69
+ * anything unasked.
70
+ */
71
+ export const DEN_ROLES: readonly RoleTemplate[] = [
72
+ {
73
+ id: "planner",
74
+ source: "den",
75
+ description:
76
+ "Turns goals into small, ready stories with acceptance criteria.",
77
+ instructions:
78
+ "Read the workspace's specs and open work items. Split goals into stories small enough for one pull request, each with testable acceptance criteria. Hand ready stories to the orchestrator. Don't write code.",
79
+ mode: "ask",
80
+ preset: ["gh issue list", "gh issue view"],
81
+ category: "engineering",
82
+ },
83
+ {
84
+ id: "orchestrator",
85
+ source: "den",
86
+ description:
87
+ "Hands ready stories to other agents and follows them to done.",
88
+ instructions:
89
+ "Take the next ready story in priority order and start the persona that fits it, one agent per story. Respect the workspace's concurrency cap. Ask a person before anything irreversible; never approve or merge.",
90
+ mode: "ask",
91
+ preset: ["gh issue list", "gh pr view"],
92
+ category: "engineering",
93
+ },
94
+ {
95
+ id: "sde",
96
+ source: "den",
97
+ description: "Implements one story, test first, and opens a pull request.",
98
+ instructions:
99
+ "Take one story. Write a failing test for each acceptance criterion first, then make it pass. Keep the change small, run the repo's checks, and open a pull request that says what you verified.",
100
+ mode: "ask",
101
+ preset: ["git status", "git diff", "git log"],
102
+ category: "engineering",
103
+ },
104
+ {
105
+ id: "reviewer",
106
+ source: "den",
107
+ description:
108
+ "Reviews a pull request adversarially. Never approves or merges.",
109
+ instructions:
110
+ "Check the change against its story's acceptance criteria, then look for what wasn't tested: edge cases, failure paths, security. Verify each finding in the code and report them ranked by impact. Never approve, merge or push.",
111
+ mode: "ask",
112
+ preset: ["git diff", "git log", "gh pr diff", "gh pr view"],
113
+ category: "engineering",
114
+ },
115
+ {
116
+ id: "tester",
117
+ source: "den",
118
+ description: "Runs the test suites and reports failures with repro steps.",
119
+ instructions:
120
+ "Run the workspace's unit, integration and end-to-end suites. For each failure, write the exact repro and the smallest failing command. Don't fix code.",
121
+ mode: "ask",
122
+ preset: ["git status"],
123
+ category: "engineering",
124
+ },
125
+ {
126
+ id: "designer",
127
+ source: "den",
128
+ description: "Designs flows and screens with the design system.",
129
+ instructions:
130
+ "Start from the user's goal and the story's acceptance criteria. Compose from the design system's components, and describe every state (empty, loading, error, success) and narrow screens. Hand off a short spec the developers can build and test.",
131
+ mode: "ask",
132
+ preset: [],
133
+ category: "design",
134
+ },
135
+ ];
136
+
137
+ const isMapping = (v: unknown): v is Record<string, unknown> =>
138
+ v !== null && typeof v === "object" && !Array.isArray(v);
139
+ const shown = (v: unknown) =>
140
+ (JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
141
+
142
+ function line(where: string, v: unknown, max: number): string {
143
+ if (
144
+ typeof v !== "string" ||
145
+ !v.trim() ||
146
+ [...v].length > max ||
147
+ /[\p{Cc}\p{Cf}]/u.test(v)
148
+ )
149
+ throw new Error(
150
+ `${where}: must be one line of 1-${max} characters, no control characters`,
151
+ );
152
+ return v.trim();
153
+ }
154
+
155
+ /**
156
+ * `agents.categories` checked: a list of { id, label }. A Den category's
157
+ * id is refused (it can't be renamed). Anything else throws, naming it.
158
+ */
159
+ export function checkCategoriesConfig(raw: unknown): Category[] {
160
+ if (raw === undefined || raw === null) return [];
161
+ if (!Array.isArray(raw))
162
+ throw new Error("agents.categories: must be a list of { id, label }");
163
+ const seen = new Set(DEN_CATEGORIES.map((c) => c.id));
164
+ return raw.map((x, i) => {
165
+ const where = `agents.categories[${i}]`;
166
+ if (!isMapping(x))
167
+ throw new Error(`${where}: must be a mapping { id, label }`);
168
+ for (const k of Object.keys(x))
169
+ if (k !== "id" && k !== "label")
170
+ throw new Error(`${where}.${k}: unknown key (takes id, label)`);
171
+ if (typeof x.id !== "string" || !REGISTRY_ID_RE.test(x.id))
172
+ throw new Error(
173
+ `${where}.id: ${shown(x.id)} must be lowercase letters, digits and "-" (1-32)`,
174
+ );
175
+ if (seen.has(x.id))
176
+ throw new Error(`${where}.id: "${x.id}" is already a category`);
177
+ seen.add(x.id);
178
+ return {
179
+ id: x.id,
180
+ label: line(`${where}.label`, x.label, 40),
181
+ source: "distribution" as const,
182
+ };
183
+ });
184
+ }
185
+
186
+ /** What `agents.roles` holds, checked (merged with Den's by roleRegistry). */
187
+ export interface RolesConfig {
188
+ /** New roles. */
189
+ added: RoleTemplate[];
190
+ /** Den roles' preset extensions, by role id. */
191
+ extended: Record<string, string[]>;
192
+ }
193
+
194
+ /**
195
+ * `agents.roles` checked. A Den role takes `preset` only (an extension);
196
+ * a new role takes description, instructions, mode, preset and category.
197
+ * `categories` are the ids a new role's category may name.
198
+ */
199
+ export function checkRolesConfig(
200
+ raw: unknown,
201
+ categories: readonly string[] = DEN_CATEGORIES.map((c) => c.id),
202
+ ): RolesConfig {
203
+ const out: RolesConfig = { added: [], extended: {} };
204
+ if (raw === undefined || raw === null) return out;
205
+ if (!isMapping(raw))
206
+ throw new Error(
207
+ "agents.roles: must be a mapping of role id to its template",
208
+ );
209
+ for (const [id, v] of Object.entries(raw)) {
210
+ const where = `agents.roles.${id}`;
211
+ if (!REGISTRY_ID_RE.test(id))
212
+ throw new Error(
213
+ `${where}: a role id must be lowercase letters, digits and "-" (1-32)`,
214
+ );
215
+ if (!isMapping(v)) throw new Error(`${where}: must be a mapping`);
216
+ const den = DEN_ROLES.find((r) => r.id === id);
217
+ if (den) {
218
+ for (const k of Object.keys(v))
219
+ if (k !== "preset")
220
+ throw new Error(
221
+ `${where}.${k}: ${id} is Den's role: a distribution may only extend its preset`,
222
+ );
223
+ out.extended[id] = checkCommands(v.preset, `${where}.preset`);
224
+ continue;
225
+ }
226
+ const keys = ["description", "instructions", "mode", "preset", "category"];
227
+ for (const k of Object.keys(v))
228
+ if (!keys.includes(k))
229
+ throw new Error(
230
+ `${where}.${k}: unknown key (takes ${keys.join(", ")})`,
231
+ );
232
+ const mode = v.mode ?? "ask";
233
+ if (!(PERMISSION_INTENTS as readonly unknown[]).includes(mode))
234
+ throw new Error(
235
+ `${where}.mode: ${shown(mode)} must be one of ${PERMISSION_INTENTS.join(", ")}`,
236
+ );
237
+ const category = v.category ?? "engineering";
238
+ if (typeof category !== "string" || !categories.includes(category))
239
+ throw new Error(
240
+ `${where}.category: ${shown(category)} is not a category (${categories.join(", ")})`,
241
+ );
242
+ const instructions = v.instructions ?? "";
243
+ if (typeof instructions !== "string" || instructions.length > 4000)
244
+ throw new Error(
245
+ `${where}.instructions: must be text of up to 4000 characters`,
246
+ );
247
+ out.added.push({
248
+ id,
249
+ source: "distribution",
250
+ description: line(`${where}.description`, v.description, 200),
251
+ instructions: instructions.trim(),
252
+ mode: mode as PermissionIntent,
253
+ preset: checkCommands(v.preset, `${where}.preset`),
254
+ category,
255
+ });
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /** Den's roles with a distribution's extensions, then its new roles. */
261
+ export function roleRegistry(
262
+ cfg: RolesConfig = { added: [], extended: {} },
263
+ ): RoleTemplate[] {
264
+ const den = DEN_ROLES.map((r) => {
265
+ const ext = cfg.extended[r.id];
266
+ if (!ext?.length) return { ...r, preset: [...r.preset] };
267
+ return {
268
+ ...r,
269
+ preset: [...new Set([...r.preset, ...ext])],
270
+ extendedBy: "distribution" as const,
271
+ extension: [...ext],
272
+ };
273
+ });
274
+ return [...den, ...cfg.added];
275
+ }
276
+
277
+ /** Den's categories, then a distribution's. */
278
+ export function categoryRegistry(extra: readonly Category[] = []): Category[] {
279
+ return [...DEN_CATEGORIES, ...extra];
280
+ }
281
+
282
+ /** A role's command preset ([] for none, or a role that isn't registered). */
283
+ export function rolePreset(
284
+ roles: readonly RoleTemplate[],
285
+ role: string | undefined,
286
+ ): string[] {
287
+ return role ? (roles.find((r) => r.id === role)?.preset ?? []) : [];
288
+ }
@@ -20,6 +20,8 @@ import type { CatalogEntry } from "./catalog.js";
20
20
  import type { AgentRecord } from "./records.js";
21
21
  import type { Persona } from "./personas.js";
22
22
  import type { BinSource, RuntimeFind } from "./runtime-search.js";
23
+ import type { PermissionIntent } from "./permissions.js";
24
+ import type { RuntimeConversation } from "./conversations.js";
23
25
 
24
26
  export const RUNTIME_KIND = "runtime";
25
27
 
@@ -95,6 +97,16 @@ export interface LaunchInput {
95
97
  * session rotates (0: never). Den's hooks count them. Default 3.
96
98
  */
97
99
  rotateAfter?: number;
100
+ /**
101
+ * The permission intent to launch with (AG-13): the agent's, else its
102
+ * persona's `permission_mode`. Only a runtime with `permissions` takes it.
103
+ */
104
+ permission?: PermissionIntent;
105
+ /**
106
+ * Plain command prefixes allowed without asking (AG-13.3, 13.4): the
107
+ * role's preset and the extra commands, checked (permissions.ts).
108
+ */
109
+ allow?: string[];
98
110
  /**
99
111
  * The new conversation's first prompt (a relaunch from handoff.md).
100
112
  * Only a runtime with `firstPrompt: true` is given one.
@@ -116,6 +128,29 @@ export interface LaunchOutput {
116
128
 
117
129
  export type ResumeInput = LaunchInput;
118
130
 
131
+ /**
132
+ * A model a runtime offers (AG-12.3): its id as the Model list shows it,
133
+ * and the versions it can be pinned to, newest first. "Latest" tracks the
134
+ * newest release; a pinned version is one of `versions`.
135
+ */
136
+ export interface RuntimeModel {
137
+ id: string;
138
+ label: string;
139
+ versions: string[];
140
+ latest: string;
141
+ }
142
+
143
+ /**
144
+ * How a runtime expresses Den's permission intents (AG-13.2), as its own
145
+ * launch config: a read-only preview of each mode ("What this allows on
146
+ * <runtime>"), and how one plain command prefix becomes its rules
147
+ * (`{c}` the prefix). The runtime's launch writes exactly these.
148
+ */
149
+ export interface RuntimePermissions {
150
+ modes: Record<PermissionIntent, string>;
151
+ commandRules: string[];
152
+ }
153
+
119
154
  export interface Runtime {
120
155
  /** "den-claude", "generic". */
121
156
  id: string;
@@ -142,6 +177,13 @@ export interface Runtime {
142
177
  * firstPrompt): Relaunch from handoff needs it (den#4).
143
178
  */
144
179
  firstPrompt?: boolean;
180
+ /**
181
+ * The models it offers (AG-12.3), from the catalog shipped with each Den
182
+ * release; none: it takes no model choice from Den.
183
+ */
184
+ models?(): RuntimeModel[];
185
+ /** How it expresses permission intents (AG-13); none: it can't yet. */
186
+ permissions?: RuntimePermissions;
145
187
  /** Throws LaunchError when this persona or this machine can't run it. */
146
188
  launch(input: LaunchInput): LaunchOutput;
147
189
  /** A new run that continues the last one; null when there is nothing to resume. */
@@ -152,6 +194,14 @@ export interface Runtime {
152
194
  * just mean it isn't written yet.
153
195
  */
154
196
  conversationExists?(token: string, env?: NodeJS.ProcessEnv): boolean;
197
+ /**
198
+ * AG-17.1, bring in a conversation: the runtime's own recent
199
+ * conversations that ran in `cwd` or a folder inside it, newest first,
200
+ * read from its history (only the ends of each file, never a whole
201
+ * transcript). Each id is one `resume` continues. None: this runtime
202
+ * can't bring one in yet. Must never throw.
203
+ */
204
+ listSessions?(cwd: string, env?: NodeJS.ProcessEnv): RuntimeConversation[];
155
205
  /**
156
206
  * What the runtime prints when it exits at once (0.4.8): `noConversation`
157
207
  * when a resume finds nothing to continue, `idInUse` when a new
@@ -21,6 +21,13 @@ import { ConfigError, isConfigError } from "./config-error.js";
21
21
  import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
22
22
  import type { Guardrails } from "./agents/guardrails.js";
23
23
  import { DEFAULT_ROTATE, type RotateSettings } from "./agents/rotation.js";
24
+ import {
25
+ checkCategoriesConfig,
26
+ checkRolesConfig,
27
+ DEN_CATEGORIES,
28
+ type Category,
29
+ type RolesConfig,
30
+ } from "./agents/roles.js";
24
31
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
25
32
 
26
33
  import { product } from "./product.js";
@@ -811,6 +818,8 @@ export const MCP_ACTIONS = [
811
818
  "run_tests",
812
819
  // AG-14.4: resume another agent of its workspace (or a granted one).
813
820
  "agent_resume",
821
+ // AG-17.1: bring in a conversation started outside Den, as an agent.
822
+ "agent_import",
814
823
  ] as const;
815
824
  export type McpAction = (typeof MCP_ACTIONS)[number];
816
825
 
@@ -918,6 +927,9 @@ export const AGENTS_RESERVED_KEYS = [
918
927
  "runtime_catalog",
919
928
  "default_pack",
920
929
  "rotate",
930
+ // AG-10.5, AG-11.1: the distribution's persona categories and roles.
931
+ "categories",
932
+ "roles",
921
933
  ];
922
934
 
923
935
  /** One agent's own block, `agents.<id>`. */
@@ -941,6 +953,10 @@ export interface ResolvedAgentsConfig {
941
953
  runtimeCatalog: CatalogEntry[];
942
954
  /** agents.rotate with its defaults (den#4); unset: DEFAULT_ROTATE. */
943
955
  rotate?: RotateSettings;
956
+ /** agents.categories (AG-10.5): the distribution's, after Den's. */
957
+ categories?: Category[];
958
+ /** agents.roles (AG-11.1): new roles and Den roles' preset extensions. */
959
+ roles?: RolesConfig;
944
960
  }
945
961
 
946
962
  /** `agents.rotate` checked; any other key, or a bad value, throws naming it. */
@@ -1034,6 +1050,11 @@ export function readAgentsConfig(
1034
1050
  ),
1035
1051
  rotate: rotateBlock(raw.rotate),
1036
1052
  };
1053
+ out.categories = checkCategoriesConfig(raw.categories);
1054
+ out.roles = checkRolesConfig(raw.roles, [
1055
+ ...DEN_CATEGORIES.map((c) => c.id),
1056
+ ...out.categories.map((c) => c.id),
1057
+ ]);
1037
1058
  const max = raw.max_concurrent;
1038
1059
  if (max !== undefined) {
1039
1060
  if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
@@ -1066,7 +1087,7 @@ export function readAgentsConfig(
1066
1087
  const where = `agents.${id}`;
1067
1088
  if (!AGENT_KEY_RE.test(id) || !isMapping(block))
1068
1089
  throw new Error(
1069
- `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, rotate, ` +
1090
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, rotate, categories, roles, ` +
1070
1091
  `or an agent id mapping to { grants: [...] })`,
1071
1092
  );
1072
1093
  for (const k of Object.keys(block))
@@ -2794,18 +2794,13 @@ export function runServiceCommand(
2794
2794
  // later down signalled the dead group while the real listener ran on).
2795
2795
  // A failed start keeps no record at all.
2796
2796
  const serviceExited = (exitCode: number): void => {
2797
- const rec = recorded;
2798
- if (rec && processGroupStateSync(rec)?.state === "gone") {
2799
- const now = findDaemonByAddress(resolved.address);
2800
- // A non-zero exit is a failed start: nothing of it runs, so no
2801
- // record (its containers, if any, still run: then only the group
2802
- // goes). A zero exit handed off (`make up`): its group stays, and
2803
- // down says it already exited.
2804
- if (now?.pgid === rec.pgid && exitCode !== 0) {
2805
- if (!(now.containers?.length ?? 0)) unregisterDaemon(resolved.address);
2806
- else clearDaemonProcessGroup(resolved.address);
2807
- }
2808
- }
2797
+ const rec: RecordedProcessGroup | null = recorded;
2798
+ const state = rec ? (processGroupStateSync(rec)?.state ?? null) : null;
2799
+ const now = findDaemonByAddress(resolved.address);
2800
+ const action = failedStartCleanup(exitCode, rec, state, now);
2801
+ if (action === "unregister") unregisterDaemon(resolved.address);
2802
+ else if (action === "clear-group")
2803
+ clearDaemonProcessGroup(resolved.address);
2809
2804
  onServiceExit?.(exitCode);
2810
2805
  };
2811
2806
  const proc = recordGroup
@@ -2835,6 +2830,30 @@ export function runServiceCommand(
2835
2830
  return { proc, detachable, interactive: false, logPath };
2836
2831
  }
2837
2832
 
2833
+ /**
2834
+ * What a detached up does with its record when the service process exits.
2835
+ * A non-zero exit is a failed start: nothing of it runs, so no record (its
2836
+ * containers, if any, still run: then only the group goes). A zero exit
2837
+ * handed off (`make up`): its group stays, and down says it already exited.
2838
+ * The record goes only if it is this run's: its group is the one this run
2839
+ * recorded and that group is gone, or this run could not record a group
2840
+ * (the command exited, a zombie, before `ps` saw it) and the record names
2841
+ * none.
2842
+ */
2843
+ export function failedStartCleanup(
2844
+ exitCode: number,
2845
+ rec: RecordedProcessGroup | null,
2846
+ groupState: string | null,
2847
+ now: { pgid?: number; containers?: unknown[] } | undefined,
2848
+ ): "unregister" | "clear-group" | "keep" {
2849
+ if (exitCode === 0 || !now) return "keep";
2850
+ const ours = rec
2851
+ ? groupState === "gone" && now.pgid === rec.pgid
2852
+ : now.pgid === undefined;
2853
+ if (!ours) return "keep";
2854
+ return (now.containers?.length ?? 0) ? "clear-group" : "unregister";
2855
+ }
2856
+
2838
2857
  // ── Health checks ─────────────────────────────────────────────────────────────
2839
2858
  // (implementation lives in health.ts — re-exported above)
2840
2859
 
@@ -344,6 +344,17 @@ export function resolveWorkspaceDir(
344
344
  return reposRoot;
345
345
  }
346
346
 
347
+ /**
348
+ * The folders of a workspace's by-path repos (`repo add <path>`), wherever
349
+ * they are, outside its own folder too (AG-17.1). Managed clones and a
350
+ * feature workspace's clones live under Den's or the workspace's folder.
351
+ */
352
+ export function repoFolders(cfg: WorkspaceConfig | undefined): string[] {
353
+ return Object.values(cfg?.services ?? {})
354
+ .map((e) => (e as { path?: string }).path)
355
+ .filter((p): p is string => typeof p === "string" && path.isAbsolute(p));
356
+ }
357
+
347
358
  export function getWorkspaceByName(name: string): {
348
359
  name: string;
349
360
  config: WorkspaceConfig;