@monster-orange/den 0.4.7 → 0.4.9
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 +174 -0
- package/README.md +7 -7
- package/docs/distributions.md +243 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/src/shared/agents/den-claude.ts +57 -3
- package/src/shared/agents/guardrails/hooks.json +23 -0
- package/src/shared/agents/guardrails/instructions.md +23 -0
- package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
- package/src/shared/agents/guardrails/stop-hook.ts +48 -2
- package/src/shared/agents/guardrails.ts +38 -7
- package/src/shared/agents/handoff.ts +118 -0
- package/src/shared/agents/personas.ts +11 -1
- package/src/shared/agents/records.ts +122 -1
- package/src/shared/agents/rotation.ts +362 -0
- package/src/shared/agents/runtime.ts +27 -0
- package/src/shared/den-config.ts +72 -1
- package/src/shared/git/gh-pr-state.ts +32 -0
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +377 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +319 -0
- package/src/ui/cli/agent-ops.ts +678 -51
- package/src/ui/cli/agents-command.ts +192 -22
- package/src/ui/cli/index.ts +13 -1
- package/src/ui/cli/usage-command.ts +323 -0
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +247 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +400 -13
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +40 -0
- package/src/ui/server/public/i18n/ja.js +36 -0
- package/src/ui/server/public/shell.js +79 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +60 -5
- package/src/ui/server/sudo-store.ts +36 -7
|
@@ -22,6 +22,29 @@
|
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
}
|
|
25
|
+
],
|
|
26
|
+
"PreCompact": [
|
|
27
|
+
{
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "{rotation}",
|
|
32
|
+
"timeout": 10
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"SessionStart": [
|
|
38
|
+
{
|
|
39
|
+
"matcher": "compact|resume",
|
|
40
|
+
"hooks": [
|
|
41
|
+
{
|
|
42
|
+
"type": "command",
|
|
43
|
+
"command": "{rotation}",
|
|
44
|
+
"timeout": 10
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
25
48
|
]
|
|
26
49
|
}
|
|
27
50
|
}
|
|
@@ -37,3 +37,26 @@ a numbered yes/no question is sent back to you to fix.
|
|
|
37
37
|
Write "Decisions: none" when there are none, and "URL: none" (with a short
|
|
38
38
|
reason) when the work has no page to open. Every message you end a turn
|
|
39
39
|
with is read by a person, hand-back or not: explain each ID in it.
|
|
40
|
+
|
|
41
|
+
### When your session rotates
|
|
42
|
+
|
|
43
|
+
A session ends when its pull request merges, or after a few compactions
|
|
44
|
+
(three unless your team set another number). Den tells you when that
|
|
45
|
+
happens. Then write a handoff to the file Den names (`handoff.md` in your
|
|
46
|
+
agent files folder) and end your turn. Den checks it, and relaunches your
|
|
47
|
+
role from it in a fresh conversation. Use these headings, each filled in:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
## Objective
|
|
51
|
+
<what the work is for, in a sentence or two>
|
|
52
|
+
## State
|
|
53
|
+
<what is done, and where it is: branch, pull request, files>
|
|
54
|
+
## Open items
|
|
55
|
+
<what is left, one per line; or none>
|
|
56
|
+
## How to QA
|
|
57
|
+
<the steps to see it working>
|
|
58
|
+
## Next step
|
|
59
|
+
<the one thing to do first>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Write it plainly. Explain every ID in it, as in a hand-back.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Den's session-rotation hook for Claude Code sessions it launches
|
|
3
|
+
* (den#4). One script for two events, run with the agent's own files dir
|
|
4
|
+
* and its limit:
|
|
5
|
+
*
|
|
6
|
+
* rotation-hook.ts --files <agent files dir> --after <n> (0: never rotates)
|
|
7
|
+
*
|
|
8
|
+
* Hook contract (https://code.claude.com/docs/en/hooks):
|
|
9
|
+
* - PreCompact: JSON on stdin with the common fields (`session_id`,
|
|
10
|
+
* `transcript_path`, `cwd`, `hook_event_name`) and `trigger` (manual or
|
|
11
|
+
* auto) and `custom_instructions`. Exit 0's stdout becomes instructions
|
|
12
|
+
* for the compaction summary. It runs before Claude Code decides to
|
|
13
|
+
* compact ("Not enough messages to compact" still runs it), so it never
|
|
14
|
+
* counts. When this compaction would make a rotation due, its stdout
|
|
15
|
+
* asks the summary to keep the instruction to write the handoff.
|
|
16
|
+
* - SessionStart, with `source` "compact" (after a compaction that
|
|
17
|
+
* happened) or "resume". "compact" counts the compaction in
|
|
18
|
+
* rotation.json (rotation.ts). While a rotation is due, it prints
|
|
19
|
+
* `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":…}}`,
|
|
20
|
+
* which Claude Code adds to the context: write handoff.md, then end
|
|
21
|
+
* the turn.
|
|
22
|
+
*
|
|
23
|
+
* The parser is tolerant: a field it doesn't find is left out, never an
|
|
24
|
+
* error. Any error lets Claude Code carry on (exit 0, nothing printed):
|
|
25
|
+
* the hook never blocks a compaction.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
dueFor,
|
|
29
|
+
noteCompaction,
|
|
30
|
+
notePendingCompaction,
|
|
31
|
+
readHandoff,
|
|
32
|
+
readRotation,
|
|
33
|
+
rotationInstruction,
|
|
34
|
+
} from "../rotation.js";
|
|
35
|
+
|
|
36
|
+
export interface RotationHookArgs {
|
|
37
|
+
files: string;
|
|
38
|
+
after: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `--files <dir> --after <n>`; null when either is missing. */
|
|
42
|
+
export function parseHookArgs(
|
|
43
|
+
argv: readonly string[],
|
|
44
|
+
): RotationHookArgs | null {
|
|
45
|
+
const at = (flag: string) => {
|
|
46
|
+
const i = argv.indexOf(flag);
|
|
47
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
48
|
+
};
|
|
49
|
+
const files = at("--files");
|
|
50
|
+
const after = Number(at("--after") ?? "");
|
|
51
|
+
if (!files || !Number.isInteger(after) || after < 0) return null;
|
|
52
|
+
return { files, after };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The hook's stdout for one input ("" prints nothing). */
|
|
56
|
+
export function rotationHookOutput(
|
|
57
|
+
input: unknown,
|
|
58
|
+
args: RotationHookArgs,
|
|
59
|
+
now: Date = new Date(),
|
|
60
|
+
): string {
|
|
61
|
+
if (!input || typeof input !== "object") return "";
|
|
62
|
+
const i = input as Record<string, unknown>;
|
|
63
|
+
const event = i.hook_event_name;
|
|
64
|
+
const conversation =
|
|
65
|
+
typeof i.session_id === "string" && i.session_id ? i.session_id : undefined;
|
|
66
|
+
if (event === "PreCompact" || (event === undefined && "trigger" in i)) {
|
|
67
|
+
// Not counted here: the compaction may not happen.
|
|
68
|
+
const prev = readRotation(args.files);
|
|
69
|
+
notePendingCompaction(args.files, conversation, i.trigger, args.after);
|
|
70
|
+
const counted =
|
|
71
|
+
prev &&
|
|
72
|
+
(!conversation ||
|
|
73
|
+
!prev.conversation ||
|
|
74
|
+
prev.conversation === conversation)
|
|
75
|
+
? prev
|
|
76
|
+
: null;
|
|
77
|
+
const next = (counted?.compactions ?? 0) + 1;
|
|
78
|
+
const due =
|
|
79
|
+
dueFor(counted, conversation) ??
|
|
80
|
+
(args.after > 0 && next >= args.after ? "compactions" : undefined);
|
|
81
|
+
if (!due) return "";
|
|
82
|
+
return [
|
|
83
|
+
"Keep this instruction, word for word, at the top of the summary:",
|
|
84
|
+
rotationInstruction(
|
|
85
|
+
args.files,
|
|
86
|
+
due,
|
|
87
|
+
Math.max(next, counted?.compactions ?? 0),
|
|
88
|
+
),
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
if (event === "SessionStart" || (event === undefined && "source" in i)) {
|
|
92
|
+
if (i.source !== "compact" && i.source !== "resume") return "";
|
|
93
|
+
const s =
|
|
94
|
+
i.source === "compact"
|
|
95
|
+
? noteCompaction(args.files, i, args.after, now)
|
|
96
|
+
: readRotation(args.files);
|
|
97
|
+
const due = dueFor(s, conversation);
|
|
98
|
+
if (!due || readHandoff(args.files).status === "ok") return "";
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
hookSpecificOutput: {
|
|
101
|
+
hookEventName: "SessionStart",
|
|
102
|
+
additionalContext: rotationInstruction(args.files, due, s!.compactions),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main(): Promise<void> {
|
|
110
|
+
const args = parseHookArgs(process.argv.slice(2));
|
|
111
|
+
let text = "";
|
|
112
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
113
|
+
if (!args) return;
|
|
114
|
+
let out = "";
|
|
115
|
+
try {
|
|
116
|
+
out = rotationHookOutput(JSON.parse(text), args);
|
|
117
|
+
} catch {
|
|
118
|
+
out = "";
|
|
119
|
+
}
|
|
120
|
+
if (out) process.stdout.write(`${out}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && /rotation-hook\.[jt]s$/.test(process.argv[1]))
|
|
124
|
+
main().then(
|
|
125
|
+
() => process.exit(0),
|
|
126
|
+
() => process.exit(0),
|
|
127
|
+
);
|
|
@@ -13,17 +13,63 @@
|
|
|
13
13
|
* When Claude is already continuing because of a stop hook
|
|
14
14
|
* (stop_hook_active), it lets the turn end, so it never loops. Any error
|
|
15
15
|
* lets the turn end too.
|
|
16
|
+
*
|
|
17
|
+
* Session rotation (den#4): run with `--files <agent files dir> --after
|
|
18
|
+
* <n>`, while a rotation is due (rotation.ts) and handoff.md is missing or
|
|
19
|
+
* fails the handoff check, it blocks once with what to write. Once the
|
|
20
|
+
* handoff passes, the turn ends as usual.
|
|
16
21
|
*/
|
|
17
22
|
import { bareIds, checkHandback, looksLikeHandback } from "../handback.js";
|
|
23
|
+
import {
|
|
24
|
+
dueFor,
|
|
25
|
+
readHandoff,
|
|
26
|
+
readRotation,
|
|
27
|
+
rotationInstruction,
|
|
28
|
+
} from "../rotation.js";
|
|
29
|
+
import { parseHookArgs, type RotationHookArgs } from "./rotation-hook.js";
|
|
30
|
+
|
|
31
|
+
/** A due rotation's block, or "" (no rotation due, or the handoff passes). */
|
|
32
|
+
function rotationBlock(
|
|
33
|
+
input: { session_id?: unknown },
|
|
34
|
+
args: RotationHookArgs,
|
|
35
|
+
): string {
|
|
36
|
+
const s = readRotation(args.files);
|
|
37
|
+
const conversation =
|
|
38
|
+
typeof input.session_id === "string" ? input.session_id : undefined;
|
|
39
|
+
const due = dueFor(s, conversation);
|
|
40
|
+
if (!due) return "";
|
|
41
|
+
const h = readHandoff(args.files);
|
|
42
|
+
if (h.status === "ok") return "";
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
decision: "block",
|
|
45
|
+
reason: [
|
|
46
|
+
rotationInstruction(args.files, due, s!.compactions),
|
|
47
|
+
...(h.status === "invalid"
|
|
48
|
+
? [
|
|
49
|
+
"Den checked the handoff there and it is not ready. Fix these:",
|
|
50
|
+
...h.problems.map((p) => `- ${p}`),
|
|
51
|
+
]
|
|
52
|
+
: []),
|
|
53
|
+
].join("\n"),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
18
56
|
|
|
19
57
|
/** The hook's stdout for one Stop input ("" lets the turn end). */
|
|
20
|
-
export function stopDecision(
|
|
58
|
+
export function stopDecision(
|
|
59
|
+
input: unknown,
|
|
60
|
+
rotation?: RotationHookArgs | null,
|
|
61
|
+
): string {
|
|
21
62
|
if (!input || typeof input !== "object") return "";
|
|
22
63
|
const i = input as {
|
|
23
64
|
stop_hook_active?: unknown;
|
|
24
65
|
last_assistant_message?: unknown;
|
|
66
|
+
session_id?: unknown;
|
|
25
67
|
};
|
|
26
68
|
if (i.stop_hook_active === true) return "";
|
|
69
|
+
if (rotation) {
|
|
70
|
+
const block = rotationBlock(i, rotation);
|
|
71
|
+
if (block) return block;
|
|
72
|
+
}
|
|
27
73
|
const msg = i.last_assistant_message;
|
|
28
74
|
if (typeof msg !== "string") return "";
|
|
29
75
|
// A hand-back is checked whole. Any other final message is what the
|
|
@@ -52,7 +98,7 @@ async function main(): Promise<void> {
|
|
|
52
98
|
for await (const chunk of process.stdin) text += String(chunk);
|
|
53
99
|
let out = "";
|
|
54
100
|
try {
|
|
55
|
-
out = stopDecision(JSON.parse(text));
|
|
101
|
+
out = stopDecision(JSON.parse(text), parseHookArgs(process.argv.slice(2)));
|
|
56
102
|
} catch {
|
|
57
103
|
out = "";
|
|
58
104
|
}
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* role prompt (personas.ts rolePrompt), so every runtime
|
|
8
8
|
* gets them.
|
|
9
9
|
* hooks.json a Claude Code settings fragment: a PreToolUse hook on
|
|
10
|
-
* Bash and Read (pretooluse.mjs)
|
|
11
|
-
* (stop-hook.ts, the hand-back check)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Bash and Read (pretooluse.mjs), a Stop hook
|
|
11
|
+
* (stop-hook.ts, the hand-back check), and PreCompact
|
|
12
|
+
* and SessionStart (compact, resume) hooks
|
|
13
|
+
* (rotation-hook.ts, session rotation: den#4). den-claude
|
|
14
|
+
* merges it into the agent's own --settings file; Den
|
|
15
|
+
* never writes the user's ~/.claude.
|
|
14
16
|
*
|
|
15
17
|
* On by default for Den's default pack, off for a distribution's packs.
|
|
16
18
|
* A distribution turns them off or on per pack (agents.default_pack, a
|
|
@@ -68,27 +70,56 @@ export interface HookCommands {
|
|
|
68
70
|
pretooluse: string;
|
|
69
71
|
/** The Stop hook's command line. */
|
|
70
72
|
stop: string;
|
|
73
|
+
/** The PreCompact and SessionStart hooks' command line (den#4). */
|
|
74
|
+
rotation: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where an agent's rotation state lives and its limit (den#4): its files
|
|
79
|
+
* dir, and the compactions after which it rotates (0: never).
|
|
80
|
+
*/
|
|
81
|
+
export interface RotationArgs {
|
|
82
|
+
files: string;
|
|
83
|
+
after: number;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
/** The command lines Den's hooks run: this node, Den's own scripts. */
|
|
74
87
|
export function hookCommands(
|
|
75
88
|
dir: string = GUARDRAILS_DIR,
|
|
76
89
|
node: string = process.execPath,
|
|
90
|
+
rotation?: RotationArgs,
|
|
77
91
|
): HookCommands {
|
|
92
|
+
const more = (c: EngineCommand): EngineCommand =>
|
|
93
|
+
rotation
|
|
94
|
+
? {
|
|
95
|
+
command: c.command,
|
|
96
|
+
args: [
|
|
97
|
+
...c.args,
|
|
98
|
+
"--files",
|
|
99
|
+
rotation.files,
|
|
100
|
+
"--after",
|
|
101
|
+
String(rotation.after),
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
: c;
|
|
78
105
|
return {
|
|
79
106
|
pretooluse: commandLine({
|
|
80
107
|
command: node,
|
|
81
108
|
args: [path.join(dir, "pretooluse.mjs")],
|
|
82
109
|
}),
|
|
83
110
|
stop: commandLine(
|
|
84
|
-
scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts"),
|
|
111
|
+
more(scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts")),
|
|
112
|
+
),
|
|
113
|
+
rotation: commandLine(
|
|
114
|
+
more(scriptEntrypoint("src/shared/agents/guardrails/rotation-hook.ts")),
|
|
85
115
|
),
|
|
86
116
|
};
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
/**
|
|
90
120
|
* The `hooks` block of the agent's settings file: hooks.json with each
|
|
91
|
-
* `{pretooluse}` / `{stop}` placeholder replaced by its
|
|
121
|
+
* `{pretooluse}` / `{stop}` / `{rotation}` placeholder replaced by its
|
|
122
|
+
* command line.
|
|
92
123
|
*/
|
|
93
124
|
export function guardrailHooks(
|
|
94
125
|
dir: string = GUARDRAILS_DIR,
|
|
@@ -100,7 +131,7 @@ export function guardrailHooks(
|
|
|
100
131
|
const fill = (v: unknown): unknown => {
|
|
101
132
|
if (typeof v === "string")
|
|
102
133
|
return v.replace(
|
|
103
|
-
/\{(pretooluse|stop)\}/g,
|
|
134
|
+
/\{(pretooluse|stop|rotation)\}/g,
|
|
104
135
|
(_, k: keyof HookCommands) => cmds[k],
|
|
105
136
|
);
|
|
106
137
|
if (Array.isArray(v)) return v.map(fill);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff check (den#4, session rotation): is a handoff.md complete
|
|
3
|
+
* and plain? A session that rotates (after agents.rotate.after_compactions
|
|
4
|
+
* compactions, or once its pull request merged) writes one in its files
|
|
5
|
+
* dir; Den relaunches the role from it, in a fresh conversation whose
|
|
6
|
+
* first prompt is the handoff. `<cli> agents handoff-check <file|->` runs
|
|
7
|
+
* the check, and so does the Stop hook while a rotation is due.
|
|
8
|
+
*
|
|
9
|
+
* The template (guardrails/instructions.md):
|
|
10
|
+
*
|
|
11
|
+
* ## Objective
|
|
12
|
+
* <what the work is for, in a sentence or two>
|
|
13
|
+
* ## State
|
|
14
|
+
* <what is done and where it lives: branch, pull request, files>
|
|
15
|
+
* ## Open items
|
|
16
|
+
* <what is left, one per line; or none>
|
|
17
|
+
* ## How to QA
|
|
18
|
+
* <the steps to see it working>
|
|
19
|
+
* ## Next step
|
|
20
|
+
* <the one thing to do first>
|
|
21
|
+
*
|
|
22
|
+
* It rejects a handoff when:
|
|
23
|
+
*
|
|
24
|
+
* 1. A part is missing or empty. A part is a heading that is its label
|
|
25
|
+
* (`## Open items`) or a line that starts with its label and a colon
|
|
26
|
+
* (`Next step: open the PR`). Its content is the rest of the line and
|
|
27
|
+
* the lines after it, up to the next part or heading.
|
|
28
|
+
* 2. It has a bare ID: the hand-back check's rule (handback.ts bareIds).
|
|
29
|
+
* Code, links and code blocks are not scanned.
|
|
30
|
+
* 3. It is longer than HANDOFF_MAX_CHARS: it is the next session's first
|
|
31
|
+
* prompt, so it must stay short.
|
|
32
|
+
*/
|
|
33
|
+
import { bareIds } from "./handback.js";
|
|
34
|
+
|
|
35
|
+
export const HANDOFF_PARTS = [
|
|
36
|
+
"Objective",
|
|
37
|
+
"State",
|
|
38
|
+
"Open items",
|
|
39
|
+
"How to QA",
|
|
40
|
+
"Next step",
|
|
41
|
+
] as const;
|
|
42
|
+
export type HandoffPart = (typeof HANDOFF_PARTS)[number];
|
|
43
|
+
|
|
44
|
+
/** The longest handoff Den passes on (it becomes a first prompt). */
|
|
45
|
+
export const HANDOFF_MAX_CHARS = 20_000;
|
|
46
|
+
|
|
47
|
+
export interface HandoffResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
/** One line each, plain, naming what to fix. */
|
|
50
|
+
problems: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const LABEL = new RegExp(
|
|
54
|
+
`^\\s*(?:[-*+]\\s+)?(#{1,6}\\s+)?(?:\\*\\*|__)?(${HANDOFF_PARTS.map((p) =>
|
|
55
|
+
p.replace(/ /g, "\\s+"),
|
|
56
|
+
).join("|")})(?:\\*\\*|__)?\\s*(:)?(?:\\*\\*|__)?\\s*(.*)$`,
|
|
57
|
+
"i",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
function canonical(label: string): HandoffPart {
|
|
61
|
+
const l = label.replace(/\s+/g, " ").toLowerCase();
|
|
62
|
+
return HANDOFF_PARTS.find((p) => p.toLowerCase() === l)!;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Each part's content lines, by part. */
|
|
66
|
+
export function handoffParts(text: string): Map<HandoffPart, string[]> {
|
|
67
|
+
const found = new Map<HandoffPart, string[]>();
|
|
68
|
+
let cur: HandoffPart | null = null;
|
|
69
|
+
let fence = false;
|
|
70
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
71
|
+
const fenceLine = /^\s*(```|~~~)/.test(line);
|
|
72
|
+
if (fenceLine) fence = !fence;
|
|
73
|
+
const m = fence || fenceLine ? null : LABEL.exec(line);
|
|
74
|
+
// A label needs a colon, or to be the whole heading.
|
|
75
|
+
if (m && (m[3] || (m[1] && !m[4].trim()))) {
|
|
76
|
+
cur = canonical(m[2]);
|
|
77
|
+
const body = m[4].trim();
|
|
78
|
+
found.set(cur, body ? [body] : []);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!fence && /^\s*#{1,6}\s/.test(line)) {
|
|
82
|
+
// The document's own title, or another heading: no part's content.
|
|
83
|
+
cur = null;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (cur && line.trim()) found.get(cur)!.push(line.trim());
|
|
87
|
+
}
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Check a handoff. Pure: the text in, the problems out. */
|
|
92
|
+
export function checkHandoff(text: string): HandoffResult {
|
|
93
|
+
const s = String(text ?? "");
|
|
94
|
+
const problems: string[] = [];
|
|
95
|
+
if (!s.trim())
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
problems: [
|
|
99
|
+
`The handoff is empty. Write ${HANDOFF_PARTS.map((p) => `"## ${p}"`).join(", ")}, each with its part filled in.`,
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
const found = handoffParts(s);
|
|
103
|
+
for (const part of HANDOFF_PARTS) {
|
|
104
|
+
const body = found.get(part);
|
|
105
|
+
if (body === undefined) problems.push(`Missing "## ${part}". Add it.`);
|
|
106
|
+
else if (!body.join(" ").trim())
|
|
107
|
+
problems.push(`"## ${part}" is empty. Fill it in.`);
|
|
108
|
+
}
|
|
109
|
+
if (s.length > HANDOFF_MAX_CHARS)
|
|
110
|
+
problems.push(
|
|
111
|
+
`The handoff is ${s.length} characters long. Keep it under ${HANDOFF_MAX_CHARS}: it is the next session's first prompt.`,
|
|
112
|
+
);
|
|
113
|
+
for (const id of bareIds(s))
|
|
114
|
+
problems.push(
|
|
115
|
+
`"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
|
|
116
|
+
);
|
|
117
|
+
return { ok: problems.length === 0, problems };
|
|
118
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* tools "Read, Grep" or a list: hints, never a restriction Den applies
|
|
14
14
|
*
|
|
15
15
|
* Den's own keys sit beside them: `runtime` (default den-claude),
|
|
16
|
-
* `standing` (starts with its workspace, §5
|
|
16
|
+
* `standing` (starts with its workspace, §5; resumes after a restart),
|
|
17
|
+
* `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
|
|
17
18
|
* first), `argv` (the generic runtime's template), and `permission_mode`
|
|
18
19
|
* (den-claude's Claude Code permission mode; it checks the value), and
|
|
19
20
|
* `den_hooks` / `den_instructions` (true or false: this persona's
|
|
@@ -45,6 +46,11 @@ export interface Persona {
|
|
|
45
46
|
model?: string;
|
|
46
47
|
/** Started with its workspace (planner, orchestrator). */
|
|
47
48
|
standing: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* `resume_on_restart: true` (AG-14.3): resumed on its own when Den or
|
|
51
|
+
* the machine restarted. Standing personas always are.
|
|
52
|
+
*/
|
|
53
|
+
resumeOnRestart?: boolean;
|
|
48
54
|
/** Tool hints. */
|
|
49
55
|
tools: string[];
|
|
50
56
|
/** What to read first (specs, the design system, workspace config). */
|
|
@@ -135,6 +141,9 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
135
141
|
const standing = f.standing ?? false;
|
|
136
142
|
if (typeof standing !== "boolean")
|
|
137
143
|
throw new Error(`${file}: standing must be true or false`);
|
|
144
|
+
const resumeOnRestart = f.resume_on_restart ?? false;
|
|
145
|
+
if (typeof resumeOnRestart !== "boolean")
|
|
146
|
+
throw new Error(`${file}: resume_on_restart must be true or false`);
|
|
138
147
|
let argv: string[] | undefined;
|
|
139
148
|
if (f.argv !== undefined && f.argv !== null) {
|
|
140
149
|
if (
|
|
@@ -166,6 +175,7 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
166
175
|
...(runtime ? { runtime } : {}),
|
|
167
176
|
...(model && model !== "inherit" ? { model } : {}),
|
|
168
177
|
standing,
|
|
178
|
+
...(resumeOnRestart ? { resumeOnRestart } : {}),
|
|
169
179
|
tools: stringList(f, "tools", file),
|
|
170
180
|
context: stringList(f, "context", file),
|
|
171
181
|
...(argv ? { argv } : {}),
|
|
@@ -4,16 +4,23 @@
|
|
|
4
4
|
* same agents, and so an agent outlives the session it runs in.
|
|
5
5
|
*
|
|
6
6
|
* agents/<id>.json the record (0600, its directory 0700)
|
|
7
|
-
* agent-files/<id>/ files its runtime wrote (0600): its MCP config
|
|
7
|
+
* agent-files/<id>/ files its runtime wrote (0600): its MCP config;
|
|
8
|
+
* and its rotation state and handoff.md (den#4)
|
|
8
9
|
*
|
|
9
10
|
* A record never holds the env or the argv: the role prompt, tokens and
|
|
10
11
|
* anything else a launch passes stay out of it. An agent id is unique in a
|
|
11
12
|
* distribution.
|
|
13
|
+
*
|
|
14
|
+
* AG-14: a removed agent's record moves to removed-agents/<id>.json, so
|
|
15
|
+
* Undo can bring it back; restoreRecord does, and purgeRemoved forgets
|
|
16
|
+
* the ones older than the undo window. The runtime's own conversation
|
|
17
|
+
* history is never touched.
|
|
12
18
|
*/
|
|
13
19
|
import * as fs from "fs";
|
|
14
20
|
import * as path from "path";
|
|
15
21
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
16
22
|
import type { SessionRef } from "../sessions/types.js";
|
|
23
|
+
import type { RotationEntry } from "./rotation.js";
|
|
17
24
|
|
|
18
25
|
export interface AgentRecord {
|
|
19
26
|
id: string;
|
|
@@ -34,6 +41,59 @@ export interface AgentRecord {
|
|
|
34
41
|
resumedAt?: string;
|
|
35
42
|
/** The runtime's handle for resume (den-claude: the conversation id). */
|
|
36
43
|
resumeToken?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The runtime was launched on resumeToken at least once (0.4.8). A
|
|
46
|
+
* resume then always tries the runtime's own resume first, never a new
|
|
47
|
+
* conversation under the same id (which the runtime refuses once the
|
|
48
|
+
* conversation exists).
|
|
49
|
+
*/
|
|
50
|
+
launched?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The workspaces beyond its own its MCP served when it started
|
|
53
|
+
* (agents.<id>.grants then). A resume keeps them, never more than the
|
|
54
|
+
* config still allows (AG-14).
|
|
55
|
+
*/
|
|
56
|
+
grants?: string[];
|
|
57
|
+
/** When its session was seen to end on its own (AG-14: "Exited"). */
|
|
58
|
+
endedAt?: string;
|
|
59
|
+
/** That session's exit code, when the host said it. */
|
|
60
|
+
exitCode?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Set when its session was gone after Den or the machine restarted
|
|
63
|
+
* (AG-14.3: "Interrupted"); cleared by a resume or a start.
|
|
64
|
+
*/
|
|
65
|
+
interruptedAt?: string;
|
|
66
|
+
/** What its run came to (den#6), filled in by `<cli> usage`. */
|
|
67
|
+
outcome?: AgentOutcome;
|
|
68
|
+
/** Its tokens, from the runtime's own transcript (den#6). */
|
|
69
|
+
usage?: AgentUsage;
|
|
70
|
+
/**
|
|
71
|
+
* Each time Den relaunched it from its handoff (den#4, session
|
|
72
|
+
* rotation), oldest first.
|
|
73
|
+
*/
|
|
74
|
+
rotations?: RotationEntry[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What an agent's run came to (den#6). Read locally; never sent. */
|
|
78
|
+
export interface AgentOutcome {
|
|
79
|
+
/** The pull request it opened, when it opened one. */
|
|
80
|
+
prOpened?: string;
|
|
81
|
+
/** That pull request merged in the run. */
|
|
82
|
+
prMerged?: boolean;
|
|
83
|
+
/** The last test run it made passed (true) or failed (false). */
|
|
84
|
+
testsGreen?: boolean;
|
|
85
|
+
/** From its start to its last activity, in milliseconds. */
|
|
86
|
+
wallClockMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An agent's token counts (den#6), summed over its transcripts. */
|
|
90
|
+
export interface AgentUsage {
|
|
91
|
+
calls: number;
|
|
92
|
+
input: number;
|
|
93
|
+
output: number;
|
|
94
|
+
cacheRead: number;
|
|
95
|
+
cacheWrite: number;
|
|
96
|
+
compactions: number;
|
|
37
97
|
}
|
|
38
98
|
|
|
39
99
|
/** An agent id: the same rules as a workspace or session name. */
|
|
@@ -85,6 +145,67 @@ export function readRecord(stateDir: string, id: string): AgentRecord | null {
|
|
|
85
145
|
}
|
|
86
146
|
}
|
|
87
147
|
|
|
148
|
+
/** Where removed records wait for Undo (AG-14.1b). */
|
|
149
|
+
export function removedDir(stateDir: string): string {
|
|
150
|
+
return path.join(stateDir, "removed-agents");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** How long a removed record can be brought back. */
|
|
154
|
+
export const UNDO_KEEP_MS = 60 * 60 * 1000;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Forget agent `id` (its record moves aside for Undo). True when there
|
|
158
|
+
* was one. Its files and the runtime's history stay.
|
|
159
|
+
*/
|
|
160
|
+
export function removeRecord(stateDir: string, id: string): boolean {
|
|
161
|
+
const from = recordFile(stateDir, id);
|
|
162
|
+
if (!fs.existsSync(from)) return false;
|
|
163
|
+
ensureDir(removedDir(stateDir));
|
|
164
|
+
const to = path.join(removedDir(stateDir), `${id}.json`);
|
|
165
|
+
fs.renameSync(from, to);
|
|
166
|
+
// Its mtime is when it was removed: purgeRemoved goes by it.
|
|
167
|
+
const now = new Date();
|
|
168
|
+
fs.utimesSync(to, now, now);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Bring a removed record back (Undo). False when there is none to bring
|
|
174
|
+
* back, or an agent of that id exists again.
|
|
175
|
+
*/
|
|
176
|
+
export function restoreRecord(stateDir: string, id: string): boolean {
|
|
177
|
+
if (!AGENT_ID_RE.test(id)) return false;
|
|
178
|
+
const from = path.join(removedDir(stateDir), `${id}.json`);
|
|
179
|
+
const to = recordFile(stateDir, id);
|
|
180
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return false;
|
|
181
|
+
ensureDir(agentsDir(stateDir));
|
|
182
|
+
fs.renameSync(from, to);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Forget removed records older than `keepMs` for good. */
|
|
187
|
+
export function purgeRemoved(
|
|
188
|
+
stateDir: string,
|
|
189
|
+
keepMs = UNDO_KEEP_MS,
|
|
190
|
+
now = Date.now(),
|
|
191
|
+
): void {
|
|
192
|
+
let names: string[];
|
|
193
|
+
try {
|
|
194
|
+
names = fs.readdirSync(removedDir(stateDir));
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
for (const n of names) {
|
|
199
|
+
if (!n.endsWith(".json") || !AGENT_ID_RE.test(n.slice(0, -5))) continue;
|
|
200
|
+
const f = path.join(removedDir(stateDir), n);
|
|
201
|
+
try {
|
|
202
|
+
if (now - fs.statSync(f).mtimeMs > keepMs) fs.unlinkSync(f);
|
|
203
|
+
} catch {
|
|
204
|
+
/* gone already */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
88
209
|
export function listRecords(stateDir: string): AgentRecord[] {
|
|
89
210
|
let names: string[];
|
|
90
211
|
try {
|