@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 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 or codex app-server; `none` reads at its next turn */
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
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
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 = ["channels", "pi", "opencode-plugin", "codex-app-server", "claude-inbox", "none"];
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
- const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session].map((v) => v ? findSecret(v) : null).find(Boolean);
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
- let joined = row !== void 0;
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, { handle: row.handle, harness, ...joined ? { joined: true } : {}, ...transient ? { transient: true } : {}, ...sub });
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
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
2463
- renameRow(row, value, byHuman) {
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 how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
2478
- throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Swarm Communication Protocol core: wire types, framing, scopes, policy, judge and the relay core.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
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
- const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session].map((v) => (v ? findSecret(v) : null)).find(Boolean)
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: { model: string | null; cwd: string | null; parent: string | null; labels: string; session: string | null; process: string | null },
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
- let joined = row !== undefined
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, { handle: row.handle, harness, ...(joined ? { joined: true } : {}), ...(transient ? { transient: true } : {}), ...sub })
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(actor: ScopeActor, params: { endpoint?: unknown; handle: unknown }): { endpoint: EndpointView; from: string; session: string | null } {
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
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1797
- private renameRow(row: EndpointRow, value: unknown, byHuman: boolean): void {
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 how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
1812
- throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`)
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 or codex app-server; `none` reads at its next turn */
115
- export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "none"
116
- export const WAKES: readonly Wake[] = ["channels", "pi", "opencode-plugin", "codex-app-server", "claude-inbox", "none"]
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 */