@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.
- package/CHANGELOG.md +151 -0
- package/README.md +7 -7
- package/docs/distributions.md +189 -11
- package/package.json +1 -1
- package/src/shared/agents/conversations.ts +227 -0
- package/src/shared/agents/default-pack/be-dev.md +1 -0
- package/src/shared/agents/default-pack/devops.md +1 -0
- package/src/shared/agents/default-pack/fe-dev.md +1 -0
- package/src/shared/agents/default-pack/orchestrator.md +3 -1
- package/src/shared/agents/default-pack/planner.md +1 -0
- package/src/shared/agents/default-pack/reviewer.md +1 -0
- package/src/shared/agents/default-pack/tester.md +16 -0
- package/src/shared/agents/default-pack/ux-designer.md +1 -0
- package/src/shared/agents/den-claude.ts +128 -13
- package/src/shared/agents/permissions.ts +108 -0
- package/src/shared/agents/persona-store.ts +286 -0
- package/src/shared/agents/personas.ts +116 -12
- package/src/shared/agents/records.ts +33 -0
- package/src/shared/agents/roles.ts +288 -0
- package/src/shared/agents/runtime.ts +50 -0
- package/src/shared/den-config.ts +22 -1
- 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 +796 -29
- package/src/ui/cli/agents-command.ts +97 -3
- package/src/ui/cli/env-probe.ts +4 -1
- package/src/ui/mcp/api.ts +12 -1
- package/src/ui/mcp/tools.ts +60 -1
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +412 -22
- package/src/ui/server/public/agent-start.js +3 -2
- package/src/ui/server/public/agents-page.js +693 -49
- package/src/ui/server/public/agents.html +76 -15
- package/src/ui/server/public/i18n/es.js +143 -0
- package/src/ui/server/public/i18n/ja.js +144 -0
- package/src/ui/server/public/persona-ui.css +563 -0
- package/src/ui/server/public/persona-ui.js +796 -0
- package/src/ui/server/public/settings-page.js +4 -9
- package/src/ui/server/public/settings-registry.js +22 -0
- package/src/ui/server/public/settings.html +1 -0
- package/src/ui/server/public/shell.js +140 -0
- package/src/ui/server/public/topbar.js +2 -2
- package/src/ui/server/request-guard.ts +17 -4
- package/src/ui/server/route-classes.ts +31 -0
- package/src/ui/server/session-routes.ts +21 -5
- package/src/ui/server/ui-assets.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: orchestrator
|
|
3
|
+
role: orchestrator
|
|
3
4
|
description: Takes the next ready story, picks the persona for it and starts that agent.
|
|
4
5
|
standing: true
|
|
5
6
|
context: [ready work items, running agents in the workspace]
|
|
@@ -9,7 +10,8 @@ You are the orchestrator. Keep work flowing without overloading the machine
|
|
|
9
10
|
or the people reviewing it.
|
|
10
11
|
|
|
11
12
|
- Take the next ready story, in priority order. Pick the persona that fits it:
|
|
12
|
-
fe-dev, be-dev or devops for implementation, ux-designer for design work
|
|
13
|
+
fe-dev, be-dev or devops for implementation, ux-designer for design work,
|
|
14
|
+
tester to run the suites.
|
|
13
15
|
- Start one agent per story, in its own worktree, and link it to the story.
|
|
14
16
|
- Respect the workspace's concurrency cap; wait rather than queueing more.
|
|
15
17
|
- When a pull request is opened, ask the reviewer for a review. Never approve
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tester
|
|
3
|
+
role: tester
|
|
4
|
+
description: Runs the test suites and reports failures with repro steps.
|
|
5
|
+
context: [the story and its acceptance criteria, the repo's test commands]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are the tester. Find out what is broken before anyone ships it.
|
|
9
|
+
|
|
10
|
+
- Run the workspace's unit, integration and end-to-end suites, as the repo
|
|
11
|
+
documents them.
|
|
12
|
+
- For each failure, write the exact repro: the smallest failing command, what
|
|
13
|
+
it printed, and what you expected.
|
|
14
|
+
- Separate real failures from flaky ones; run a suspected flake again before
|
|
15
|
+
reporting it.
|
|
16
|
+
- Don't fix code. Report what you found to the person or agent who asked.
|
|
@@ -61,12 +61,27 @@ import {
|
|
|
61
61
|
type LaunchOutput,
|
|
62
62
|
type Runtime,
|
|
63
63
|
type RuntimeDetect,
|
|
64
|
+
type RuntimeModel,
|
|
65
|
+
type RuntimePermissions,
|
|
64
66
|
} from "./runtime.js";
|
|
67
|
+
import { PERMISSION_INTENTS, type PermissionIntent } from "./permissions.js";
|
|
65
68
|
import { builtinEntry } from "./catalog.js";
|
|
66
69
|
import { catalogFind } from "./catalog-runtime.js";
|
|
67
70
|
import { locateRuntime, type RuntimeFind } from "./runtime-search.js";
|
|
68
71
|
import { claudeProjectsDir } from "../usage/paths.js";
|
|
69
|
-
import { transcriptTitle } from "./transcript-follow.js";
|
|
72
|
+
import { cleanName, transcriptTitle } from "./transcript-follow.js";
|
|
73
|
+
import { claudeConversations } from "./conversations.js";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `/rename "x"` keeps the quotes in customTitle: one matching pair around
|
|
77
|
+
* the whole name goes, for display only (0.4.13). Nothing inside: null.
|
|
78
|
+
*/
|
|
79
|
+
export function unquoteTitle(name: string | null): string | null {
|
|
80
|
+
if (!name || name.length < 2) return name;
|
|
81
|
+
const q = name[0];
|
|
82
|
+
if ((q !== '"' && q !== "'") || name[name.length - 1] !== q) return name;
|
|
83
|
+
return cleanName(name.slice(1, -1));
|
|
84
|
+
}
|
|
70
85
|
|
|
71
86
|
export const DEN_CLAUDE_ID = "den-claude";
|
|
72
87
|
const BIN = "claude";
|
|
@@ -90,12 +105,91 @@ export const PERMISSION_MODES = [
|
|
|
90
105
|
] as const;
|
|
91
106
|
export const DEFAULT_PERMISSION_MODE = "default";
|
|
92
107
|
|
|
93
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Den's permission intents (AG-13) as Claude Code's modes:
|
|
110
|
+
* https://code.claude.com/docs/en/iam (permission modes): `default` asks
|
|
111
|
+
* on first use of each tool, `acceptEdits` accepts file edits for the
|
|
112
|
+
* session, `bypassPermissions` skips every prompt.
|
|
113
|
+
*/
|
|
114
|
+
export const INTENT_MODE: Record<PermissionIntent, string> = {
|
|
115
|
+
ask: "default",
|
|
116
|
+
edits: "acceptEdits",
|
|
117
|
+
all: "bypassPermissions",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One plain command prefix as Claude Code allow rules (the same docs,
|
|
122
|
+
* Bash rules): the command alone, and the command with arguments (`*`
|
|
123
|
+
* matches any run of characters). Deny rules (Den's state dir) still win:
|
|
124
|
+
* Claude Code checks deny before allow.
|
|
125
|
+
*/
|
|
126
|
+
export const COMMAND_RULES = ["Bash({c})", "Bash({c} *)"];
|
|
127
|
+
|
|
128
|
+
/** How den-claude expresses permission intents (AG-13.2). */
|
|
129
|
+
export const DEN_CLAUDE_PERMISSIONS: RuntimePermissions = {
|
|
130
|
+
modes: {
|
|
131
|
+
ask: "--permission-mode default: asks before edits and commands",
|
|
132
|
+
edits:
|
|
133
|
+
"--permission-mode acceptEdits: file edits run without asking; commands ask",
|
|
134
|
+
all: "--permission-mode bypassPermissions: nothing asks",
|
|
135
|
+
},
|
|
136
|
+
commandRules: COMMAND_RULES,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** A command prefix's allow rules (permissions.ts checked it). */
|
|
140
|
+
export function allowRules(commands: readonly string[]): string[] {
|
|
141
|
+
return commands.flatMap((c) =>
|
|
142
|
+
COMMAND_RULES.map((r) => r.split("{c}").join(c)),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Claude Code's models (AG-12.3): the aliases `claude --model` takes,
|
|
148
|
+
* each "latest" (the alias tracks its newest model) or pinned to one full
|
|
149
|
+
* model id. Claude Code has no command that lists models, so the list
|
|
150
|
+
* ships with each Den release (https://code.claude.com/docs/en/model-config:
|
|
151
|
+
* aliases opus, sonnet, haiku, or a full model name). Newest first.
|
|
152
|
+
*/
|
|
153
|
+
export const DEN_CLAUDE_MODELS: readonly RuntimeModel[] = [
|
|
154
|
+
{
|
|
155
|
+
id: "opus",
|
|
156
|
+
label: "Opus",
|
|
157
|
+
versions: [
|
|
158
|
+
"claude-opus-5-5",
|
|
159
|
+
"claude-opus-5",
|
|
160
|
+
"claude-opus-4-8",
|
|
161
|
+
"claude-opus-4-7",
|
|
162
|
+
"claude-opus-4-6",
|
|
163
|
+
],
|
|
164
|
+
latest: "claude-opus-5-5",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "sonnet",
|
|
168
|
+
label: "Sonnet",
|
|
169
|
+
versions: ["claude-sonnet-5-5", "claude-sonnet-5", "claude-sonnet-4-6"],
|
|
170
|
+
latest: "claude-sonnet-5-5",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "haiku",
|
|
174
|
+
label: "Haiku",
|
|
175
|
+
versions: ["claude-haiku-5-5", "claude-haiku-4-5"],
|
|
176
|
+
latest: "claude-haiku-5-5",
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The mode to launch with: the agent's intent (Start › Advanced), else the
|
|
182
|
+
* persona's `permission_mode`: one of Den's intents, or one of Claude
|
|
183
|
+
* Code's own modes (as before AG-13). A bad one names the key.
|
|
184
|
+
*/
|
|
94
185
|
function permissionMode(input: LaunchInput): string {
|
|
186
|
+
if (input.permission) return INTENT_MODE[input.permission];
|
|
95
187
|
const m = input.persona.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
188
|
+
if ((PERMISSION_INTENTS as readonly string[]).includes(m))
|
|
189
|
+
return INTENT_MODE[m as PermissionIntent];
|
|
96
190
|
if (!(PERMISSION_MODES as readonly string[]).includes(m))
|
|
97
191
|
throw new LaunchError(
|
|
98
|
-
`${input.persona.source}: permission_mode "${m.replace(/[^\x20-\x7e]/g, "?").slice(0, 40)}" must be one of ${PERMISSION_MODES.join(", ")}`,
|
|
192
|
+
`${input.persona.source}: permission_mode "${m.replace(/[^\x20-\x7e]/g, "?").slice(0, 40)}" must be one of ${[...PERMISSION_INTENTS, ...PERMISSION_MODES].join(", ")}`,
|
|
99
193
|
);
|
|
100
194
|
return m;
|
|
101
195
|
}
|
|
@@ -244,7 +338,9 @@ function common(input: LaunchInput): {
|
|
|
244
338
|
}),
|
|
245
339
|
)
|
|
246
340
|
: null;
|
|
247
|
-
const
|
|
341
|
+
const allow = allowRules(input.allow ?? []);
|
|
342
|
+
const settings =
|
|
343
|
+
input.stateDir || hooks || allow.length ? settingsPath(input) : null;
|
|
248
344
|
return {
|
|
249
345
|
argv: [
|
|
250
346
|
"--mcp-config",
|
|
@@ -268,13 +364,18 @@ function common(input: LaunchInput): {
|
|
|
268
364
|
? {
|
|
269
365
|
[settings]: `${JSON.stringify(
|
|
270
366
|
{
|
|
271
|
-
...(input.stateDir
|
|
367
|
+
...(input.stateDir || allow.length
|
|
272
368
|
? {
|
|
273
369
|
permissions: {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
370
|
+
...(allow.length ? { allow } : {}),
|
|
371
|
+
...(input.stateDir
|
|
372
|
+
? {
|
|
373
|
+
deny: stateDirDenyRules(
|
|
374
|
+
input.stateDir,
|
|
375
|
+
input.env?.HOME,
|
|
376
|
+
),
|
|
377
|
+
}
|
|
378
|
+
: {}),
|
|
278
379
|
},
|
|
279
380
|
}
|
|
280
381
|
: {}),
|
|
@@ -289,7 +390,9 @@ function common(input: LaunchInput): {
|
|
|
289
390
|
warnings:
|
|
290
391
|
mode === "bypassPermissions"
|
|
291
392
|
? [
|
|
292
|
-
|
|
393
|
+
input.permission === "all"
|
|
394
|
+
? `⚠ agent ${input.agentId} starts with auto-approve all (bypassPermissions): Claude Code will not ask before running any tool`
|
|
395
|
+
: `⚠ persona ${input.persona.id} sets permission_mode: bypassPermissions: Claude Code will not ask before running any tool`,
|
|
293
396
|
]
|
|
294
397
|
: [],
|
|
295
398
|
};
|
|
@@ -347,6 +450,9 @@ export const denClaude: Runtime = {
|
|
|
347
450
|
denTools: true,
|
|
348
451
|
firstPrompt: true,
|
|
349
452
|
find: DEN_CLAUDE_FIND,
|
|
453
|
+
models: () =>
|
|
454
|
+
DEN_CLAUDE_MODELS.map((m) => ({ ...m, versions: [...m.versions] })),
|
|
455
|
+
permissions: DEN_CLAUDE_PERMISSIONS,
|
|
350
456
|
detect,
|
|
351
457
|
launch(input): LaunchOutput {
|
|
352
458
|
const bin = binFor(input);
|
|
@@ -383,14 +489,23 @@ export const denClaude: Runtime = {
|
|
|
383
489
|
conversationExists(token, env = process.env) {
|
|
384
490
|
return transcriptFile(token, env) !== null;
|
|
385
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
|
+
},
|
|
386
500
|
// AG-4b, unofficial (catalog sessionName): `/rename` appends a
|
|
387
501
|
// {"type":"custom-title","customTitle":…} record to the transcript; the
|
|
388
|
-
// latest one, read on from the last ask (never the whole file)
|
|
389
|
-
// any failure: it must never break a
|
|
502
|
+
// latest one, read on from the last ask (never the whole file), less one
|
|
503
|
+
// pair of surrounding quotes. Null on any failure: it must never break a
|
|
504
|
+
// pane.
|
|
390
505
|
sessionName(rec, env = process.env) {
|
|
391
506
|
try {
|
|
392
507
|
const file = transcriptFile(rec.resumeToken, env);
|
|
393
|
-
return file ? transcriptTitle(file) : null;
|
|
508
|
+
return file ? unquoteTitle(transcriptTitle(file)) : null;
|
|
394
509
|
} catch {
|
|
395
510
|
return null;
|
|
396
511
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissions as intent (AG-13): what a person chooses, before any
|
|
3
|
+
* runtime's syntax.
|
|
4
|
+
*
|
|
5
|
+
* ask Ask for everything (the default)
|
|
6
|
+
* edits Auto-approve edits in the worktree
|
|
7
|
+
* all Auto-approve all (a session started so is always tagged)
|
|
8
|
+
*
|
|
9
|
+
* And extra allowed commands: plain command prefixes ("npm test"), one per
|
|
10
|
+
* entry, no patterns. Each runtime translates both into its own launch
|
|
11
|
+
* config (den-claude.ts); a runtime that can't express one says so, and it
|
|
12
|
+
* is never silently dropped. What a persona pack shares is this intent
|
|
13
|
+
* only, never vendor syntax.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const PERMISSION_INTENTS = ["ask", "edits", "all"] as const;
|
|
17
|
+
export type PermissionIntent = (typeof PERMISSION_INTENTS)[number];
|
|
18
|
+
export const DEFAULT_INTENT: PermissionIntent = "ask";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A persona's `permission_mode` as an intent. Den's three intents are
|
|
22
|
+
* themselves; Claude Code's own modes from before AG-13 read as the intent
|
|
23
|
+
* they meant (default, acceptEdits, bypassPermissions). Anything else
|
|
24
|
+
* (Claude Code's plan, a typo) is not an intent: undefined, and the
|
|
25
|
+
* runtime decides (den-claude refuses a mode it doesn't know).
|
|
26
|
+
*/
|
|
27
|
+
export function intentOf(
|
|
28
|
+
mode: string | undefined,
|
|
29
|
+
): PermissionIntent | undefined {
|
|
30
|
+
if (mode === undefined) return DEFAULT_INTENT;
|
|
31
|
+
if ((PERMISSION_INTENTS as readonly string[]).includes(mode))
|
|
32
|
+
return mode as PermissionIntent;
|
|
33
|
+
if (mode === "default") return "ask";
|
|
34
|
+
if (mode === "acceptEdits") return "edits";
|
|
35
|
+
if (mode === "bypassPermissions") return "all";
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The most extra commands one persona or launch takes. */
|
|
40
|
+
export const MAX_COMMANDS = 32;
|
|
41
|
+
/** The longest command prefix. */
|
|
42
|
+
export const MAX_COMMAND_LEN = 120;
|
|
43
|
+
/**
|
|
44
|
+
* What a plain prefix is made of: letters, digits, spaces and . _ / : = @
|
|
45
|
+
* % + , -. No shell syntax (quotes, ;, &, |, $, backticks, redirects) and
|
|
46
|
+
* no pattern characters (* ? [ ] ^ ( ) { }): Den translates a prefix, it
|
|
47
|
+
* never passes one through a shell.
|
|
48
|
+
*/
|
|
49
|
+
const PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/:=@%+,-]*$/;
|
|
50
|
+
const PATTERN_RE = /[*?[\]^$(){}|]/;
|
|
51
|
+
|
|
52
|
+
export type CommandProblem = "pattern" | "invalid" | "long";
|
|
53
|
+
|
|
54
|
+
/** Why `c` isn't a plain prefix, or null when it is. */
|
|
55
|
+
export function commandProblem(c: string): CommandProblem | null {
|
|
56
|
+
if ([...c].length > MAX_COMMAND_LEN) return "long";
|
|
57
|
+
if (PATTERN_RE.test(c)) return "pattern";
|
|
58
|
+
if (!PREFIX_RE.test(c) || /\s{2,}/.test(c) || c !== c.trim())
|
|
59
|
+
return "invalid";
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const shown = (v: unknown) =>
|
|
64
|
+
(JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A list of extra allowed commands, checked: an array of plain prefixes
|
|
68
|
+
* (each trimmed, duplicates dropped), at most MAX_COMMANDS. undefined or
|
|
69
|
+
* null is none. Anything else throws, naming the entry and why.
|
|
70
|
+
*/
|
|
71
|
+
export function checkCommands(v: unknown, where: string): string[] {
|
|
72
|
+
if (v === undefined || v === null) return [];
|
|
73
|
+
if (!Array.isArray(v))
|
|
74
|
+
throw new Error(`${where}: must be a list of command prefixes`);
|
|
75
|
+
if (v.length > MAX_COMMANDS)
|
|
76
|
+
throw new Error(`${where}: at most ${MAX_COMMANDS} commands`);
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
v.forEach((x, i) => {
|
|
79
|
+
if (typeof x !== "string")
|
|
80
|
+
throw new Error(`${where}[${i}]: must be a string`);
|
|
81
|
+
const c = x.trim();
|
|
82
|
+
const why = c ? commandProblem(c) : "invalid";
|
|
83
|
+
if (why === "pattern")
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${where}[${i}]: ${shown(c)} is a pattern: use a plain prefix ("npm test")`,
|
|
86
|
+
);
|
|
87
|
+
if (why === "long")
|
|
88
|
+
throw new Error(`${where}[${i}]: at most ${MAX_COMMAND_LEN} characters`);
|
|
89
|
+
if (why)
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${where}[${i}]: ${shown(c)} must be a plain prefix: letters, digits, single spaces and . _ / : = @ % + , -`,
|
|
92
|
+
);
|
|
93
|
+
if (!out.includes(c)) out.push(c);
|
|
94
|
+
});
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An intent, checked; a usage message naming `where` otherwise. */
|
|
99
|
+
export function checkIntent(v: unknown, where: string): PermissionIntent {
|
|
100
|
+
if (
|
|
101
|
+
typeof v === "string" &&
|
|
102
|
+
(PERMISSION_INTENTS as readonly string[]).includes(v)
|
|
103
|
+
)
|
|
104
|
+
return v as PermissionIntent;
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${where}: ${shown(v)} must be one of ${PERMISSION_INTENTS.join(", ")}`,
|
|
107
|
+
);
|
|
108
|
+
}
|