@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 +49 -0
- package/README.md +7 -7
- package/docs/distributions.md +43 -1
- package/package.json +1 -1
- package/src/shared/agents/conversations.ts +227 -0
- package/src/shared/agents/den-claude.ts +9 -0
- package/src/shared/agents/records.ts +5 -0
- package/src/shared/agents/runtime.ts +9 -0
- package/src/shared/den-config.ts +2 -0
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +11 -0
- package/src/ui/cli/agent-ops.ts +266 -17
- package/src/ui/cli/agents-command.ts +85 -1
- package/src/ui/mcp/api.ts +12 -1
- package/src/ui/mcp/tools.ts +59 -0
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +152 -1
- package/src/ui/server/public/agents-page.js +201 -8
- package/src/ui/server/public/agents.html +18 -0
- package/src/ui/server/public/i18n/es.js +23 -0
- package/src/ui/server/public/i18n/ja.js +24 -0
- package/src/ui/server/public/persona-ui.css +24 -0
- package/src/ui/server/public/shell.js +23 -0
- package/src/ui/server/request-guard.ts +17 -4
- package/src/ui/server/route-classes.ts +13 -0
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.
|
|
14
|
-
<img src="docs/images/0.4.
|
|
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.
|
|
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.
|
|
72
|
-
<img src="docs/images/0.4.
|
|
73
|
-
<img src="docs/images/0.4.
|
|
74
|
-
<img src="docs/images/0.4.
|
|
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
|
package/docs/distributions.md
CHANGED
|
@@ -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
|
@@ -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
|
package/src/shared/den-config.ts
CHANGED
|
@@ -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
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
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;
|