@scp-dev/core 0.3.0 → 0.4.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/dist/index.d.ts +23 -4
- package/dist/index.js +113 -14
- package/package.json +1 -1
- package/src/core.ts +132 -14
- package/src/protocol.ts +15 -3
package/dist/index.d.ts
CHANGED
|
@@ -403,8 +403,8 @@ type MessageKind = "message" | "request" | "reply" | "status";
|
|
|
403
403
|
type DataClass = "public" | "private";
|
|
404
404
|
type Presence = "online" | "idle" | "offline";
|
|
405
405
|
type StatusState = "working" | "waiting" | "blocked" | "done";
|
|
406
|
-
/** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter
|
|
407
|
-
type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "none";
|
|
406
|
+
/** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter, codex app-server, the session inbox, a headless resume or muse serve; `none` reads at its next turn */
|
|
407
|
+
type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "agy-resume" | "grok-resume" | "muse-serve" | "none";
|
|
408
408
|
declare const WAKES: readonly Wake[];
|
|
409
409
|
type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end";
|
|
410
410
|
declare const ACTIVITY_KINDS: readonly ActivityKind[];
|
|
@@ -439,6 +439,8 @@ interface Claim {
|
|
|
439
439
|
session?: string;
|
|
440
440
|
/** the harness process the claim comes from, `pid:<pid>:<start time>`: a shim restarted in that process may rejoin its endpoint */
|
|
441
441
|
process?: string;
|
|
442
|
+
/** the harness's own stable session id (Claude Code's): a resumed session in a new harness process takes back the offline endpoint of that id */
|
|
443
|
+
harness_session?: string;
|
|
442
444
|
/** process keys of the claimant's harness process and its ancestors, nearest first: the nearest endpoint holding one places the claim under it */
|
|
443
445
|
lineage?: string[];
|
|
444
446
|
/** a `krt_` grant from `spawn-env`: places the claim at or below its lineage placement, with the grant's caps and groups */
|
|
@@ -1240,6 +1242,12 @@ declare class RelayCore {
|
|
|
1240
1242
|
*/
|
|
1241
1243
|
private boundGrant;
|
|
1242
1244
|
private claimRow;
|
|
1245
|
+
/**
|
|
1246
|
+
* the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
|
|
1247
|
+
* of a fitting harness, no subagent's, and restarted where it sits or redeemed with its grant. A claim with a grant that row did not spend
|
|
1248
|
+
* is a new spawn and takes none. A named row first, then the latest seen
|
|
1249
|
+
*/
|
|
1250
|
+
private resumable;
|
|
1243
1251
|
/** a session's hook endpoint left for `to`: its unread mail moves there and it lets go of the session; the moved seqs and its stop budget */
|
|
1244
1252
|
private leaveHooks;
|
|
1245
1253
|
/**
|
|
@@ -1447,18 +1455,29 @@ declare class RelayCore {
|
|
|
1447
1455
|
protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null;
|
|
1448
1456
|
/**
|
|
1449
1457
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1450
|
-
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
1458
|
+
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint,
|
|
1459
|
+
* except an offline one {@link renameRow} takes over
|
|
1451
1460
|
*/
|
|
1452
1461
|
rename(actor: ScopeActor, params: {
|
|
1453
1462
|
endpoint?: unknown;
|
|
1454
1463
|
handle: unknown;
|
|
1464
|
+
take_over?: unknown;
|
|
1455
1465
|
}): {
|
|
1456
1466
|
endpoint: EndpointView;
|
|
1457
1467
|
from: string;
|
|
1458
1468
|
session: string | null;
|
|
1469
|
+
retired?: string;
|
|
1459
1470
|
};
|
|
1460
|
-
/**
|
|
1471
|
+
/**
|
|
1472
|
+
* the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
|
|
1473
|
+
* is taken over when it held the same harness session, or with `takeOver` by the human at a terminal: the id of the row retired
|
|
1474
|
+
*/
|
|
1461
1475
|
private renameRow;
|
|
1476
|
+
/**
|
|
1477
|
+
* `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
|
|
1478
|
+
* `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
|
|
1479
|
+
*/
|
|
1480
|
+
private takeOver;
|
|
1462
1481
|
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
1463
1482
|
protected renamedHandle(_from: string, _to: string): void;
|
|
1464
1483
|
/**
|
package/dist/index.js
CHANGED
|
@@ -179,7 +179,17 @@ var RelayError = class extends Error {
|
|
|
179
179
|
retryAfterS;
|
|
180
180
|
detail;
|
|
181
181
|
};
|
|
182
|
-
var WAKES = [
|
|
182
|
+
var WAKES = [
|
|
183
|
+
"channels",
|
|
184
|
+
"pi",
|
|
185
|
+
"opencode-plugin",
|
|
186
|
+
"codex-app-server",
|
|
187
|
+
"claude-inbox",
|
|
188
|
+
"agy-resume",
|
|
189
|
+
"grok-resume",
|
|
190
|
+
"muse-serve",
|
|
191
|
+
"none"
|
|
192
|
+
];
|
|
183
193
|
var ACTIVITY_KINDS = ["session_start", "tool", "stop", "note", "session_end"];
|
|
184
194
|
var MESSAGE_KINDS = ["message", "request", "reply", "status"];
|
|
185
195
|
var STATUS_STATES = ["working", "waiting", "blocked", "done"];
|
|
@@ -1521,10 +1531,13 @@ var RelayCore = class {
|
|
|
1521
1531
|
parent: cleanText(claim.parent, "parent", 128),
|
|
1522
1532
|
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
1523
1533
|
session: cleanText(claim.session, "session", 128),
|
|
1524
|
-
process: cleanText(claim.process, "process", 128)
|
|
1534
|
+
process: cleanText(claim.process, "process", 128),
|
|
1535
|
+
harness_session: cleanText(claim.harness_session, "harness_session", 128)
|
|
1525
1536
|
};
|
|
1526
1537
|
if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]");
|
|
1527
|
-
|
|
1538
|
+
if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
|
|
1539
|
+
throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -");
|
|
1540
|
+
const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session].map((v) => v ? findSecret(v) : null).find(Boolean);
|
|
1528
1541
|
if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`);
|
|
1529
1542
|
if (claim.grant !== void 0 && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
|
|
1530
1543
|
throw new RelayError("invalid_params", "grant is a krt_ token");
|
|
@@ -1636,6 +1649,7 @@ var RelayCore = class {
|
|
|
1636
1649
|
let stopBlocks = null;
|
|
1637
1650
|
let moved = [];
|
|
1638
1651
|
let exactFrom;
|
|
1652
|
+
const open = !row || presenceOf(row, now) === "offline";
|
|
1639
1653
|
if (row && presenceOf(row, now) === "offline") {
|
|
1640
1654
|
this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
|
|
1641
1655
|
row = void 0;
|
|
@@ -1669,7 +1683,9 @@ var RelayCore = class {
|
|
|
1669
1683
|
exactFrom = hooks;
|
|
1670
1684
|
}
|
|
1671
1685
|
} else if (row && transient) session = row.session;
|
|
1672
|
-
|
|
1686
|
+
const resumed = !row && open && !scope.exact && !scope.subagent && fields.harness_session ? this.resumable(harness, fields.harness_session, scope, fields, now) : void 0;
|
|
1687
|
+
if (resumed) row = resumed;
|
|
1688
|
+
let joined = row !== void 0 && !resumed;
|
|
1673
1689
|
let own = false;
|
|
1674
1690
|
let live = false;
|
|
1675
1691
|
let created = false;
|
|
@@ -1699,7 +1715,7 @@ var RelayCore = class {
|
|
|
1699
1715
|
if (!row)
|
|
1700
1716
|
throw scope.exact ? this.exactRefusal(this.byHandle(wanted), wanted, harness, now) : new RelayError("invalid_params", `no free handle for ${wanted}`);
|
|
1701
1717
|
if (exactFrom && exactFrom.id !== row.id) ({ moved, stopBlocks } = this.leaveHooks(exactFrom, row.id));
|
|
1702
|
-
const fresh = created || joined ? null : this.incarnates(row, scope, fields.process, now);
|
|
1718
|
+
const fresh = created || joined || resumed ? null : this.incarnates(row, scope, fields.process, now);
|
|
1703
1719
|
const token = transient || live ? void 0 : `kre_${randomHex(32)}`;
|
|
1704
1720
|
const field = (column) => transient ? `COALESCE(?, ${column})` : "?";
|
|
1705
1721
|
if (session) this.sql.all(`UPDATE endpoints SET session = NULL WHERE session = ? AND harness = ? AND id != ?`, session, harness, row.id);
|
|
@@ -1739,7 +1755,14 @@ var RelayCore = class {
|
|
|
1739
1755
|
if (!transient && fields.cwd !== null) this.sql.all(`UPDATE grants SET spent_cwd = ? WHERE used_by = ?`, fields.cwd, row.id);
|
|
1740
1756
|
if (!(transient && joined)) {
|
|
1741
1757
|
const sub = scope.subagent ? { subagent: scope.subagent.type ?? "agent", parent: this.addressOf(scope.subagent.root) } : {};
|
|
1742
|
-
this.audit("claim", row.id, null, {
|
|
1758
|
+
this.audit("claim", row.id, null, {
|
|
1759
|
+
handle: row.handle,
|
|
1760
|
+
harness,
|
|
1761
|
+
...joined ? { joined: true } : {},
|
|
1762
|
+
...resumed ? { resumed: true } : {},
|
|
1763
|
+
...transient ? { transient: true } : {},
|
|
1764
|
+
...sub
|
|
1765
|
+
});
|
|
1743
1766
|
}
|
|
1744
1767
|
if (fresh) this.reset(row, scope, fresh, moved, now);
|
|
1745
1768
|
if (created || fresh === "grant") this.place(this.byId(row.id), scope, now);
|
|
@@ -1747,6 +1770,23 @@ var RelayCore = class {
|
|
|
1747
1770
|
if (scope.grant !== null) this.takePending(row.id);
|
|
1748
1771
|
return { endpoint: this.view(this.byId(row.id)), ...token ? { token } : {}, ...created ? { created: true } : {} };
|
|
1749
1772
|
}
|
|
1773
|
+
/**
|
|
1774
|
+
* the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
|
|
1775
|
+
* of a fitting harness, no subagent's, and restarted where it sits or redeemed with its grant. A claim with a grant that row did not spend
|
|
1776
|
+
* is a new spawn and takes none. A named row first, then the latest seen
|
|
1777
|
+
*/
|
|
1778
|
+
resumable(harness, harnessSession, scope, fields, now) {
|
|
1779
|
+
const rows = this.sql.all(
|
|
1780
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND harness_session = ? AND connections = 0 AND (session IS NULL OR substr(session, 1, 4) != 'sub:')
|
|
1781
|
+
AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY named IS NULL, COALESCE(last_seen, created_at) DESC`,
|
|
1782
|
+
harnessSession
|
|
1783
|
+
);
|
|
1784
|
+
const parent = rows.length ? this.placement(scope, now).parent : null;
|
|
1785
|
+
const spender = scope.grant === null ? null : this.spentGrant(scope)?.used_by ?? "";
|
|
1786
|
+
return rows.find(
|
|
1787
|
+
(row) => (spender === null || spender === row.id) && this.localRow(row) && harnessFits(row.harness, harness) && presenceOf(row, now) === "offline" && (this.restarts(row, parent, fields.process) || this.redeems(row, scope, { harness, ...fields }))
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1750
1790
|
/** a session's hook endpoint left for `to`: its unread mail moves there and it lets go of the session; the moved seqs and its stop budget */
|
|
1751
1791
|
leaveHooks(hooks, to) {
|
|
1752
1792
|
const moved = this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL RETURNING seq`, to, hooks.id).map((d) => d.seq);
|
|
@@ -2445,9 +2485,11 @@ var RelayCore = class {
|
|
|
2445
2485
|
}
|
|
2446
2486
|
/**
|
|
2447
2487
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
2448
|
-
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
2488
|
+
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint,
|
|
2489
|
+
* except an offline one {@link renameRow} takes over
|
|
2449
2490
|
*/
|
|
2450
2491
|
rename(actor, params) {
|
|
2492
|
+
if (params.take_over !== void 0 && typeof params.take_over !== "boolean") throw new RelayError("invalid_params", "take_over is true or false");
|
|
2451
2493
|
return this.scopeTx(() => {
|
|
2452
2494
|
const me = this.actorRow(actor);
|
|
2453
2495
|
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
@@ -2455,12 +2497,15 @@ var RelayCore = class {
|
|
|
2455
2497
|
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2456
2498
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself");
|
|
2457
2499
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed");
|
|
2458
|
-
this.renameRow(target, params.handle, actor.kind === "human");
|
|
2459
|
-
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session };
|
|
2500
|
+
const retired = this.renameRow(target, params.handle, actor.kind === "human", params.take_over === true);
|
|
2501
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session, ...retired ? { retired } : {} };
|
|
2460
2502
|
});
|
|
2461
2503
|
}
|
|
2462
|
-
/**
|
|
2463
|
-
|
|
2504
|
+
/**
|
|
2505
|
+
* the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
|
|
2506
|
+
* is taken over when it held the same harness session, or with `takeOver` by the human at a terminal: the id of the row retired
|
|
2507
|
+
*/
|
|
2508
|
+
renameRow(row, value, byHuman, takeOver = false) {
|
|
2464
2509
|
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : "";
|
|
2465
2510
|
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`);
|
|
2466
2511
|
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential");
|
|
@@ -2473,14 +2518,23 @@ var RelayCore = class {
|
|
|
2473
2518
|
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`);
|
|
2474
2519
|
}
|
|
2475
2520
|
const held = this.byHandle(handle);
|
|
2521
|
+
let retired;
|
|
2476
2522
|
if (held) {
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2523
|
+
const offline = held.kind === "agent" && held.connections === 0 && presenceOf(held, this.now()) === "offline" && this.localRow(held);
|
|
2524
|
+
const same = held.harness_session !== null && held.harness_session === row.harness_session;
|
|
2525
|
+
if (takeOver && !byHuman && !same) throw new RelayError("scope_refused", "only the human at a terminal takes over another endpoint");
|
|
2526
|
+
if (takeOver && offline && !same && !harnessFits(held.harness, row.harness ?? "cli"))
|
|
2527
|
+
throw new RelayError("invalid_params", `${handle} is a ${held.harness} endpoint: only an endpoint of the same harness is taken over`);
|
|
2528
|
+
if (!offline || held.session?.startsWith("sub:") || !(same || takeOver && harnessFits(held.harness, row.harness ?? "cli"))) {
|
|
2529
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
|
|
2530
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name${offline ? ", or take it over with --take-over from a terminal" : ""}`);
|
|
2531
|
+
}
|
|
2532
|
+
retired = this.takeOver(row, held, same ? "session" : "human");
|
|
2479
2533
|
}
|
|
2480
2534
|
const from = row.handle;
|
|
2481
2535
|
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id);
|
|
2482
2536
|
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from);
|
|
2483
|
-
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
2537
|
+
for (const prefix of this.byId(row.id).parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
2484
2538
|
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)];
|
|
2485
2539
|
for (const [table, column] of [
|
|
2486
2540
|
["endpoints", "rules"],
|
|
@@ -2492,6 +2546,51 @@ var RelayCore = class {
|
|
|
2492
2546
|
}
|
|
2493
2547
|
this.renamedHandle(from, handle);
|
|
2494
2548
|
this.audit("rename", row.id, null, { from, to: handle });
|
|
2549
|
+
return retired;
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
|
|
2553
|
+
* `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
|
|
2554
|
+
*/
|
|
2555
|
+
takeOver(row, held, by) {
|
|
2556
|
+
if (this.ancestorsOf(row).includes(held.id))
|
|
2557
|
+
throw new RelayError("scope_refused", `${this.address(row)} sits under ${this.address(held)}: it cannot take over its own ancestor`);
|
|
2558
|
+
if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_addr !== null)
|
|
2559
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, held.parent_addr, held.linked, row.id);
|
|
2560
|
+
else if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_ep !== null) {
|
|
2561
|
+
const parent = this.byId(held.parent_ep);
|
|
2562
|
+
if (parent) this.link(row, parent, { by: this.address(row), auth: by });
|
|
2563
|
+
}
|
|
2564
|
+
const place = this.byId(row.id);
|
|
2565
|
+
const shift = (place.depth ?? 0) - (held.depth ?? 0);
|
|
2566
|
+
const below = `WITH RECURSIVE below(id) AS (SELECT id FROM endpoints WHERE parent_ep = ? UNION SELECT e.id FROM endpoints e JOIN below b ON e.parent_ep = b.id)`;
|
|
2567
|
+
const deepest = one(this.sql, `${below} SELECT MAX(depth) AS d FROM endpoints WHERE id IN (SELECT id FROM below)`, held.id)?.d;
|
|
2568
|
+
if (deepest != null && deepest + shift > LIMITS.depthMax)
|
|
2569
|
+
throw new RelayError("scope_refused", `${this.address(held)}'s children would sit deeper than ${LIMITS.depthMax} under ${this.address(place)}`);
|
|
2570
|
+
this.sql.all(`${below} UPDATE endpoints SET tree = ?, depth = depth + ? WHERE id IN (SELECT id FROM below)`, held.id, place.tree ?? place.id, shift);
|
|
2571
|
+
const children = this.sql.all(`UPDATE endpoints SET parent_ep = ? WHERE parent_ep = ? RETURNING id`, row.id, held.id).length;
|
|
2572
|
+
this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL`, row.id, held.id);
|
|
2573
|
+
this.sql.all(`UPDATE grants SET issuer = ? WHERE issuer = ?`, row.id, held.id);
|
|
2574
|
+
this.sql.all(`UPDATE grants SET under = ? WHERE under = ?`, row.id, held.id);
|
|
2575
|
+
this.sql.all(`UPDATE groups SET owner = ? WHERE owner = ?`, row.id, held.id);
|
|
2576
|
+
this.sql.all(
|
|
2577
|
+
`INSERT OR IGNORE INTO group_members (name, endpoint, added_by, at) SELECT name, ?, added_by, at FROM group_members WHERE endpoint = ?`,
|
|
2578
|
+
row.id,
|
|
2579
|
+
held.id
|
|
2580
|
+
);
|
|
2581
|
+
this.sql.all(`DELETE FROM group_members WHERE endpoint = ?`, held.id);
|
|
2582
|
+
this.sql.all(
|
|
2583
|
+
`UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ? AND harness_session IS NULL`,
|
|
2584
|
+
held.harness_session,
|
|
2585
|
+
held.harness_transcript,
|
|
2586
|
+
row.id
|
|
2587
|
+
);
|
|
2588
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id);
|
|
2589
|
+
this.dropPeek(held.id);
|
|
2590
|
+
this.scopeMemo?.ancestors.clear();
|
|
2591
|
+
this.scopeMemo?.limited.clear();
|
|
2592
|
+
this.audit("take_over", row.id, null, { from: row.handle, to: held.handle, retired: held.id, children, by });
|
|
2593
|
+
return held.id;
|
|
2495
2594
|
}
|
|
2496
2595
|
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
|
2497
2596
|
renamedHandle(_from, _to) {
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -728,9 +728,14 @@ export class RelayCore {
|
|
|
728
728
|
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
729
729
|
session: cleanText(claim.session, "session", 128),
|
|
730
730
|
process: cleanText(claim.process, "process", 128),
|
|
731
|
+
harness_session: cleanText(claim.harness_session, "harness_session", 128),
|
|
731
732
|
}
|
|
732
733
|
if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]")
|
|
733
|
-
|
|
734
|
+
if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
|
|
735
|
+
throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -")
|
|
736
|
+
const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session]
|
|
737
|
+
.map((v) => (v ? findSecret(v) : null))
|
|
738
|
+
.find(Boolean)
|
|
734
739
|
if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`)
|
|
735
740
|
if (claim.grant !== undefined && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
|
|
736
741
|
throw new RelayError("invalid_params", "grant is a krt_ token")
|
|
@@ -834,7 +839,15 @@ export class RelayCore {
|
|
|
834
839
|
private claimRow(
|
|
835
840
|
wanted: string,
|
|
836
841
|
harness: string,
|
|
837
|
-
fields: {
|
|
842
|
+
fields: {
|
|
843
|
+
model: string | null
|
|
844
|
+
cwd: string | null
|
|
845
|
+
parent: string | null
|
|
846
|
+
labels: string
|
|
847
|
+
session: string | null
|
|
848
|
+
process: string | null
|
|
849
|
+
harness_session?: string | null
|
|
850
|
+
},
|
|
838
851
|
transient: boolean,
|
|
839
852
|
scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
|
|
840
853
|
): { endpoint: EndpointView; token?: string; created?: true } {
|
|
@@ -859,6 +872,7 @@ export class RelayCore {
|
|
|
859
872
|
let stopBlocks: number | null = null
|
|
860
873
|
let moved: number[] = []
|
|
861
874
|
let exactFrom: EndpointRow | undefined
|
|
875
|
+
const open = !row || presenceOf(row, now) === "offline"
|
|
862
876
|
if (row && presenceOf(row, now) === "offline") {
|
|
863
877
|
this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id)
|
|
864
878
|
row = undefined
|
|
@@ -898,7 +912,12 @@ export class RelayCore {
|
|
|
898
912
|
exactFrom = hooks
|
|
899
913
|
}
|
|
900
914
|
} else if (row && transient) session = row.session
|
|
901
|
-
|
|
915
|
+
const resumed =
|
|
916
|
+
!row && open && !scope.exact && !scope.subagent && fields.harness_session
|
|
917
|
+
? this.resumable(harness, fields.harness_session, scope, fields, now)
|
|
918
|
+
: undefined
|
|
919
|
+
if (resumed) row = resumed
|
|
920
|
+
let joined = row !== undefined && !resumed
|
|
902
921
|
let own = false
|
|
903
922
|
let live = false
|
|
904
923
|
let created = false
|
|
@@ -944,7 +963,7 @@ export class RelayCore {
|
|
|
944
963
|
if (!row)
|
|
945
964
|
throw scope.exact ? this.exactRefusal(this.byHandle(wanted)!, wanted, harness, now) : new RelayError("invalid_params", `no free handle for ${wanted}`)
|
|
946
965
|
if (exactFrom && exactFrom.id !== row.id) ({ moved, stopBlocks } = this.leaveHooks(exactFrom, row.id))
|
|
947
|
-
const fresh = created || joined ? null : this.incarnates(row, scope, fields.process, now)
|
|
966
|
+
const fresh = created || joined || resumed ? null : this.incarnates(row, scope, fields.process, now)
|
|
948
967
|
const token = transient || live ? undefined : `kre_${randomHex(32)}`
|
|
949
968
|
const field = (column: string) => (transient ? `COALESCE(?, ${column})` : "?")
|
|
950
969
|
if (session) this.sql.all(`UPDATE endpoints SET session = NULL WHERE session = ? AND harness = ? AND id != ?`, session, harness, row.id)
|
|
@@ -984,7 +1003,14 @@ export class RelayCore {
|
|
|
984
1003
|
if (!transient && fields.cwd !== null) this.sql.all(`UPDATE grants SET spent_cwd = ? WHERE used_by = ?`, fields.cwd, row.id)
|
|
985
1004
|
if (!(transient && joined)) {
|
|
986
1005
|
const sub = scope.subagent ? { subagent: scope.subagent.type ?? "agent", parent: this.addressOf(scope.subagent.root) } : {}
|
|
987
|
-
this.audit("claim", row.id, null, {
|
|
1006
|
+
this.audit("claim", row.id, null, {
|
|
1007
|
+
handle: row.handle,
|
|
1008
|
+
harness,
|
|
1009
|
+
...(joined ? { joined: true } : {}),
|
|
1010
|
+
...(resumed ? { resumed: true } : {}),
|
|
1011
|
+
...(transient ? { transient: true } : {}),
|
|
1012
|
+
...sub,
|
|
1013
|
+
})
|
|
988
1014
|
}
|
|
989
1015
|
if (fresh) this.reset(row, scope, fresh, moved, now)
|
|
990
1016
|
if (created || fresh === "grant") this.place(this.byId(row.id)!, scope, now)
|
|
@@ -993,6 +1019,35 @@ export class RelayCore {
|
|
|
993
1019
|
return { endpoint: this.view(this.byId(row.id)!), ...(token ? { token } : {}), ...(created ? { created: true as const } : {}) }
|
|
994
1020
|
}
|
|
995
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
|
|
1024
|
+
* of a fitting harness, no subagent's, and restarted where it sits or redeemed with its grant. A claim with a grant that row did not spend
|
|
1025
|
+
* is a new spawn and takes none. A named row first, then the latest seen
|
|
1026
|
+
*/
|
|
1027
|
+
private resumable(
|
|
1028
|
+
harness: string,
|
|
1029
|
+
harnessSession: string,
|
|
1030
|
+
scope: ClaimScope,
|
|
1031
|
+
fields: { cwd: string | null; process: string | null },
|
|
1032
|
+
now: number,
|
|
1033
|
+
): EndpointRow | undefined {
|
|
1034
|
+
const rows = this.sql.all<EndpointRow>(
|
|
1035
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND harness_session = ? AND connections = 0 AND (session IS NULL OR substr(session, 1, 4) != 'sub:')
|
|
1036
|
+
AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY named IS NULL, COALESCE(last_seen, created_at) DESC`,
|
|
1037
|
+
harnessSession,
|
|
1038
|
+
)
|
|
1039
|
+
const parent = rows.length ? this.placement(scope, now).parent : null
|
|
1040
|
+
const spender = scope.grant === null ? null : (this.spentGrant(scope)?.used_by ?? "")
|
|
1041
|
+
return rows.find(
|
|
1042
|
+
(row) =>
|
|
1043
|
+
(spender === null || spender === row.id) &&
|
|
1044
|
+
this.localRow(row) &&
|
|
1045
|
+
harnessFits(row.harness, harness) &&
|
|
1046
|
+
presenceOf(row, now) === "offline" &&
|
|
1047
|
+
(this.restarts(row, parent, fields.process) || this.redeems(row, scope, { harness, ...fields })),
|
|
1048
|
+
)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
996
1051
|
/** a session's hook endpoint left for `to`: its unread mail moves there and it lets go of the session; the moved seqs and its stop budget */
|
|
997
1052
|
private leaveHooks(hooks: EndpointRow, to: string): { moved: number[]; stopBlocks: number } {
|
|
998
1053
|
const moved = this.sql
|
|
@@ -1778,9 +1833,14 @@ export class RelayCore {
|
|
|
1778
1833
|
|
|
1779
1834
|
/**
|
|
1780
1835
|
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1781
|
-
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
|
|
1836
|
+
* the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint,
|
|
1837
|
+
* except an offline one {@link renameRow} takes over
|
|
1782
1838
|
*/
|
|
1783
|
-
rename(
|
|
1839
|
+
rename(
|
|
1840
|
+
actor: ScopeActor,
|
|
1841
|
+
params: { endpoint?: unknown; handle: unknown; take_over?: unknown },
|
|
1842
|
+
): { endpoint: EndpointView; from: string; session: string | null; retired?: string } {
|
|
1843
|
+
if (params.take_over !== undefined && typeof params.take_over !== "boolean") throw new RelayError("invalid_params", "take_over is true or false")
|
|
1784
1844
|
return this.scopeTx(() => {
|
|
1785
1845
|
const me = this.actorRow(actor)
|
|
1786
1846
|
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
@@ -1788,13 +1848,16 @@ export class RelayCore {
|
|
|
1788
1848
|
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1789
1849
|
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself")
|
|
1790
1850
|
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed")
|
|
1791
|
-
this.renameRow(target, params.handle, actor.kind === "human")
|
|
1792
|
-
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session }
|
|
1851
|
+
const retired = this.renameRow(target, params.handle, actor.kind === "human", params.take_over === true)
|
|
1852
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session, ...(retired ? { retired } : {}) }
|
|
1793
1853
|
})
|
|
1794
1854
|
}
|
|
1795
1855
|
|
|
1796
|
-
/**
|
|
1797
|
-
|
|
1856
|
+
/**
|
|
1857
|
+
* the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
|
|
1858
|
+
* is taken over when it held the same harness session, or with `takeOver` by the human at a terminal: the id of the row retired
|
|
1859
|
+
*/
|
|
1860
|
+
private renameRow(row: EndpointRow, value: unknown, byHuman: boolean, takeOver = false): string | undefined {
|
|
1798
1861
|
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : ""
|
|
1799
1862
|
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`)
|
|
1800
1863
|
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential")
|
|
@@ -1807,14 +1870,23 @@ export class RelayCore {
|
|
|
1807
1870
|
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`)
|
|
1808
1871
|
}
|
|
1809
1872
|
const held = this.byHandle(handle)
|
|
1873
|
+
let retired: string | undefined
|
|
1810
1874
|
if (held) {
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1875
|
+
const offline = held.kind === "agent" && held.connections === 0 && presenceOf(held, this.now()) === "offline" && this.localRow(held)
|
|
1876
|
+
const same = held.harness_session !== null && held.harness_session === row.harness_session
|
|
1877
|
+
if (takeOver && !byHuman && !same) throw new RelayError("scope_refused", "only the human at a terminal takes over another endpoint")
|
|
1878
|
+
if (takeOver && offline && !same && !harnessFits(held.harness, row.harness ?? "cli"))
|
|
1879
|
+
throw new RelayError("invalid_params", `${handle} is a ${held.harness} endpoint: only an endpoint of the same harness is taken over`)
|
|
1880
|
+
if (!offline || held.session?.startsWith("sub:") || !(same || (takeOver && harnessFits(held.harness, row.harness ?? "cli")))) {
|
|
1881
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
|
|
1882
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name${offline ? ", or take it over with --take-over from a terminal" : ""}`)
|
|
1883
|
+
}
|
|
1884
|
+
retired = this.takeOver(row, held, same ? "session" : "human")
|
|
1813
1885
|
}
|
|
1814
1886
|
const from = row.handle
|
|
1815
1887
|
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id)
|
|
1816
1888
|
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from)
|
|
1817
|
-
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
1889
|
+
for (const prefix of this.byId(row.id)!.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
1818
1890
|
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)]
|
|
1819
1891
|
for (const [table, column] of [
|
|
1820
1892
|
["endpoints", "rules"],
|
|
@@ -1826,6 +1898,52 @@ export class RelayCore {
|
|
|
1826
1898
|
}
|
|
1827
1899
|
this.renamedHandle(from, handle)
|
|
1828
1900
|
this.audit("rename", row.id, null, { from, to: handle })
|
|
1901
|
+
return retired
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
|
|
1906
|
+
* `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
|
|
1907
|
+
*/
|
|
1908
|
+
private takeOver(row: EndpointRow, held: EndpointRow, by: "human" | "session"): string {
|
|
1909
|
+
if (this.ancestorsOf(row).includes(held.id))
|
|
1910
|
+
throw new RelayError("scope_refused", `${this.address(row)} sits under ${this.address(held)}: it cannot take over its own ancestor`)
|
|
1911
|
+
if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_addr !== null)
|
|
1912
|
+
this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, held.parent_addr, held.linked, row.id)
|
|
1913
|
+
else if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_ep !== null) {
|
|
1914
|
+
const parent = this.byId(held.parent_ep)
|
|
1915
|
+
if (parent) this.link(row, parent, { by: this.address(row), auth: by })
|
|
1916
|
+
}
|
|
1917
|
+
const place = this.byId(row.id)!
|
|
1918
|
+
const shift = (place.depth ?? 0) - (held.depth ?? 0)
|
|
1919
|
+
const below = `WITH RECURSIVE below(id) AS (SELECT id FROM endpoints WHERE parent_ep = ? UNION SELECT e.id FROM endpoints e JOIN below b ON e.parent_ep = b.id)`
|
|
1920
|
+
const deepest = one<{ d: number | null }>(this.sql, `${below} SELECT MAX(depth) AS d FROM endpoints WHERE id IN (SELECT id FROM below)`, held.id)?.d
|
|
1921
|
+
if (deepest != null && deepest + shift > LIMITS.depthMax)
|
|
1922
|
+
throw new RelayError("scope_refused", `${this.address(held)}'s children would sit deeper than ${LIMITS.depthMax} under ${this.address(place)}`)
|
|
1923
|
+
this.sql.all(`${below} UPDATE endpoints SET tree = ?, depth = depth + ? WHERE id IN (SELECT id FROM below)`, held.id, place.tree ?? place.id, shift)
|
|
1924
|
+
const children = this.sql.all<{ id: string }>(`UPDATE endpoints SET parent_ep = ? WHERE parent_ep = ? RETURNING id`, row.id, held.id).length
|
|
1925
|
+
this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL`, row.id, held.id)
|
|
1926
|
+
this.sql.all(`UPDATE grants SET issuer = ? WHERE issuer = ?`, row.id, held.id)
|
|
1927
|
+
this.sql.all(`UPDATE grants SET under = ? WHERE under = ?`, row.id, held.id)
|
|
1928
|
+
this.sql.all(`UPDATE groups SET owner = ? WHERE owner = ?`, row.id, held.id)
|
|
1929
|
+
this.sql.all(
|
|
1930
|
+
`INSERT OR IGNORE INTO group_members (name, endpoint, added_by, at) SELECT name, ?, added_by, at FROM group_members WHERE endpoint = ?`,
|
|
1931
|
+
row.id,
|
|
1932
|
+
held.id,
|
|
1933
|
+
)
|
|
1934
|
+
this.sql.all(`DELETE FROM group_members WHERE endpoint = ?`, held.id)
|
|
1935
|
+
this.sql.all(
|
|
1936
|
+
`UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ? AND harness_session IS NULL`,
|
|
1937
|
+
held.harness_session,
|
|
1938
|
+
held.harness_transcript,
|
|
1939
|
+
row.id,
|
|
1940
|
+
)
|
|
1941
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id)
|
|
1942
|
+
this.dropPeek(held.id)
|
|
1943
|
+
this.scopeMemo?.ancestors.clear()
|
|
1944
|
+
this.scopeMemo?.limited.clear()
|
|
1945
|
+
this.audit("take_over", row.id, null, { from: row.handle, to: held.handle, retired: held.id, children, by })
|
|
1946
|
+
return held.id
|
|
1829
1947
|
}
|
|
1830
1948
|
|
|
1831
1949
|
/** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
|
package/src/protocol.ts
CHANGED
|
@@ -111,9 +111,19 @@ export type MessageKind = "message" | "request" | "reply" | "status"
|
|
|
111
111
|
export type DataClass = "public" | "private"
|
|
112
112
|
export type Presence = "online" | "idle" | "offline"
|
|
113
113
|
export type StatusState = "working" | "waiting" | "blocked" | "done"
|
|
114
|
-
/** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter
|
|
115
|
-
export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "none"
|
|
116
|
-
export const WAKES: readonly Wake[] = [
|
|
114
|
+
/** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter, codex app-server, the session inbox, a headless resume or muse serve; `none` reads at its next turn */
|
|
115
|
+
export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "agy-resume" | "grok-resume" | "muse-serve" | "none"
|
|
116
|
+
export const WAKES: readonly Wake[] = [
|
|
117
|
+
"channels",
|
|
118
|
+
"pi",
|
|
119
|
+
"opencode-plugin",
|
|
120
|
+
"codex-app-server",
|
|
121
|
+
"claude-inbox",
|
|
122
|
+
"agy-resume",
|
|
123
|
+
"grok-resume",
|
|
124
|
+
"muse-serve",
|
|
125
|
+
"none",
|
|
126
|
+
]
|
|
117
127
|
|
|
118
128
|
export type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end"
|
|
119
129
|
export const ACTIVITY_KINDS: readonly ActivityKind[] = ["session_start", "tool", "stop", "note", "session_end"]
|
|
@@ -170,6 +180,8 @@ export interface Claim {
|
|
|
170
180
|
session?: string
|
|
171
181
|
/** the harness process the claim comes from, `pid:<pid>:<start time>`: a shim restarted in that process may rejoin its endpoint */
|
|
172
182
|
process?: string
|
|
183
|
+
/** the harness's own stable session id (Claude Code's): a resumed session in a new harness process takes back the offline endpoint of that id */
|
|
184
|
+
harness_session?: string
|
|
173
185
|
/** process keys of the claimant's harness process and its ancestors, nearest first: the nearest endpoint holding one places the claim under it */
|
|
174
186
|
lineage?: string[]
|
|
175
187
|
/** a `krt_` grant from `spawn-env`: places the claim at or below its lineage placement, with the grant's caps and groups */
|