@monster-orange/den 0.4.13 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.14] - 2026-11-07
8
+
9
+ ### Added
10
+
11
+ - **Bring in a conversation (AG-17.1).** A Claude Code conversation you
12
+ started in a terminal outside Den, in the workspace's folder or in one
13
+ of its repos' folders (a repo added by path outside it too), can become
14
+ a Den agent. Agents › Bring in lists them, newest first, and asks for a
15
+ persona; Bring in runs `claude --resume <id>` in the folder it ran in,
16
+ with Den's token, MCP, hooks and guards from then on. Den reads only the
17
+ first and last 64 KB of each transcript. A conversation written in the
18
+ last minute is refused: "Still open in another terminal. Close it there
19
+ first". CLI: `agents import -w <ws>` lists, `agents import -w <ws>
20
+ --session <id> --persona <p>` brings one in. MCP `agent_import` is
21
+ opt-in through `mcp.actions`. Routes: `GET
22
+ /api/workspaces/<ws>/conversations`, `POST
23
+ /api/workspaces/<ws>/conversations/<id>/import`. Only den-claude can
24
+ bring one in for now. The dialog asks you to close the
25
+ conversation in any other terminal first.
26
+ - **One persona, several agents (AG-10.10).** Starting a persona that
27
+ already has an agent in the workspace starts another: `be-dev`, then
28
+ `be-dev-2`, `be-dev-3`. A stopped agent's id is never reused by a new
29
+ start, only by its Resume or Start fresh. The Start dialog says the id
30
+ it will use ("Starts as be-dev-2") and takes an optional Name. An id
31
+ that is running is refused, and the dialog offers "Open the running
32
+ one". `--id` stays exact. The start route takes `name`; `GET
33
+ /api/personas?workspace=<ws>` answers `startsAs`; a 409 for a running
34
+ agent carries `running: {id, sessionId}`.
35
+
36
+ ### Changed
37
+
38
+ - Without `--id`, a start of a persona whose agent is stopped in that
39
+ workspace no longer replaces it: it starts `<persona>-2`. To continue
40
+ the stopped one, Resume it or Start fresh on it.
41
+
42
+ ### Fixed
43
+
44
+ - Bring in: a long title no longer cuts off its "Agent …" badge, and each
45
+ "can't bring in a conversation yet" line is its own sentence.
46
+ Its intro names the repos' folders too.
47
+
48
+ - Start › Advanced: the Model select's default reads "Persona default
49
+ (latest)" for a persona with no model of its own; the longer label was
50
+ cut off.
51
+ - An `up` whose service exits at once with an error no longer leaves the
52
+ service listed. The command could exit before Den recorded its process
53
+ group, and then its record stayed.
54
+
7
55
  ## [0.4.13] - 2026-11-07
8
56
 
9
57
  ### Added
@@ -56,6 +104,7 @@ config and manifest changes a distribution must make to move to it under
56
104
  `sessions.max_concurrent`).
57
105
  - **A tester persona** in Den's default pack.
58
106
 
107
+
59
108
  ### Changed
60
109
 
61
110
  - `POST /api/workspaces/<ws>/agents` takes `version`, `permission`,
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.13/hero-dark.png">
14
- <img src="docs/images/0.4.13/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.14/hero-dark.png">
14
+ <img src="docs/images/0.4.14/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
15
15
  </picture>
16
16
 
17
17
  ## Set up by your agent
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
38
38
  yours. Den connects them and turns on good practice from the start.
39
39
 
40
40
  <p align="center">
41
- <img src="docs/images/0.4.13/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
41
+ <img src="docs/images/0.4.14/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
42
42
  </p>
43
43
 
44
44
  ## How it helps
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
68
68
  lives in your own repo.
69
69
 
70
70
  <p align="center">
71
- <img src="docs/images/0.4.13/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
- <img src="docs/images/0.4.13/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
- <img src="docs/images/0.4.13/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
- <img src="docs/images/0.4.13/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
71
+ <img src="docs/images/0.4.14/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
+ <img src="docs/images/0.4.14/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
+ <img src="docs/images/0.4.14/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
+ <img src="docs/images/0.4.14/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
75
75
  </p>
76
76
 
77
77
  ## Get started
@@ -1932,6 +1932,13 @@ orchestrator. It is off unless `mcp.actions` names it. A scoped server
1932
1932
  itself. The web server checks the same rule, and the resume route is the
1933
1933
  only agent route an agent's token reaches.
1934
1934
 
1935
+ `agent_import` (AG-17.1) brings in a conversation started outside Den as an
1936
+ agent: with no `session` it lists them, with `session` and `persona` it
1937
+ brings one in (see [Bring in a conversation](#bring-in-a-conversation)). It
1938
+ is off unless `mcp.actions` names it, and a scoped server reaches its own
1939
+ workspace or a granted one. Without it, an agent's token gets 403 on both
1940
+ the list and the import.
1941
+
1935
1942
  ### Guards
1936
1943
 
1937
1944
  - **Lifecycle only, local only.** The service tools run a manifest's `up`,
@@ -2042,6 +2049,8 @@ server scoped to its workspace.
2042
2049
  <cli> agents list -w <ws> [--json]
2043
2050
  <cli> agents stop <id> [-w <ws>]
2044
2051
  <cli> agents resume <id> [-w <ws>]
2052
+ <cli> agents import -w <ws> [--runtime <r>] [--json] # conversations to bring in
2053
+ <cli> agents import -w <ws> --session <id> --persona <p> # bring one in
2045
2054
  <cli> agents personas [--json]
2046
2055
  <cli> agents doctor [-w <ws>] [--json] # can each runtime run here, and why not
2047
2056
  <cli> agents handback-check <file|-> # is a hand-back complete and plain (see Guardrails)
@@ -2052,7 +2061,10 @@ server scoped to its workspace.
2052
2061
  checkout dir (its `directory`, else `den-workspaces/<ws>`), capped by
2053
2062
  `agents.max_concurrent` (agents only: plain sessions never count). The id defaults to the persona's id; an id is
2054
2063
  unique in the distribution, so a second workspace's planner gets
2055
- `planner-<ws>` unless you give `--id`.
2064
+ `planner-<ws>` unless you give `--id`. A persona that already has an
2065
+ agent there, running or stopped, starts another: `planner-2`,
2066
+ `planner-3`. A stopped agent is continued by Resume or Start fresh,
2067
+ never by a new start. `--id` is exact: a running id is refused.
2056
2068
  - The **agent record**, `<stateDir>/agents/<id>.json` (0600), holds its id,
2057
2069
  persona, runtime, model, workspace, cwd, session and times, and what it was
2058
2070
  started with from the Start dialog: `version` (the model's, "latest" or
@@ -2085,6 +2097,36 @@ server scoped to its workspace.
2085
2097
  exits non-zero, Den prints the exit code and its last lines (at most 10,
2086
2098
  secret-shaped values redacted) and exits 1.
2087
2099
 
2100
+ #### Bring in a conversation
2101
+
2102
+ A conversation you started in a terminal outside Den (Claude Code, in the
2103
+ workspace's folder, a folder inside it, or a folder of one of its repos,
2104
+ a repo added by path outside it too) can become a Den agent. Agents ›
2105
+ **Bring in** lists them, newest first, with the persona list; `agents
2106
+ import -w <ws>` lists them too.
2107
+
2108
+ - Den reads the runtime's own history: Claude Code's
2109
+ `~/.claude/projects/<folder>/<id>.jsonl` (or under `CLAUDE_CONFIG_DIR`).
2110
+ It reads only the first and last 64 KB of each file, for the folder it
2111
+ ran in, its latest `/rename` and its first prompt. It never reads a whole
2112
+ transcript, and it lists at most 50.
2113
+ - Bringing one in runs the runtime's resume (`claude --resume <id>`) in the
2114
+ folder it ran in, as a new agent with the persona you pick. From then on
2115
+ Den's token, MCP config, hooks, guards and audit trail apply. It is an
2116
+ agent like any other: stop, resume, rename. The record notes
2117
+ `broughtInAt`.
2118
+ - **One writer only.** A conversation written in the last minute is taken
2119
+ to be still open in another terminal: "Still open in another terminal.
2120
+ Close it there first" (HTTP 409 `open_elsewhere`). A conversation an
2121
+ agent already continues is refused too (409 `already_agent`).
2122
+ - The agent's id is the persona's, or `<persona>-2`, `-3`… when that is
2123
+ taken; `--id` sets one. An existing agent is never overwritten.
2124
+ - Only den-claude can bring one in for now. Other runtimes say "<runtime>
2125
+ can't bring in a conversation yet".
2126
+ - Routes: `GET /api/workspaces/<ws>/conversations[?runtime=<r>]` and `POST
2127
+ /api/workspaces/<ws>/conversations/<id>/import` `{ persona, runtime?, id?
2128
+ }`. A person's only, unless MCP `agent_import` is opted in.
2129
+
2088
2130
  ### On the Workspaces page
2089
2131
 
2090
2132
  The selected workspace's agents are listed under its repo table, one row
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * AG-17.1, bring in a conversation: the runtime's own recent conversations
3
+ * for a folder, read from its history without ever reading a whole
4
+ * transcript.
5
+ *
6
+ * Claude Code keeps one JSON-lines file per conversation,
7
+ * `<projects>/<slug>/<id>.jsonl`, where <slug> is the cwd with every
8
+ * character that isn't a letter or digit made "-" (`/w/a.b` → `-w-a-b`).
9
+ * The slug alone can't tell `/w/north` from `/w/north-2`, so a
10
+ * conversation is listed only when the `cwd` its own records carry is the
11
+ * folder or inside it.
12
+ *
13
+ * Each file is read at its two ends only (ENDS_BYTES each): the head for
14
+ * its cwd and its first prompt, the tail for its latest `/rename`
15
+ * (custom-title). Title: the latest custom-title, else the first prompt a
16
+ * person typed, else null. lastAt is the file's mtime: a conversation
17
+ * written in the last OPEN_ELSEWHERE_MS is taken to be open in another
18
+ * terminal (one writer only).
19
+ */
20
+ import * as fs from "fs";
21
+ import * as path from "path";
22
+ import { claudeProjectsDir } from "../usage/paths.js";
23
+ import { cleanName } from "./transcript-follow.js";
24
+
25
+ /** One of a runtime's conversations, as Bring in lists it. */
26
+ export interface RuntimeConversation {
27
+ /** The runtime's own id for it (Claude Code: the transcript's name). */
28
+ id: string;
29
+ /** Its name for people, or null when it has none yet. */
30
+ title: string | null;
31
+ /** When it was last written (ISO). */
32
+ lastAt: string;
33
+ /** The folder it ran in. */
34
+ cwd: string;
35
+ }
36
+
37
+ /** Bytes read at each end of a transcript. */
38
+ export const ENDS_BYTES = 64 * 1024;
39
+ /** Written this recently: still open in another terminal (AG-17.1). */
40
+ export const OPEN_ELSEWHERE_MS = 60_000;
41
+ /** The most conversations listed for one folder (the newest). */
42
+ export const MAX_CONVERSATIONS = 50;
43
+
44
+ export const OPEN_ELSEWHERE_MESSAGE =
45
+ "Still open in another terminal. Close it there first";
46
+
47
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
48
+
49
+ /** Claude Code's project folder name for `dir`. */
50
+ export function claudeSlug(dir: string): string {
51
+ return dir.replace(/[^A-Za-z0-9]/g, "-");
52
+ }
53
+
54
+ /** Whether `p` is `dir` or inside it. */
55
+ function within(p: string, dir: string): boolean {
56
+ const rel = path.relative(dir, p);
57
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
58
+ }
59
+
60
+ /**
61
+ * The complete lines at the two ends of `file`: at most `bytes` from each.
62
+ * A line cut at either edge is left out; a short file is all head.
63
+ */
64
+ export function readEnds(
65
+ file: string,
66
+ bytes = ENDS_BYTES,
67
+ ): { head: string[]; tail: string[] } {
68
+ const fd = fs.openSync(file, "r");
69
+ try {
70
+ const size = fs.fstatSync(fd).size;
71
+ const read = (at: number, n: number) => {
72
+ const buf = Buffer.alloc(n);
73
+ const got = fs.readSync(fd, buf, 0, n, at);
74
+ return buf.subarray(0, got).toString("utf-8");
75
+ };
76
+ const headText = read(0, Math.min(size, bytes));
77
+ const head = headText.split("\n");
78
+ // The whole file: every line. Else the last piece was cut.
79
+ if (size <= bytes) return { head: head.filter(Boolean), tail: [] };
80
+ head.pop();
81
+ const start = Math.max(bytes, size - bytes);
82
+ const tail = read(start, size - start).split("\n");
83
+ // The first piece was cut (or is the head's).
84
+ tail.shift();
85
+ return { head: head.filter(Boolean), tail: tail.filter(Boolean) };
86
+ } finally {
87
+ fs.closeSync(fd);
88
+ }
89
+ }
90
+
91
+ function parse(line: string): Record<string, unknown> | null {
92
+ try {
93
+ const v = JSON.parse(line) as unknown;
94
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** The text a person typed in a user record, or null (tool results, meta). */
101
+ function promptText(rec: Record<string, unknown>): string | null {
102
+ if (rec.type !== "user" || rec.isMeta === true) return null;
103
+ const msg = rec.message as { content?: unknown } | undefined;
104
+ let text: unknown = msg?.content;
105
+ if (Array.isArray(text)) {
106
+ const first = text.find(
107
+ (c) => (c as { type?: unknown })?.type === "text",
108
+ ) as { text?: unknown } | undefined;
109
+ text = first?.text;
110
+ }
111
+ if (typeof text !== "string") return null;
112
+ // Slash commands and their output come wrapped in tags: not a prompt.
113
+ if (text.trimStart().startsWith("<")) return null;
114
+ return cleanName(text);
115
+ }
116
+
117
+ /** A transcript's cwd and title, from its two ends. */
118
+ export function transcriptSummary(
119
+ file: string,
120
+ bytes = ENDS_BYTES,
121
+ ): { cwd: string | null; title: string | null } {
122
+ const { head, tail } = readEnds(file, bytes);
123
+ let cwd: string | null = null;
124
+ let prompt: string | null = null;
125
+ let title: string | null = null;
126
+ const titleIn = (line: string) => {
127
+ if (!line.includes("custom-title")) return;
128
+ const r = parse(line);
129
+ if (r?.type === "custom-title") title = cleanName(r.customTitle) ?? title;
130
+ };
131
+ for (const line of head) {
132
+ titleIn(line);
133
+ if (cwd && prompt) continue;
134
+ if (!line.includes('"cwd"') && !line.includes('"user"')) continue;
135
+ const r = parse(line);
136
+ if (!r) continue;
137
+ if (!cwd && typeof r.cwd === "string" && path.isAbsolute(r.cwd))
138
+ cwd = r.cwd;
139
+ prompt ??= promptText(r);
140
+ }
141
+ for (const line of tail) titleIn(line);
142
+ return { cwd, title: title ?? prompt };
143
+ }
144
+
145
+ /** A folder's absolute forms: as given and its real path. */
146
+ function forms(dir: string): string[] {
147
+ const out = [path.resolve(dir)];
148
+ try {
149
+ const real = fs.realpathSync(dir);
150
+ if (!out.includes(real)) out.push(real);
151
+ } catch {
152
+ /* gone: as given */
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Claude Code's conversations that ran in `folder` or a folder inside it,
159
+ * newest first, at most MAX_CONVERSATIONS. Never throws: a history it
160
+ * can't read is no conversations.
161
+ */
162
+ export function claudeConversations(
163
+ folder: string,
164
+ env: NodeJS.ProcessEnv = process.env,
165
+ ): RuntimeConversation[] {
166
+ const root = claudeProjectsDir(env);
167
+ const dirs = forms(folder);
168
+ const slugs = dirs.map(claudeSlug);
169
+ let projects: string[];
170
+ try {
171
+ projects = fs.readdirSync(root);
172
+ } catch {
173
+ return [];
174
+ }
175
+ const files: { id: string; file: string; mtimeMs: number }[] = [];
176
+ for (const p of projects) {
177
+ if (!slugs.some((s) => p === s || p.startsWith(`${s}-`))) continue;
178
+ let names: string[];
179
+ try {
180
+ names = fs.readdirSync(path.join(root, p));
181
+ } catch {
182
+ continue;
183
+ }
184
+ for (const n of names) {
185
+ if (!n.endsWith(".jsonl")) continue;
186
+ const id = n.slice(0, -".jsonl".length);
187
+ if (!ID_RE.test(id)) continue;
188
+ const file = path.join(root, p, n);
189
+ try {
190
+ const st = fs.statSync(file);
191
+ if (st.isFile() && st.size > 0)
192
+ files.push({ id, file, mtimeMs: st.mtimeMs });
193
+ } catch {
194
+ /* gone */
195
+ }
196
+ }
197
+ }
198
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
199
+ const out: RuntimeConversation[] = [];
200
+ const seen = new Set<string>();
201
+ for (const f of files) {
202
+ if (out.length >= MAX_CONVERSATIONS) break;
203
+ if (seen.has(f.id)) continue;
204
+ let s: ReturnType<typeof transcriptSummary>;
205
+ try {
206
+ s = transcriptSummary(f.file);
207
+ } catch {
208
+ continue;
209
+ }
210
+ const cwd = s.cwd;
211
+ if (!cwd || !dirs.some((d) => within(cwd, d))) continue;
212
+ seen.add(f.id);
213
+ out.push({
214
+ id: f.id,
215
+ title: s.title,
216
+ lastAt: new Date(f.mtimeMs).toISOString(),
217
+ cwd,
218
+ });
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** Whether a conversation last written at `lastAt` is still open elsewhere. */
224
+ export function openElsewhere(lastAt: string, now = Date.now()): boolean {
225
+ const t = Date.parse(lastAt);
226
+ return Number.isFinite(t) && now - t < OPEN_ELSEWHERE_MS;
227
+ }
@@ -70,6 +70,7 @@ import { catalogFind } from "./catalog-runtime.js";
70
70
  import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
71
71
  import { claudeProjectsDir } from "../usage/paths.js";
72
72
  import { cleanName, transcriptTitle } from "./transcript-follow.js";
73
+ import { claudeConversations } from "./conversations.js";
73
74
 
74
75
  /**
75
76
  * `/rename "x"` keeps the quotes in customTitle: one matching pair around
@@ -488,6 +489,14 @@ export const denClaude: Runtime = {
488
489
  conversationExists(token, env = process.env) {
489
490
  return transcriptFile(token, env) !== null;
490
491
  },
492
+ // AG-17.1: its transcripts for the folder (conversations.ts).
493
+ listSessions(cwd, env = process.env) {
494
+ try {
495
+ return claudeConversations(cwd, env);
496
+ } catch {
497
+ return [];
498
+ }
499
+ },
491
500
  // AG-4b, unofficial (catalog sessionName): `/rename` appends a
492
501
  // {"type":"custom-title","customTitle":…} record to the transcript; the
493
502
  // latest one, read on from the last ask (never the whole file), less one
@@ -42,6 +42,11 @@ export interface AgentRecord {
42
42
  resumedAt?: string;
43
43
  /** The runtime's handle for resume (den-claude: the conversation id). */
44
44
  resumeToken?: string;
45
+ /**
46
+ * AG-17.1: brought in from a conversation started outside Den (when);
47
+ * Den's token, hooks and guards apply from then on.
48
+ */
49
+ broughtInAt?: string;
45
50
  /**
46
51
  * The runtime was launched on resumeToken at least once (0.4.8). A
47
52
  * resume then always tries the runtime's own resume first, never a new
@@ -21,6 +21,7 @@ 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
23
  import type { PermissionIntent } from "./permissions.js";
24
+ import type { RuntimeConversation } from "./conversations.js";
24
25
 
25
26
  export const RUNTIME_KIND = "runtime";
26
27
 
@@ -193,6 +194,14 @@ export interface Runtime {
193
194
  * just mean it isn't written yet.
194
195
  */
195
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[];
196
205
  /**
197
206
  * What the runtime prints when it exits at once (0.4.8): `noConversation`
198
207
  * when a resume finds nothing to continue, `idInUse` when a new
@@ -818,6 +818,8 @@ export const MCP_ACTIONS = [
818
818
  "run_tests",
819
819
  // AG-14.4: resume another agent of its workspace (or a granted one).
820
820
  "agent_resume",
821
+ // AG-17.1: bring in a conversation started outside Den, as an agent.
822
+ "agent_import",
821
823
  ] as const;
822
824
  export type McpAction = (typeof MCP_ACTIONS)[number];
823
825
 
@@ -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;