seisin 0.4.2 → 0.6.0
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/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/src/grants.js
CHANGED
|
@@ -14,11 +14,12 @@ import { join } from "node:path";
|
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import { realpathSync, lstatSync } from "node:fs";
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
+
import { WILD } from "./paths.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* What every agent needs to write no matter which role it is.
|
|
20
21
|
*
|
|
21
|
-
* Found by running a
|
|
22
|
+
* Found by running a policy with 32 roles through the sandbox: territory alone
|
|
22
23
|
* looks correct and is unusable. An agent writes its session state under its
|
|
23
24
|
* own config directory and its tools write scratch files to the temp dir, so a
|
|
24
25
|
* policy of "your folders and nothing else" stops the agent before it starts.
|
|
@@ -67,7 +68,7 @@ export function roleHomeRoot(config) {
|
|
|
67
68
|
*
|
|
68
69
|
* This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
|
|
69
70
|
* is the tail of the path itself. Two checkouts that end the same way get the
|
|
70
|
-
* same id: `/Users/ana/dev/
|
|
71
|
+
* same id: `/Users/ana/dev/project` and `/Users/bob/dev/project` collide, and
|
|
71
72
|
* so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
|
|
72
73
|
* cosmetic clash — both repos' role `dev` would share one HOME, which is the
|
|
73
74
|
* session token of one handed to the other. Measured on three of four ordinary
|
|
@@ -127,6 +128,111 @@ export const RUNTIME_WRITES = [
|
|
|
127
128
|
"~/.cache", "$TMPDIR", "/tmp",
|
|
128
129
|
];
|
|
129
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The agents that keep their state — and their sign-in — under a directory of
|
|
133
|
+
* their own name, and where that sign-in sits when it is a file.
|
|
134
|
+
*
|
|
135
|
+
* `~/.claude` and `~/.codex` used to be granted to every run, whatever it ran.
|
|
136
|
+
* The cost was measured on 2026-09-30: a role running nothing but `cat` read
|
|
137
|
+
* `~/.codex/auth.json`, the ChatGPT tokens of whoever signed Codex in. The
|
|
138
|
+
* directory is the agent's scratch, so it goes to a run of that agent; and the
|
|
139
|
+
* other agents' sign-in files are denied to it, read and write, so a Claude
|
|
140
|
+
* run cannot read Codex's tokens or swap them for another account's.
|
|
141
|
+
*
|
|
142
|
+
* `~/.claude/.credentials.json` is where Claude Code keeps its sign-in when
|
|
143
|
+
* there is no keychain (Linux, and macOS when the keychain is unavailable).
|
|
144
|
+
* On macOS it is normally in the login keychain, which no file rule reaches.
|
|
145
|
+
*
|
|
146
|
+
* What this does NOT close, said where it is decided: a run of Codex can read
|
|
147
|
+
* its own `auth.json`, and so can every command Codex runs, because they share
|
|
148
|
+
* one sandbox. Only Codex storing its sign-in in the keychain closes that
|
|
149
|
+
* (`cli_auth_credentials_store = "keyring"`), and that is Codex's setting.
|
|
150
|
+
*/
|
|
151
|
+
export const AGENT_HOMES = { claude: "~/.claude", codex: "~/.codex" };
|
|
152
|
+
export const AGENT_CREDENTIALS = {
|
|
153
|
+
claude: ["~/.claude/.credentials.json"],
|
|
154
|
+
codex: ["~/.codex/auth.json"],
|
|
155
|
+
};
|
|
156
|
+
export const AGENTS = Object.keys(AGENT_HOMES);
|
|
157
|
+
|
|
158
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "fish"]);
|
|
159
|
+
const LAUNCHERS = new Set(["node", "npx", "bunx", "pnpx", "bun", "deno"]);
|
|
160
|
+
|
|
161
|
+
/** Which agent a token names, by its basename or its package path, or null. */
|
|
162
|
+
function agentNamed(token) {
|
|
163
|
+
const t = String(token ?? "");
|
|
164
|
+
const base = t.split("/").pop().replace(/\.(c|m)?js$/, "");
|
|
165
|
+
if (AGENTS.includes(base)) return base;
|
|
166
|
+
if (/@openai\/codex(\/|$)/.test(t)) return "codex";
|
|
167
|
+
if (/@anthropic-ai\/claude-code(\/|$)/.test(t)) return "claude";
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The agent a command line runs, or null when it is none seisin knows.
|
|
173
|
+
*
|
|
174
|
+
* Reads what a person writes after `--`: the program, through `env` and
|
|
175
|
+
* `VAR=value`, through a launcher (`node …/@openai/codex/bin/codex.js`,
|
|
176
|
+
* `npx @anthropic-ai/claude-code`), and through `sh -c "codex exec …"` by the
|
|
177
|
+
* first word of the script. Nothing cleverer: a prompt that mentions "claude"
|
|
178
|
+
* must not turn a run into a Claude run. A wrapper script is not followed —
|
|
179
|
+
* `seisin run <role> --agent codex -- ./wrapper.sh` says it instead.
|
|
180
|
+
*/
|
|
181
|
+
export function agentOf(cmd = []) {
|
|
182
|
+
const argv = [...cmd];
|
|
183
|
+
for (let depth = 0; depth < 4 && argv.length; depth++) {
|
|
184
|
+
while (argv.length && (argv[0] === "env" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0]) ||
|
|
185
|
+
(argv[0].startsWith("-") && depth > 0))) argv.shift();
|
|
186
|
+
if (!argv.length) return null;
|
|
187
|
+
const head = argv.shift();
|
|
188
|
+
const named = agentNamed(head);
|
|
189
|
+
if (named) return named;
|
|
190
|
+
const base = head.split("/").pop();
|
|
191
|
+
if (LAUNCHERS.has(base)) {
|
|
192
|
+
const next = argv.find((a) => !a.startsWith("-"));
|
|
193
|
+
return next ? agentNamed(next) : null;
|
|
194
|
+
}
|
|
195
|
+
if (SHELLS.has(base)) {
|
|
196
|
+
const i = argv.findIndex((a) => /^-[a-z]*c$/.test(a));
|
|
197
|
+
if (i === -1 || argv[i + 1] === undefined) return null;
|
|
198
|
+
argv.splice(0, argv.length, ...argv[i + 1].trim().split(/\s+/));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The scratch list for a run of `agent`: every entry, minus the homes of the
|
|
208
|
+
* agents it is not.
|
|
209
|
+
*
|
|
210
|
+
* `agent === undefined` is "no command known" — check, console, explain, and
|
|
211
|
+
* the union surface.js needs — and answers with every home, which is the most
|
|
212
|
+
* any run of the role gets. `null` is a command that is no agent seisin knows,
|
|
213
|
+
* and gets neither home. An explicit `[runtime] writes` goes through the same
|
|
214
|
+
* filter: listing `~/.codex` there says what a Codex run may write, not that
|
|
215
|
+
* a `cat` may.
|
|
216
|
+
*/
|
|
217
|
+
export function runtimeWritesFor(config, agent) {
|
|
218
|
+
const list = (config.runtimeWrites ?? RUNTIME_WRITES).map(expand);
|
|
219
|
+
if (agent === undefined) return list;
|
|
220
|
+
const others = AGENTS.filter((a) => a !== agent).map((a) => expand(AGENT_HOMES[a]));
|
|
221
|
+
return list.filter((p) => !others.some((h) => p === h || p.startsWith(h + "/")));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The sign-in files of every agent this run is not, absolute, under `home`.
|
|
226
|
+
*
|
|
227
|
+
* Empty when no command is known: a role's settings without a command are the
|
|
228
|
+
* most it can get, and the most includes being run as either agent.
|
|
229
|
+
*/
|
|
230
|
+
export function otherAgentsCredentials(agent, home = homedir()) {
|
|
231
|
+
if (agent === undefined) return [];
|
|
232
|
+
const at = (p) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
233
|
+
return AGENTS.filter((a) => a !== agent).flatMap((a) => AGENT_CREDENTIALS[a].map(at)).map((p) => realOrSelf(p));
|
|
234
|
+
}
|
|
235
|
+
|
|
130
236
|
/**
|
|
131
237
|
* `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
|
|
132
238
|
*
|
|
@@ -145,10 +251,18 @@ export function isLink(p) {
|
|
|
145
251
|
}
|
|
146
252
|
}
|
|
147
253
|
|
|
148
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* The path with symlinks followed, or the path itself if it is not on disk.
|
|
256
|
+
*
|
|
257
|
+
* `.native` is realpath(3): one call instead of an lstat per component, and on
|
|
258
|
+
* a case-insensitive disk (APFS) it answers with the case the disk has. The JS
|
|
259
|
+
* version keeps whatever case it was given, so `/users/x` and `/Users/x` —
|
|
260
|
+
* one directory — compared as two, and a path spelled the other way came out
|
|
261
|
+
* "outside the repo" while the kernel treated it as inside.
|
|
262
|
+
*/
|
|
149
263
|
export function realOrSelf(p) {
|
|
150
264
|
try {
|
|
151
|
-
return realpathSync(p);
|
|
265
|
+
return realpathSync.native(p);
|
|
152
266
|
} catch {
|
|
153
267
|
return p;
|
|
154
268
|
}
|
|
@@ -159,7 +273,7 @@ export function expand(p) {
|
|
|
159
273
|
if (p === "$TMPDIR") out = tmpdir();
|
|
160
274
|
else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
|
|
161
275
|
try {
|
|
162
|
-
return realpathSync(out);
|
|
276
|
+
return realpathSync.native(out);
|
|
163
277
|
} catch {
|
|
164
278
|
return out; // not on disk yet; hand the literal through rather than drop it
|
|
165
279
|
}
|
|
@@ -195,7 +309,6 @@ export function toWritePath(glob, key = "writes") {
|
|
|
195
309
|
* prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
|
|
196
310
|
* here, it is the answer.
|
|
197
311
|
*/
|
|
198
|
-
const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
|
|
199
312
|
const EXPRESSIBLE = (g) =>
|
|
200
313
|
g === "**" || // the whole repo
|
|
201
314
|
!WILD.test(g) || // a literal path
|
|
@@ -217,7 +330,7 @@ function unexpressible(glob, key = "writes") {
|
|
|
217
330
|
* grants the whole repo, so `observe: true` answers for that case.
|
|
218
331
|
*
|
|
219
332
|
* `.seisin/` is deliberately NOT here. It used to be, because the hook runs
|
|
220
|
-
* inside the
|
|
333
|
+
* inside the sandbox and has to record what it decided — which made the log and
|
|
221
334
|
* the queue writable by the process they are a record of. The hook now sends
|
|
222
335
|
* its lines to the parent over a socket and the parent holds the file. See
|
|
223
336
|
* spool.js.
|
|
@@ -226,7 +339,7 @@ function unexpressible(glob, key = "writes") {
|
|
|
226
339
|
* the real ~/.claude, ~/.cache and /tmp, which every role shares — convenient,
|
|
227
340
|
* and the reason `isolate` exists.
|
|
228
341
|
*/
|
|
229
|
-
export function writePathsOf(config, role, { observe = false } = {}) {
|
|
342
|
+
export function writePathsOf(config, role, { observe = false, agent } = {}) {
|
|
230
343
|
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
231
344
|
const level = config.isolate === true ? "home" : config.isolate;
|
|
232
345
|
const home = level === "home" ? roleHome(config, role.name) : null;
|
|
@@ -234,6 +347,6 @@ export function writePathsOf(config, role, { observe = false } = {}) {
|
|
|
234
347
|
...(observe ? [abs(".")] : role.writes.map((g) => abs(toWritePath(g)))),
|
|
235
348
|
...(home
|
|
236
349
|
? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
|
|
237
|
-
: (config
|
|
350
|
+
: runtimeWritesFor(config, agent)),
|
|
238
351
|
];
|
|
239
352
|
}
|
package/src/handoff.js
CHANGED
|
@@ -16,8 +16,18 @@
|
|
|
16
16
|
* *demand* for work. It may not determine authority, routing identity,
|
|
17
17
|
* admission or policy. Every input below is either derived from the boundary
|
|
18
18
|
* or supplied by the caller; none of them is a role name a model chose.
|
|
19
|
+
*
|
|
20
|
+
* ── Where the decision is made ──
|
|
21
|
+
* Where the request is read, not where it is written. Every reader of the
|
|
22
|
+
* queue — `seisin requests`, the end of `seisin run`, the notification, the
|
|
23
|
+
* console, the MCP — gets it from requests.js (withHandoffs), against the
|
|
24
|
+
* policy in front of it, so a grant made since changes it at once. What is
|
|
25
|
+
* written down is only history: the chain a request was asked in, set by the
|
|
26
|
+
* parent of a run started with `--handoff`, and what the execution plane did
|
|
27
|
+
* about it (recordHandoff). Reads go to a person: a key is shared by a grant,
|
|
28
|
+
* never handed over.
|
|
19
29
|
*/
|
|
20
|
-
import { ownersOf } from "./owners.js";
|
|
30
|
+
import { ownersOf, keyHolders, inKeyDir } from "./owners.js";
|
|
21
31
|
|
|
22
32
|
/** Small on purpose: a fuse for a topology nobody predicted, not a budget. */
|
|
23
33
|
export const MAX_DEPTH = 4;
|
|
@@ -44,10 +54,26 @@ export function decideHandoff({
|
|
|
44
54
|
if (!config || !config.roles) throw new Error("decideHandoff: config needs roles");
|
|
45
55
|
|
|
46
56
|
const depthMax = limits.depth ?? MAX_DEPTH;
|
|
47
|
-
|
|
57
|
+
// A caller may provide incomplete history. The requester is always visited.
|
|
58
|
+
const visited = [...new Set([...(chain?.visited ?? []), request.role])];
|
|
48
59
|
const depth = chain?.depth ?? 0;
|
|
49
60
|
const at = (type, extra = {}) => ({ type, revision, chainId: chain?.id ?? null, ...extra });
|
|
50
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A read is not handed to anybody. A key is declared, not owned, and the
|
|
64
|
+
* only thing that changes who reads it is a person sharing it; another
|
|
65
|
+
* role's private folder is the policy working. Who declares or owns it is
|
|
66
|
+
* still worked out here — the queue shows it — but the decision is a
|
|
67
|
+
* person's, unless the policy already moved and the asker has it now.
|
|
68
|
+
*/
|
|
69
|
+
if (request.action === "read") {
|
|
70
|
+
const key = isKeyRequest(config, request);
|
|
71
|
+
const holders = key ? keyHolders(config, request.target) : ownersOf(config, request.target);
|
|
72
|
+
if (holders.includes(request.role))
|
|
73
|
+
return at("resolved", { resolution: "policy_changed", role: request.role, owners: holders });
|
|
74
|
+
return at("human", { reason: key ? "key" : "read", owners: holders });
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
const owners = ownersOf(config, request.target);
|
|
52
78
|
|
|
53
79
|
/**
|
|
@@ -61,9 +87,6 @@ export function decideHandoff({
|
|
|
61
87
|
* wrong thing.
|
|
62
88
|
*/
|
|
63
89
|
if (owners.length === 0) return at("human", { reason: "unowned", owners });
|
|
64
|
-
if (owners.length > 1) return at("human", { reason: "ambiguous", owners });
|
|
65
|
-
|
|
66
|
-
const [owner] = owners;
|
|
67
90
|
|
|
68
91
|
/**
|
|
69
92
|
* The policy moved and the asker owns it now. Nobody granted anything, so
|
|
@@ -71,8 +94,30 @@ export function decideHandoff({
|
|
|
71
94
|
* so is what keeps the human queue readable. Sending this to a person would
|
|
72
95
|
* be asking them to decide something already decided.
|
|
73
96
|
*/
|
|
97
|
+
if (owners.length === 1 && owners[0] === request.role)
|
|
98
|
+
return at("resolved", { resolution: "policy_changed", role: request.role, owners });
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Only an owner that takes handed work can be handed it.
|
|
102
|
+
*
|
|
103
|
+
* A role marked `handoff = false` is a pipeline phase, not a worker: it owns
|
|
104
|
+
* its territory — often all of it, `**` — and starting it because some other
|
|
105
|
+
* role reached for a file there would run a phase for a reason that has
|
|
106
|
+
* nothing to do with it. So it is left out of the choice, and the existing
|
|
107
|
+
* rule runs over who is left: one receiver is a route, several are for a
|
|
108
|
+
* person, and none is a person's too, with a reason that says the owner is
|
|
109
|
+
* there and does not take the work. Ownership itself is untouched — the
|
|
110
|
+
* queue and the agent still hear whose it is.
|
|
111
|
+
*/
|
|
112
|
+
const receivers = owners.filter((o) => config.roles[o]?.handoff !== false);
|
|
113
|
+
const narrowed = receivers.length < owners.length ? { receivers } : {};
|
|
114
|
+
if (receivers.length === 0) return at("human", { reason: "no-receiver", owners, receivers });
|
|
115
|
+
if (receivers.length > 1) return at("human", { reason: "ambiguous", owners, ...narrowed });
|
|
116
|
+
|
|
117
|
+
const [owner] = receivers;
|
|
118
|
+
// The asker is that one receiver: it owns the path now, whoever else does.
|
|
74
119
|
if (owner === request.role)
|
|
75
|
-
return at("resolved", { resolution: "policy_changed", role: owner });
|
|
120
|
+
return at("resolved", { resolution: "policy_changed", role: owner, owners });
|
|
76
121
|
|
|
77
122
|
/**
|
|
78
123
|
* A role already in this chain does not get re-entered, even for a different
|
|
@@ -81,10 +126,10 @@ export function decideHandoff({
|
|
|
81
126
|
* person should make on purpose.
|
|
82
127
|
*/
|
|
83
128
|
if (visited.includes(owner))
|
|
84
|
-
return at("cycle", { role: owner, visited: [...visited] });
|
|
129
|
+
return at("cycle", { role: owner, owners, visited: [...visited] });
|
|
85
130
|
|
|
86
131
|
if (depth + 1 > depthMax)
|
|
87
|
-
return at("depth", { role: owner, depth: depth + 1, max: depthMax });
|
|
132
|
+
return at("depth", { role: owner, owners, depth: depth + 1, max: depthMax });
|
|
88
133
|
|
|
89
134
|
/**
|
|
90
135
|
* Bounded invocation. A role cannot write another role's territory, but it
|
|
@@ -97,15 +142,245 @@ export function decideHandoff({
|
|
|
97
142
|
*/
|
|
98
143
|
const senderMax = limits.senderChains ?? Infinity;
|
|
99
144
|
if ((load.senderOpenChains ?? 0) >= senderMax)
|
|
100
|
-
return at("throttled", { role: owner, limit: "senderChains", max: senderMax });
|
|
145
|
+
return at("throttled", { role: owner, owners, limit: "senderChains", max: senderMax });
|
|
101
146
|
|
|
102
147
|
const receiverMax = limits.receiverConcurrent ?? Infinity;
|
|
103
148
|
if ((load.receiverRunning ?? 0) >= receiverMax)
|
|
104
|
-
return at("throttled", { role: owner, limit: "receiverConcurrent", max: receiverMax });
|
|
149
|
+
return at("throttled", { role: owner, owners, limit: "receiverConcurrent", max: receiverMax });
|
|
105
150
|
|
|
106
151
|
return at("route", {
|
|
107
152
|
role: owner,
|
|
153
|
+
owners,
|
|
154
|
+
...narrowed,
|
|
108
155
|
depth: depth + 1,
|
|
109
156
|
visited: [...visited, owner],
|
|
110
157
|
});
|
|
111
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a read request is for a key: inside a key directory, or a bare name,
|
|
162
|
+
* which is how a key in the first key directory is written.
|
|
163
|
+
*/
|
|
164
|
+
export function isKeyRequest(config, request) {
|
|
165
|
+
const t = request?.target;
|
|
166
|
+
return request?.action === "read" && typeof t === "string" && (inKeyDir(config, t) || !t.includes("/"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The chain a request belongs to, as decideHandoff takes it.
|
|
171
|
+
*
|
|
172
|
+
* A request filed by a run that was itself handed work carries the chain that
|
|
173
|
+
* run was started with (see chainFrom); one filed by any other run starts a
|
|
174
|
+
* chain of its own, named by its own id. Never read from inside the sandbox:
|
|
175
|
+
* the parent writes it, from the decision it checked before starting the run.
|
|
176
|
+
*/
|
|
177
|
+
export function chainOf(request) {
|
|
178
|
+
const c = request?.chain;
|
|
179
|
+
const ok = c && typeof c.id === "string" && Number.isInteger(c.depth) && c.depth >= 0 &&
|
|
180
|
+
Array.isArray(c.visited) && c.visited.every((r) => typeof r === "string");
|
|
181
|
+
return ok ? { id: c.id, depth: c.depth, visited: [...c.visited] } : { id: request?.key ?? null, depth: 0, visited: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What a run started for a routed decision carries into the requests it files.
|
|
186
|
+
* Null for anything but a route: only a route starts work.
|
|
187
|
+
*/
|
|
188
|
+
export function chainFrom(decision) {
|
|
189
|
+
if (decision?.type !== "route") return null;
|
|
190
|
+
return { id: decision.chainId, depth: decision.depth, visited: [...decision.visited] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The variable `seisin run --handoff` sets in the receiver's environment, so
|
|
195
|
+
* the hook inside can word a denial with the chain the run belongs to.
|
|
196
|
+
*
|
|
197
|
+
* Words only. The chain a request is recorded in is the parent's, written by
|
|
198
|
+
* intake from the run it started (requests.js, record), and nothing read from
|
|
199
|
+
* here reaches the queue. A process inside that rewrote it would change the
|
|
200
|
+
* sentence it is told about its own denial, and nothing else. Reserved, so a
|
|
201
|
+
* policy cannot deliver a key under this name (keys.js, RESERVED_ENV).
|
|
202
|
+
*/
|
|
203
|
+
export const CHAIN_ENV = "SEISIN_HANDOFF_CHAIN";
|
|
204
|
+
|
|
205
|
+
/** The run's chain from its environment, or null: absent and malformed are the same. */
|
|
206
|
+
export function chainFromEnv(env = process.env) {
|
|
207
|
+
const raw = env?.[CHAIN_ENV];
|
|
208
|
+
if (!raw) return null;
|
|
209
|
+
let c;
|
|
210
|
+
try { c = JSON.parse(raw); } catch { return null; }
|
|
211
|
+
const ok = c && typeof c === "object" && typeof c.id === "string" && Number.isInteger(c.depth) && c.depth >= 0 &&
|
|
212
|
+
Array.isArray(c.visited) && c.visited.every((r) => typeof r === "string");
|
|
213
|
+
return ok ? { id: c.id, depth: c.depth, visited: [...c.visited] } : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The decision as the denied agent is told it: whose the path is, where the
|
|
218
|
+
* work goes, and what to do instead of retrying. One sentence, the same from
|
|
219
|
+
* the PreToolUse hook, after a failed tool call and from `seisin whose`.
|
|
220
|
+
*
|
|
221
|
+
* It informs and offers nothing to act with. Every role it names is one the
|
|
222
|
+
* policy named; it never asks the agent to pick one, and there is no answer
|
|
223
|
+
* the agent can give back that would change who takes the work — the queue
|
|
224
|
+
* decides that again, from the policy, whenever it is read.
|
|
225
|
+
*/
|
|
226
|
+
export function toAgent(decision, { target, role, key = null, again = "" }) {
|
|
227
|
+
const d = decision ?? {};
|
|
228
|
+
// Where a chain stops, the agent is not told whose the file is, or the chain
|
|
229
|
+
// it came along. Measured: with the owner in the message, two runs of three
|
|
230
|
+
// added whose file it was after the sentence they were told to say alone.
|
|
231
|
+
if (isStopped(d))
|
|
232
|
+
return `${target} is not ${role}'s to change, and its handoff chain stops here: ` +
|
|
233
|
+
(d.type === "cycle" ? "handing it on would send it back the way it came." : `${d.depth} of ${d.max} deep.`) +
|
|
234
|
+
` A person decides. Do not work around it. ${ELSEWHERE}${again}` + stopped(d, target, key);
|
|
235
|
+
const owners = d.owners ?? [];
|
|
236
|
+
const whose = d.reason === "key"
|
|
237
|
+
? (owners.length ? `${target} is declared for ${owners.join(", ")}, not ${role}.` : `No role declares ${target}.`)
|
|
238
|
+
: owners.length
|
|
239
|
+
? `${target} belongs to ${owners.join(", ")}. It is not ${role}'s to change.`
|
|
240
|
+
: `${target} has no owner.`;
|
|
241
|
+
// A key or a read is not a change to put somewhere else.
|
|
242
|
+
const write = d.reason !== "key" && d.reason !== "read" && d.type !== "resolved";
|
|
243
|
+
return `${whose} ${nextStep(d, role)}` + (write ? ` ${ELSEWHERE}` : "") + again;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A chain that stops: a cycle, or one too deep. Its denial ends on the sentence to pass on. */
|
|
247
|
+
export function isStopped(decision) {
|
|
248
|
+
return decision?.type === "cycle" || decision?.type === "depth";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Where a handoff chain stops, the words to pass on, given literally.
|
|
253
|
+
*
|
|
254
|
+
* Measured with real runs: told "not that frontend will make the change", an
|
|
255
|
+
* agent stopped saying frontend would make it and, in one run of two, told
|
|
256
|
+
* its user to go and coordinate with frontend instead. A negative instruction
|
|
257
|
+
* leaves the agent to compose the sentence; this one hands it over.
|
|
258
|
+
*/
|
|
259
|
+
function stopped(d, target, key) {
|
|
260
|
+
const waiting = key ? `the request ${key} is waiting in seisin requests` : "the request is waiting in seisin requests";
|
|
261
|
+
// Measured again with the literal words: passed on in three runs of three,
|
|
262
|
+
// and then contradicted in two, by an agent adding who should do it or where
|
|
263
|
+
// else it could go. So the sentence ends on that, too. Measured a third time:
|
|
264
|
+
// no run added either, but one added whose file it was, and one dropped the
|
|
265
|
+
// path from the front ("Not written. …"). So both are said as well. And
|
|
266
|
+
// nothing comes after the sentence: what not to add is said first, so the
|
|
267
|
+
// quote is the last thing the agent reads (hook.js ends the denial on it).
|
|
268
|
+
// Measured a fourth time: an agent that headed its line with the path ate
|
|
269
|
+
// the sentence's subject ("src/x — Not written. …"). So the path is no
|
|
270
|
+
// longer the subject, and the sentence is to be said whole regardless.
|
|
271
|
+
return " About this file, tell whoever sent you one sentence and add nothing to it: not whose file it is, " +
|
|
272
|
+
"who should do it or where else it could go. The sentence, word for word and whole, even where your line " +
|
|
273
|
+
`already names the file: "Not written: ${target}. A person has to decide who does it; ${waiting}."`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What "working around it" means, said once. Measured: an agent that would
|
|
278
|
+
* never retry the write still offered, twice, to leave the file in a folder of
|
|
279
|
+
* its own — the same change, under a path nobody reviews.
|
|
280
|
+
*/
|
|
281
|
+
export const ELSEWHERE = "Do not put the change somewhere else instead (another path, a copy, /tmp).";
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* How the denial ends: that it is already queued, and for whom. Said apart
|
|
285
|
+
* from toAgent because only a denial that files a request may say it — the
|
|
286
|
+
* note after a failed call and `seisin whose` file nothing.
|
|
287
|
+
*
|
|
288
|
+
* A route ends on the role it goes to. Ending it on "a person", as every
|
|
289
|
+
* denial once did, told the agent two owners in one sentence, and with a
|
|
290
|
+
* handoff chain in it the agent repeated the wrong one.
|
|
291
|
+
*/
|
|
292
|
+
export function queuedLine(decision) {
|
|
293
|
+
return decision?.type === "route"
|
|
294
|
+
? `Already queued as a handoff to ${decision.role}: a run of ${decision.role}, or a person, picks it up — retrying or waiting will not move it.`
|
|
295
|
+
: "Already queued for a person to answer — retrying or waiting will not move it.";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What to do about it, from the decision alone: the second half of toAgent, and `seisin whose`. */
|
|
299
|
+
export function nextStep(decision, role) {
|
|
300
|
+
const d = decision ?? {};
|
|
301
|
+
switch (d.type) {
|
|
302
|
+
case "route":
|
|
303
|
+
return `It is handed to ${d.role}` +
|
|
304
|
+
(d.depth > 1 ? ` (handoff chain ${(d.visited ?? []).join(" → ")}, ${d.depth} of ${MAX_DEPTH} deep)` : "") +
|
|
305
|
+
": hand it over rather than working around it.";
|
|
306
|
+
case "human":
|
|
307
|
+
if (d.reason === "key") return "Ask for what you need from it rather than reading the key.";
|
|
308
|
+
if (d.reason === "no-receiver")
|
|
309
|
+
return `${(d.owners ?? []).join(", ") || "Its owner"} ${(d.owners ?? []).length > 1 ? "do" : "does"} not take handed work, ` +
|
|
310
|
+
"so a person decides who takes it. Do not work around it.";
|
|
311
|
+
return d.reason === "ambiguous"
|
|
312
|
+
? "With more than one owner, a person decides who takes it. Do not pick one, and do not work around it."
|
|
313
|
+
: "A person decides who takes it. Do not work around it.";
|
|
314
|
+
// Measured: told only "not handed back", an agent still told its user
|
|
315
|
+
// that the owner would make the change. It has to hear that nothing is
|
|
316
|
+
// asked of the owner, and what to say instead.
|
|
317
|
+
case "cycle":
|
|
318
|
+
return `It is not handed back to ${d.role}: this work already came through ${d.role} ` +
|
|
319
|
+
`(handoff chain ${(d.visited ?? []).join(" → ")}), so nothing is asked of ${d.role}. ` +
|
|
320
|
+
"A person decides how to split it. Do not work around it.";
|
|
321
|
+
case "depth":
|
|
322
|
+
return `The handoff chain stops here, ${d.depth} of ${d.max} deep. A person decides. Do not work around it.`;
|
|
323
|
+
case "resolved":
|
|
324
|
+
return `The policy now gives it to ${role}: the next run has it.`;
|
|
325
|
+
default: {
|
|
326
|
+
const said = describeHandoff(d) || "a person decides";
|
|
327
|
+
return `${said[0].toUpperCase()}${said.slice(1)}. Do not work around it.`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The decision in words a person reads, one line, no colour.
|
|
334
|
+
*
|
|
335
|
+
* `attempt` is what the execution plane last recorded about the request
|
|
336
|
+
* (requests.js, recordHandoff). It is shown only where seisin's own decision
|
|
337
|
+
* is a route to the same role: whether that role could be started is
|
|
338
|
+
* admission, which only whatever starts it knows. Every other outcome is the
|
|
339
|
+
* policy's, recomputed now, and a recorded attempt cannot overrule it.
|
|
340
|
+
*/
|
|
341
|
+
export function describeHandoff(decision, attempt = null, chainStopped = null) {
|
|
342
|
+
const said = describeDecision(decision, attempt);
|
|
343
|
+
// A run took it and its chain then stopped further on: the request is still
|
|
344
|
+
// pending, and without this it read as handed and done.
|
|
345
|
+
return said && chainStopped?.length
|
|
346
|
+
? `${said}; its handoff chain stopped at ${chainStopped.join(", ")}, which needs a person`
|
|
347
|
+
: said;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function describeDecision(decision, attempt) {
|
|
351
|
+
const d = decision;
|
|
352
|
+
if (!d) return "";
|
|
353
|
+
if (d.type === "route" && attempt && attempt.role === d.role) {
|
|
354
|
+
if (attempt.outcome === "throttled")
|
|
355
|
+
return `handoff held: ${limitWords(attempt.limit, attempt.role)} ${attempt.max}/${attempt.max} — still to do`;
|
|
356
|
+
if (attempt.outcome === "route") return `handed to ${d.role} — a run of ${d.role} took it`;
|
|
357
|
+
}
|
|
358
|
+
switch (d.type) {
|
|
359
|
+
case "route":
|
|
360
|
+
return `handed to ${d.role}` + (d.depth > 1 ? ` (handoff chain, ${d.depth} of ${MAX_DEPTH})` : "");
|
|
361
|
+
case "throttled":
|
|
362
|
+
return `handoff held: ${limitWords(d.limit, d.role)} ${d.max}/${d.max} — still to do`;
|
|
363
|
+
case "cycle":
|
|
364
|
+
return `handoff stopped: ${d.role} is already in this handoff chain — needs a person to split the work`;
|
|
365
|
+
case "depth":
|
|
366
|
+
return `handoff stopped: handoff chain reached ${d.depth} of ${d.max} — needs a person`;
|
|
367
|
+
case "human":
|
|
368
|
+
if (d.reason === "ambiguous") return `a person decides: more than one role owns this (${(d.receivers ?? d.owners).join(", ")})`;
|
|
369
|
+
if (d.reason === "no-receiver")
|
|
370
|
+
return `a person decides: ${d.owners.join(", ")} ${d.owners.length > 1 ? "own" : "owns"} this and ${d.owners.length > 1 ? "take" : "takes"} no handed work (handoff = false)`;
|
|
371
|
+
if (d.reason === "key") return "a person decides: a key is shared by a grant, never handed over";
|
|
372
|
+
if (d.reason === "read") return "a person decides: a read is not handed over";
|
|
373
|
+
return "a person decides: no role owns this";
|
|
374
|
+
case "resolved":
|
|
375
|
+
return `no handoff needed — the policy moved and ${d.role} has it now`;
|
|
376
|
+
default:
|
|
377
|
+
return `handoff: ${d.type}`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The limit names are internal; what a person reads should not be a field name. */
|
|
382
|
+
function limitWords(limit, role) {
|
|
383
|
+
if (limit === "senderChains") return "open handoff chains for this role";
|
|
384
|
+
if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
|
|
385
|
+
return limit ?? "a limit";
|
|
386
|
+
}
|