@monster-orange/den 0.4.22 → 0.4.24
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 +73 -0
- package/docs/distributions.md +91 -30
- package/package.json +1 -1
- package/service/install.sh +5 -3
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/den-config.ts +27 -12
- package/src/ui/cli/agent-ops.ts +514 -15
- package/src/ui/cli/agents-command.ts +169 -3
- package/src/ui/cli/doctor-command.ts +24 -1
- package/src/ui/cli/doctor-where.ts +59 -13
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/web-command.ts +32 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +161 -5
- package/src/ui/server/index.ts +13 -5
- package/src/ui/server/public/agents-page.js +214 -22
- package/src/ui/server/public/agents.html +32 -0
- package/src/ui/server/public/combobox.js +150 -0
- package/src/ui/server/public/error-page.js +26 -14
- package/src/ui/server/public/i18n/es.js +47 -4
- package/src/ui/server/public/i18n/ja.js +43 -4
- package/src/ui/server/public/settings-registry.js +318 -0
- package/src/ui/server/public/settings.html +166 -4
- package/src/ui/server/public/shell.js +62 -16
- package/src/ui/server/public/svc-panel.js +54 -15
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +23 -5
- package/src/ui/server/ui-assets.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,79 @@ 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.24] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Visit opens in Safari (D27).** Visit, in the service panel and the
|
|
12
|
+
rail's ↗, is now a real link (`target=_blank`) instead of a button that
|
|
13
|
+
called `window.open`, which Safari's popup blocker stopped. The panel
|
|
14
|
+
also holds its 5 s repaint while a pointer is down in it, so a click
|
|
15
|
+
is never lost to a repaint between press and release.
|
|
16
|
+
- **Signing in from another machine (D29).** On an unclaimed Den with
|
|
17
|
+
`web.owner_github` set, "Sign in as @login" is the primary button. The
|
|
18
|
+
button that only works on the Den machine says so: "On <host> itself:
|
|
19
|
+
connect GitHub". With only `web.allowed_github_org`, the page says to
|
|
20
|
+
open it on that machine, or to set `web.owner_github`.
|
|
21
|
+
- **Rotation overrides are picked by name (D30).** Settings › Agents ›
|
|
22
|
+
Rotation's Add override form has searchable pickers (type to filter,
|
|
23
|
+
arrow keys, Enter) for the agent, persona, workspace or service, and a
|
|
24
|
+
choice for each value, never a typed id. The overrides table shows
|
|
25
|
+
names; an override for something that is gone is greyed, with Remove.
|
|
26
|
+
At 375 px the table stacks into cards, so no word breaks mid-letter.
|
|
27
|
+
- **doctor, where it runs.** In login mode (as installed), the server and
|
|
28
|
+
session host in the desktop session show ℹ, not ⚠. An older session
|
|
29
|
+
host still serving, with the service's own host standing by, is ℹ
|
|
30
|
+
"Nothing to do", not "unknown ✓". The version-manager node warning at
|
|
31
|
+
`service install` prints once, for the web job.
|
|
32
|
+
|
|
33
|
+
## [0.4.23] - 2026-11-10
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Agents relaunch from their handoff by themselves (D28).** When an
|
|
38
|
+
agent's rotation is due (after `after_compactions` compactions, or its
|
|
39
|
+
pull request merged), Den asks it for its handoff at its next idle,
|
|
40
|
+
never mid-turn, and relaunches it once the handoff passes: a fresh
|
|
41
|
+
conversation with the same name, persona, workspace and folder. A toast
|
|
42
|
+
and the audit log say so, and the old conversation is kept. Den asks
|
|
43
|
+
twice at most, then falls back to asking you.
|
|
44
|
+
- **The rotation callout has buttons, running or stopped**, on the row and
|
|
45
|
+
in the pane bar: Relaunch now (asking for the handoff first if needed),
|
|
46
|
+
Write handoff, Keep this conversation (with "Don't ask again for this
|
|
47
|
+
agent") and Settings. It says what happens next and where the settings
|
|
48
|
+
come from. Before, a running agent got a message and nothing to press,
|
|
49
|
+
and Stop then Start showed the same message again.
|
|
50
|
+
- **Rotation settings at every scope.** `after_compactions` (a number or
|
|
51
|
+
`never`), `on_due` (`auto`, the default, `ask` or `off`) and
|
|
52
|
+
`on_pr_merged`. The most specific wins: agent, persona in a workspace,
|
|
53
|
+
persona, workspace, service, this install, org config. Settings › Agents
|
|
54
|
+
› Rotation and `<cli> agents rotate` set them; `<cli> agents rotate <id>
|
|
55
|
+
--show` names where each value comes from. `<cli> agents keep` and
|
|
56
|
+
`<cli> agents handoff` are the callout's buttons. A change applies to
|
|
57
|
+
running agents too.
|
|
58
|
+
- Resume (or Start) on a due agent follows `on_due`: `auto` relaunches it
|
|
59
|
+
from its handoff, `ask` refuses with the callout's choices, `off` goes
|
|
60
|
+
on with the same conversation.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- **`web restart` with the service installed** restarts the launchd job
|
|
65
|
+
and names its pid and the service's log. Before, it started a plain
|
|
66
|
+
server, said "started", and that server died seconds later because
|
|
67
|
+
launchd had brought the job back onto the port. `web up` starts a job
|
|
68
|
+
that isn't answering; `web down` refuses and names `service uninstall`.
|
|
69
|
+
- The "port in use" message no longer says to kill whatever holds the
|
|
70
|
+
port, which killed the service. It names `service status` and `web
|
|
71
|
+
restart`.
|
|
72
|
+
|
|
73
|
+
### Migrations
|
|
74
|
+
|
|
75
|
+
- None required. Rotation now relaunches by itself (`on_due: auto`). To
|
|
76
|
+
keep den#4's behavior (a person relaunches), set `on_due: ask` under
|
|
77
|
+
`agents.rotate` in the org config, or `<cli> agents rotate on_due ask`.
|
|
78
|
+
`agents.rotate.standing` still works; a persona override replaces it.
|
|
79
|
+
|
|
7
80
|
## [0.4.22] - 2026-11-10
|
|
8
81
|
|
|
9
82
|
### Added
|
package/docs/distributions.md
CHANGED
|
@@ -1969,6 +1969,14 @@ terminal and agent) as launchd jobs, restarted if they exit:
|
|
|
1969
1969
|
./bin/acme service uninstall # stop them and remove their plists
|
|
1970
1970
|
```
|
|
1971
1971
|
|
|
1972
|
+
With the service installed, `web` works through launchd. `web restart`
|
|
1973
|
+
restarts the job (`launchctl kickstart -k`) and names its new pid and the
|
|
1974
|
+
service's log. `web up` starts the job when it isn't answering. `web down`
|
|
1975
|
+
refuses, because launchd would only start it again: use `service
|
|
1976
|
+
uninstall` to stop it. Under `always`, restarting needs sudo, so `web
|
|
1977
|
+
restart` prints the `sudo launchctl kickstart -k system/<label>` line for
|
|
1978
|
+
you to run.
|
|
1979
|
+
|
|
1972
1980
|
### Login or always
|
|
1973
1981
|
|
|
1974
1982
|
| `--mode` | Jobs | At logout |
|
|
@@ -3296,10 +3304,12 @@ report. It works on den-claude for personas with Den's hooks on.
|
|
|
3296
3304
|
the turn once while the handoff is missing or fails the check. The
|
|
3297
3305
|
instruction: write `handoff.md` in the agent's files folder, then end
|
|
3298
3306
|
the turn.
|
|
3299
|
-
4. Den relaunches the role
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3307
|
+
4. Den relaunches the role, by itself unless the settings say otherwise
|
|
3308
|
+
(`on_due`, below). A relaunch starts a fresh conversation (a new
|
|
3309
|
+
conversation id) with the same name, persona, workspace, folder and
|
|
3310
|
+
grants. Its first prompt is the handoff. The handoff moves to
|
|
3311
|
+
`agent-files/<id>/handoffs/`, the record notes the rotation, and the
|
|
3312
|
+
old conversation is kept.
|
|
3303
3313
|
|
|
3304
3314
|
**The handoff** has five parts, each a heading, each filled in. Write it
|
|
3305
3315
|
plainly, and explain every ID, as in a hand-back. It must stay under 20,000
|
|
@@ -3332,38 +3342,89 @@ agent's `handoff.md` (by its id), a file, or stdin: exit 0 when
|
|
|
3332
3342
|
it passes, 1 with each missing or empty part and each unexplained ID. A
|
|
3333
3343
|
part can also be a line such as `Next step: open the PR`.
|
|
3334
3344
|
|
|
3335
|
-
**
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
(
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
**
|
|
3345
|
+
**What happens when a rotation is due** (`on_due`):
|
|
3346
|
+
|
|
3347
|
+
- `auto` (the default). At the agent's next idle, never mid-turn, Den
|
|
3348
|
+
types the instruction into its session: write the handoff. Once the
|
|
3349
|
+
handoff passes the check and the agent is idle, Den relaunches it. The
|
|
3350
|
+
page shows a toast ("Relaunched <id> from its handoff after 3
|
|
3351
|
+
compactions. The old conversation <id> is kept.") and the audit log
|
|
3352
|
+
records it. Den asks at most twice, two minutes apart. If the handoff
|
|
3353
|
+
still fails, it falls back to `ask`.
|
|
3354
|
+
- `ask`. Nothing happens by itself. The agent's callout has the buttons.
|
|
3355
|
+
- `off`. Nothing happens, and no callout shows. Resume goes on with the
|
|
3356
|
+
same conversation.
|
|
3357
|
+
|
|
3358
|
+
**The callout** shows on a due agent's row (running or stopped) and in its
|
|
3359
|
+
pane bar. It says what happens next and where the settings come from
|
|
3360
|
+
("Rotates after 3 compactions (persona planner); when due: relaunch by
|
|
3361
|
+
itself (default)"). Its buttons:
|
|
3362
|
+
|
|
3363
|
+
- **Relaunch now**: from the handoff when it passes. Otherwise Den asks
|
|
3364
|
+
for it first (a stopped agent resumes to write it) and relaunches it
|
|
3365
|
+
once it passes, whatever `on_due` says.
|
|
3366
|
+
- **Write handoff**: on a running agent without one, types the
|
|
3367
|
+
instruction. It is refused mid-turn.
|
|
3368
|
+
- **Keep this conversation**: the compaction count starts again. With
|
|
3369
|
+
"Don't ask again for this agent", that agent's `after_compactions` is
|
|
3370
|
+
set to `never`. A person's only.
|
|
3371
|
+
- **Settings**: Settings › Agents › Rotation.
|
|
3372
|
+
|
|
3373
|
+
**Resume and Start on a due agent** follow `on_due`. Under `auto`, Resume
|
|
3374
|
+
relaunches it from its handoff once the handoff passes. Under `ask`,
|
|
3375
|
+
`<cli> agents resume <id>` refuses (exit 1) and names
|
|
3376
|
+
`<cli> agents relaunch <id>`, and the resume route answers 409 with code
|
|
3377
|
+
`rotated`. `<cli> agents relaunch <id> [-w <ws>]` and
|
|
3378
|
+
`POST /api/workspaces/<ws>/agents/<id>/relaunch` relaunch it at any time
|
|
3379
|
+
(a running agent is stopped first; a missing or failing handoff is a 409
|
|
3380
|
+
`no_handoff`). After a restart, a standing persona that rotated relaunches
|
|
3381
|
+
on its own.
|
|
3382
|
+
|
|
3383
|
+
**Settings.** Three keys:
|
|
3384
|
+
|
|
3385
|
+
| Key | Values | Default |
|
|
3386
|
+
| ------------------- | ---------------------- | ------- |
|
|
3387
|
+
| `after_compactions` | 1 to 100, or `never` | 3 |
|
|
3388
|
+
| `on_due` | `auto`, `ask` or `off` | `auto` |
|
|
3389
|
+
| `on_pr_merged` | `true` or `false` | `true` |
|
|
3390
|
+
|
|
3391
|
+
Each key comes from the most specific scope that sets it: the agent, the
|
|
3392
|
+
persona in a workspace, the persona, the workspace, the service (the
|
|
3393
|
+
workspace service whose folder holds the agent's folder), this install,
|
|
3394
|
+
the org config, then Den's default. A change applies to running agents
|
|
3395
|
+
too, at their next look (about every 15 seconds).
|
|
3396
|
+
|
|
3397
|
+
The org's values go in the org config:
|
|
3354
3398
|
|
|
3355
3399
|
```yaml
|
|
3356
3400
|
agents:
|
|
3357
3401
|
rotate:
|
|
3358
|
-
after_compactions: 3 # 1 to 100
|
|
3359
|
-
|
|
3402
|
+
after_compactions: 3 # 1 to 100, or never
|
|
3403
|
+
on_due: auto # auto, ask or off
|
|
3404
|
+
on_pr_merged: true
|
|
3360
3405
|
```
|
|
3361
3406
|
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3407
|
+
This install's values and every override are set in Settings › Agents ›
|
|
3408
|
+
Rotation, or from the command line, and kept in
|
|
3409
|
+
`<stateDir>/agent-rotation.json`:
|
|
3410
|
+
|
|
3411
|
+
```bash
|
|
3412
|
+
<cli> agents rotate # every value and override
|
|
3413
|
+
<cli> agents rotate on_due ask # this install
|
|
3414
|
+
<cli> agents rotate --persona planner after_compactions never
|
|
3415
|
+
<cli> agents rotate --persona planner -w notes after_compactions 5 # that persona in notes
|
|
3416
|
+
<cli> agents rotate --workspace notes on_due off
|
|
3417
|
+
<cli> agents rotate --service shop on_pr_merged false
|
|
3418
|
+
<cli> agents rotate --agent art --unset after_compactions
|
|
3419
|
+
<cli> agents rotate art --show # its values, and where each comes from
|
|
3420
|
+
<cli> agents keep <id> [--never] # Keep this conversation
|
|
3421
|
+
<cli> agents handoff <id> # Write handoff
|
|
3422
|
+
```
|
|
3423
|
+
|
|
3424
|
+
`agents rotate` and `agents keep` are refused inside an agent's session.
|
|
3425
|
+
den#4's `standing: false` still works: standing personas never rotate,
|
|
3426
|
+
unless a persona or agent override says otherwise. Set it per persona
|
|
3427
|
+
instead (`--persona planner after_compactions never`).
|
|
3367
3428
|
|
|
3368
3429
|
### Runtimes
|
|
3369
3430
|
|
package/package.json
CHANGED
package/service/install.sh
CHANGED
|
@@ -110,8 +110,9 @@ pick_node() {
|
|
|
110
110
|
done
|
|
111
111
|
# Warn when installing; `print` writes the plist and nothing else.
|
|
112
112
|
if [ "${1:-}" = install ]; then
|
|
113
|
-
echo "⚠ the
|
|
114
|
-
echo " when that version is removed
|
|
113
|
+
echo "⚠ the web job will run $node, a version manager's node: it stops starting" >&2
|
|
114
|
+
echo " when that version is removed (the session host runs Den's own copy of node)." >&2
|
|
115
|
+
echo " Re-run install after switching, or set DEN_SERVICE_NODE." >&2
|
|
115
116
|
fi
|
|
116
117
|
echo "$node"
|
|
117
118
|
}
|
|
@@ -165,7 +166,8 @@ host_exe() { # $1 node
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
write_plist() { # $1 = output file, $2 = web|host, $3 = mode
|
|
168
|
-
|
|
169
|
+
# Warned once, for the web job: the host job runs Den's node copy.
|
|
170
|
+
NODE="$(pick_node "$([ "$2" = web ] && echo "$MODE")")"
|
|
169
171
|
TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
|
|
170
172
|
local exe="$NODE" label="$LABEL" log="web.log"
|
|
171
173
|
if [ "$2" = host ]; then
|
|
@@ -59,6 +59,11 @@ export function rotationHookOutput(
|
|
|
59
59
|
now: Date = new Date(),
|
|
60
60
|
): string {
|
|
61
61
|
if (!input || typeof input !== "object") return "";
|
|
62
|
+
// D28: the limit in force is the one Den keeps in rotation.json (it
|
|
63
|
+
// follows the settings); the launch's --after is the first one.
|
|
64
|
+
const stored = readRotation(args.files);
|
|
65
|
+
if (stored && stored.after !== args.after)
|
|
66
|
+
args = { ...args, after: stored.after };
|
|
62
67
|
const i = input as Record<string, unknown>;
|
|
63
68
|
const event = i.hook_event_name;
|
|
64
69
|
const conversation =
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D28: session-rotation settings and where each comes from.
|
|
3
|
+
*
|
|
4
|
+
* after_compactions a whole number (1–100), or "never"; default 3
|
|
5
|
+
* on_due auto | ask | off; default auto
|
|
6
|
+
* auto: Den asks the agent for its handoff at its next
|
|
7
|
+
* idle and relaunches it from it by itself
|
|
8
|
+
* ask: the callout's buttons (Relaunch now, Write
|
|
9
|
+
* handoff, Keep this conversation, Settings)
|
|
10
|
+
* off: nothing happens; the count is still shown
|
|
11
|
+
* on_pr_merged true | false; default true
|
|
12
|
+
*
|
|
13
|
+
* Scopes, the most specific wins, each key on its own:
|
|
14
|
+
* agent › persona in a workspace › persona › workspace › service (the
|
|
15
|
+
* workspace service whose folder holds the agent's) › install (Settings ›
|
|
16
|
+
* Agents › Rotation, this machine) › org (`agents.rotate` in the org
|
|
17
|
+
* config) › Den's default.
|
|
18
|
+
*
|
|
19
|
+
* The install value and every override live in
|
|
20
|
+
* `<stateDir>/agent-rotation.json` (0600; never under agents/, where every
|
|
21
|
+
* file is an agent's record). The org value is the org config's.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as path from "path";
|
|
25
|
+
import { writePrivate } from "./records.js";
|
|
26
|
+
|
|
27
|
+
export type OnDue = "auto" | "ask" | "off";
|
|
28
|
+
|
|
29
|
+
/** One scope's settings, as written: any key may be absent. */
|
|
30
|
+
export interface RotateValues {
|
|
31
|
+
after_compactions?: number | "never";
|
|
32
|
+
on_due?: OnDue;
|
|
33
|
+
on_pr_merged?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RotateKey = keyof RotateValues;
|
|
37
|
+
export const ROTATE_KEYS: readonly RotateKey[] = [
|
|
38
|
+
"after_compactions",
|
|
39
|
+
"on_due",
|
|
40
|
+
"on_pr_merged",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** The scopes an override can be set at (most specific first). */
|
|
44
|
+
export const OVERRIDE_SCOPES = [
|
|
45
|
+
"agent",
|
|
46
|
+
"persona-workspace",
|
|
47
|
+
"persona",
|
|
48
|
+
"workspace",
|
|
49
|
+
"service",
|
|
50
|
+
] as const;
|
|
51
|
+
export type OverrideScope = (typeof OVERRIDE_SCOPES)[number];
|
|
52
|
+
export type RotateScope = OverrideScope | "install" | "org" | "default";
|
|
53
|
+
|
|
54
|
+
export const ROTATE_DEFAULTS: Required<RotateValues> = {
|
|
55
|
+
after_compactions: 3,
|
|
56
|
+
on_due: "auto",
|
|
57
|
+
on_pr_merged: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** `<stateDir>/agent-rotation.json`. */
|
|
61
|
+
export interface RotateFile {
|
|
62
|
+
install?: RotateValues;
|
|
63
|
+
overrides?: Partial<Record<OverrideScope, Record<string, RotateValues>>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Who an agent is, for resolving its settings. */
|
|
67
|
+
export interface RotateSubject {
|
|
68
|
+
agent: string;
|
|
69
|
+
persona: string;
|
|
70
|
+
workspace: string;
|
|
71
|
+
/** The workspace service whose folder holds the agent's, if any. */
|
|
72
|
+
service?: string;
|
|
73
|
+
/** A standing persona (planner, orchestrator): `standing: false` in the org config keeps it from rotating. */
|
|
74
|
+
standing?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where a value came from: "default", or a scope and its target. */
|
|
78
|
+
export interface RotateSource {
|
|
79
|
+
scope: RotateScope;
|
|
80
|
+
/** The agent id, persona, "persona@workspace", workspace or service. */
|
|
81
|
+
target?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface EffectiveRotate {
|
|
85
|
+
/** 0: never rotates. */
|
|
86
|
+
afterCompactions: number;
|
|
87
|
+
onDue: OnDue;
|
|
88
|
+
onPrMerged: boolean;
|
|
89
|
+
source: Record<RotateKey, RotateSource>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The org config's `agents.rotate`, checked (den-config.ts). */
|
|
93
|
+
export interface OrgRotate extends RotateValues {
|
|
94
|
+
/** Dropped by D28 (persona scope replaces it); still honoured. */
|
|
95
|
+
standing?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const rotateFilePath = (stateDir: string) =>
|
|
99
|
+
path.join(stateDir, "agent-rotation.json");
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One value checked: the problem in words, or null. `after_compactions`
|
|
103
|
+
* takes 1–100 or "never" (0 also means never, from the CLI).
|
|
104
|
+
*/
|
|
105
|
+
export function checkRotateValue(key: string, v: unknown): string | null {
|
|
106
|
+
if (key === "after_compactions")
|
|
107
|
+
return v === "never" ||
|
|
108
|
+
(typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 100)
|
|
109
|
+
? null
|
|
110
|
+
: `after_compactions: a whole number from 1 to 100, or never (got ${JSON.stringify(v)})`;
|
|
111
|
+
if (key === "on_due")
|
|
112
|
+
return v === "auto" || v === "ask" || v === "off"
|
|
113
|
+
? null
|
|
114
|
+
: `on_due: auto, ask or off (got ${JSON.stringify(v)})`;
|
|
115
|
+
if (key === "on_pr_merged")
|
|
116
|
+
return typeof v === "boolean"
|
|
117
|
+
? null
|
|
118
|
+
: `on_pr_merged: true or false (got ${JSON.stringify(v)})`;
|
|
119
|
+
return `${key}: unknown key (takes ${ROTATE_KEYS.join(", ")})`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A command-line value as the key takes it ("3", "never", "ask", "false"). */
|
|
123
|
+
export function parseRotateValue(key: string, raw: string): unknown {
|
|
124
|
+
if (key === "after_compactions")
|
|
125
|
+
return raw === "never" || raw === "0"
|
|
126
|
+
? "never"
|
|
127
|
+
: /^\d+$/.test(raw)
|
|
128
|
+
? Number(raw)
|
|
129
|
+
: raw;
|
|
130
|
+
if (key === "on_pr_merged")
|
|
131
|
+
return raw === "true" ? true : raw === "false" ? false : raw;
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Only the valid keys of `v` (a bad file never breaks an agent). */
|
|
136
|
+
function cleanValues(v: unknown): RotateValues {
|
|
137
|
+
const out: RotateValues = {};
|
|
138
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return out;
|
|
139
|
+
for (const k of ROTATE_KEYS) {
|
|
140
|
+
const x = (v as Record<string, unknown>)[k];
|
|
141
|
+
if (x !== undefined && checkRotateValue(k, x) === null)
|
|
142
|
+
(out as Record<string, unknown>)[k] = x;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** agent-rotation.json, cleaned; {} when absent or unreadable. */
|
|
148
|
+
export function readRotateFile(stateDir: string): RotateFile {
|
|
149
|
+
let v: unknown;
|
|
150
|
+
try {
|
|
151
|
+
v = JSON.parse(fs.readFileSync(rotateFilePath(stateDir), "utf-8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
|
156
|
+
const o = v as Record<string, unknown>;
|
|
157
|
+
const out: RotateFile = {};
|
|
158
|
+
const inst = cleanValues(o.install);
|
|
159
|
+
if (Object.keys(inst).length) out.install = inst;
|
|
160
|
+
if (o.overrides && typeof o.overrides === "object") {
|
|
161
|
+
const ov: RotateFile["overrides"] = {};
|
|
162
|
+
for (const scope of OVERRIDE_SCOPES) {
|
|
163
|
+
const byTarget = (o.overrides as Record<string, unknown>)[scope];
|
|
164
|
+
if (!byTarget || typeof byTarget !== "object") continue;
|
|
165
|
+
for (const [target, vals] of Object.entries(byTarget)) {
|
|
166
|
+
const c = cleanValues(vals);
|
|
167
|
+
if (Object.keys(c).length) (ov[scope] ??= {})[target] = c;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (Object.keys(ov).length) out.overrides = ov;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function writeRotateFile(stateDir: string, f: RotateFile): void {
|
|
176
|
+
fs.mkdirSync(path.dirname(rotateFilePath(stateDir)), {
|
|
177
|
+
recursive: true,
|
|
178
|
+
mode: 0o700,
|
|
179
|
+
});
|
|
180
|
+
writePrivate(rotateFilePath(stateDir), JSON.stringify(f, null, 2) + "\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Set (or with `value` undefined, clear) one key at one scope. `scope`
|
|
185
|
+
* "install" takes no target. Throws, naming the problem, on a bad key or
|
|
186
|
+
* value. Returns the file as written.
|
|
187
|
+
*/
|
|
188
|
+
export function setRotateValue(
|
|
189
|
+
stateDir: string,
|
|
190
|
+
scope: OverrideScope | "install",
|
|
191
|
+
target: string | undefined,
|
|
192
|
+
key: string,
|
|
193
|
+
value: unknown,
|
|
194
|
+
): RotateFile {
|
|
195
|
+
if (value !== undefined) {
|
|
196
|
+
const bad = checkRotateValue(key, value);
|
|
197
|
+
if (bad) throw new Error(bad);
|
|
198
|
+
} else if (!(ROTATE_KEYS as readonly string[]).includes(key))
|
|
199
|
+
throw new Error(checkRotateValue(key, undefined)!);
|
|
200
|
+
if (scope !== "install" && !target)
|
|
201
|
+
throw new Error(`a ${scope} override needs the ${scope} it is for`);
|
|
202
|
+
const f = readRotateFile(stateDir);
|
|
203
|
+
const k = key as RotateKey;
|
|
204
|
+
const apply = (vals: RotateValues | undefined): RotateValues => {
|
|
205
|
+
const next: RotateValues = { ...(vals ?? {}) };
|
|
206
|
+
if (value === undefined) delete next[k];
|
|
207
|
+
else (next as Record<string, unknown>)[k] = value;
|
|
208
|
+
return next;
|
|
209
|
+
};
|
|
210
|
+
if (scope === "install") {
|
|
211
|
+
const next = apply(f.install);
|
|
212
|
+
if (Object.keys(next).length) f.install = next;
|
|
213
|
+
else delete f.install;
|
|
214
|
+
} else {
|
|
215
|
+
const ov = (f.overrides ??= {});
|
|
216
|
+
const byTarget = (ov[scope] ??= {});
|
|
217
|
+
const next = apply(byTarget[target!]);
|
|
218
|
+
if (Object.keys(next).length) byTarget[target!] = next;
|
|
219
|
+
else delete byTarget[target!];
|
|
220
|
+
if (!Object.keys(byTarget).length) delete ov[scope];
|
|
221
|
+
if (!Object.keys(ov).length) delete f.overrides;
|
|
222
|
+
}
|
|
223
|
+
writeRotateFile(stateDir, f);
|
|
224
|
+
return f;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The target an override scope uses for `s`, or undefined. */
|
|
228
|
+
export function targetFor(
|
|
229
|
+
scope: OverrideScope,
|
|
230
|
+
s: RotateSubject,
|
|
231
|
+
): string | undefined {
|
|
232
|
+
switch (scope) {
|
|
233
|
+
case "agent":
|
|
234
|
+
return s.agent;
|
|
235
|
+
case "persona-workspace":
|
|
236
|
+
return `${s.persona}@${s.workspace}`;
|
|
237
|
+
case "persona":
|
|
238
|
+
return s.persona;
|
|
239
|
+
case "workspace":
|
|
240
|
+
return s.workspace;
|
|
241
|
+
case "service":
|
|
242
|
+
return s.service;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Agent `s`'s settings, each key from the most specific scope that sets it. */
|
|
247
|
+
export function resolveRotate(
|
|
248
|
+
s: RotateSubject,
|
|
249
|
+
file: RotateFile,
|
|
250
|
+
org: OrgRotate | undefined,
|
|
251
|
+
): EffectiveRotate {
|
|
252
|
+
const layers: { source: RotateSource; values: RotateValues }[] = [];
|
|
253
|
+
for (const scope of OVERRIDE_SCOPES) {
|
|
254
|
+
const target = targetFor(scope, s);
|
|
255
|
+
const values = target ? file.overrides?.[scope]?.[target] : undefined;
|
|
256
|
+
if (values) layers.push({ source: { scope, target }, values });
|
|
257
|
+
}
|
|
258
|
+
if (file.install)
|
|
259
|
+
layers.push({ source: { scope: "install" }, values: file.install });
|
|
260
|
+
if (org) {
|
|
261
|
+
const values: RotateValues = { ...org };
|
|
262
|
+
delete (values as OrgRotate).standing;
|
|
263
|
+
// den#4's `standing: false`: standing personas never rotate.
|
|
264
|
+
if (org.standing === false && s.standing)
|
|
265
|
+
values.after_compactions = "never";
|
|
266
|
+
layers.push({ source: { scope: "org" }, values });
|
|
267
|
+
}
|
|
268
|
+
const pick = <K extends RotateKey>(k: K) => {
|
|
269
|
+
for (const l of layers)
|
|
270
|
+
if (l.values[k] !== undefined)
|
|
271
|
+
return {
|
|
272
|
+
v: l.values[k] as Required<RotateValues>[K],
|
|
273
|
+
source: l.source,
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
v: ROTATE_DEFAULTS[k],
|
|
277
|
+
source: { scope: "default" } as RotateSource,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
const a = pick("after_compactions");
|
|
281
|
+
const o = pick("on_due");
|
|
282
|
+
const p = pick("on_pr_merged");
|
|
283
|
+
return {
|
|
284
|
+
afterCompactions: a.v === "never" ? 0 : a.v,
|
|
285
|
+
onDue: o.v,
|
|
286
|
+
onPrMerged: p.v,
|
|
287
|
+
source: {
|
|
288
|
+
after_compactions: a.source,
|
|
289
|
+
on_due: o.source,
|
|
290
|
+
on_pr_merged: p.source,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** "persona planner", "agent art", "this install", "org config", "default". */
|
|
296
|
+
export function sourceText(src: RotateSource): string {
|
|
297
|
+
switch (src.scope) {
|
|
298
|
+
case "default":
|
|
299
|
+
return "default";
|
|
300
|
+
case "install":
|
|
301
|
+
return "this install";
|
|
302
|
+
case "org":
|
|
303
|
+
return "org config";
|
|
304
|
+
case "persona-workspace":
|
|
305
|
+
return `persona ${src.target!.replace("@", " in ")}`;
|
|
306
|
+
default:
|
|
307
|
+
return `${src.scope} ${src.target}`;
|
|
308
|
+
}
|
|
309
|
+
}
|