@scp-dev/core 0.3.0 → 0.3.1

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
@@ -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
@@ -1521,10 +1521,13 @@ var RelayCore = class {
1521
1521
  parent: cleanText(claim.parent, "parent", 128),
1522
1522
  labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
1523
1523
  session: cleanText(claim.session, "session", 128),
1524
- process: cleanText(claim.process, "process", 128)
1524
+ process: cleanText(claim.process, "process", 128),
1525
+ harness_session: cleanText(claim.harness_session, "harness_session", 128)
1525
1526
  };
1526
1527
  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);
1528
+ if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
1529
+ throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -");
1530
+ 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
1531
  if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`);
1529
1532
  if (claim.grant !== void 0 && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
1530
1533
  throw new RelayError("invalid_params", "grant is a krt_ token");
@@ -1636,6 +1639,7 @@ var RelayCore = class {
1636
1639
  let stopBlocks = null;
1637
1640
  let moved = [];
1638
1641
  let exactFrom;
1642
+ const open = !row || presenceOf(row, now) === "offline";
1639
1643
  if (row && presenceOf(row, now) === "offline") {
1640
1644
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
1641
1645
  row = void 0;
@@ -1669,7 +1673,9 @@ var RelayCore = class {
1669
1673
  exactFrom = hooks;
1670
1674
  }
1671
1675
  } else if (row && transient) session = row.session;
1672
- let joined = row !== void 0;
1676
+ const resumed = !row && open && !scope.exact && !scope.subagent && fields.harness_session ? this.resumable(harness, fields.harness_session, scope, fields, now) : void 0;
1677
+ if (resumed) row = resumed;
1678
+ let joined = row !== void 0 && !resumed;
1673
1679
  let own = false;
1674
1680
  let live = false;
1675
1681
  let created = false;
@@ -1699,7 +1705,7 @@ var RelayCore = class {
1699
1705
  if (!row)
1700
1706
  throw scope.exact ? this.exactRefusal(this.byHandle(wanted), wanted, harness, now) : new RelayError("invalid_params", `no free handle for ${wanted}`);
1701
1707
  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);
1708
+ const fresh = created || joined || resumed ? null : this.incarnates(row, scope, fields.process, now);
1703
1709
  const token = transient || live ? void 0 : `kre_${randomHex(32)}`;
1704
1710
  const field = (column) => transient ? `COALESCE(?, ${column})` : "?";
1705
1711
  if (session) this.sql.all(`UPDATE endpoints SET session = NULL WHERE session = ? AND harness = ? AND id != ?`, session, harness, row.id);
@@ -1739,7 +1745,14 @@ var RelayCore = class {
1739
1745
  if (!transient && fields.cwd !== null) this.sql.all(`UPDATE grants SET spent_cwd = ? WHERE used_by = ?`, fields.cwd, row.id);
1740
1746
  if (!(transient && joined)) {
1741
1747
  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 });
1748
+ this.audit("claim", row.id, null, {
1749
+ handle: row.handle,
1750
+ harness,
1751
+ ...joined ? { joined: true } : {},
1752
+ ...resumed ? { resumed: true } : {},
1753
+ ...transient ? { transient: true } : {},
1754
+ ...sub
1755
+ });
1743
1756
  }
1744
1757
  if (fresh) this.reset(row, scope, fresh, moved, now);
1745
1758
  if (created || fresh === "grant") this.place(this.byId(row.id), scope, now);
@@ -1747,6 +1760,23 @@ var RelayCore = class {
1747
1760
  if (scope.grant !== null) this.takePending(row.id);
1748
1761
  return { endpoint: this.view(this.byId(row.id)), ...token ? { token } : {}, ...created ? { created: true } : {} };
1749
1762
  }
1763
+ /**
1764
+ * the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
1765
+ * 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
1766
+ * is a new spawn and takes none. A named row first, then the latest seen
1767
+ */
1768
+ resumable(harness, harnessSession, scope, fields, now) {
1769
+ const rows = this.sql.all(
1770
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND harness_session = ? AND connections = 0 AND (session IS NULL OR substr(session, 1, 4) != 'sub:')
1771
+ AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY named IS NULL, COALESCE(last_seen, created_at) DESC`,
1772
+ harnessSession
1773
+ );
1774
+ const parent = rows.length ? this.placement(scope, now).parent : null;
1775
+ const spender = scope.grant === null ? null : this.spentGrant(scope)?.used_by ?? "";
1776
+ return rows.find(
1777
+ (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 }))
1778
+ );
1779
+ }
1750
1780
  /** 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
1781
  leaveHooks(hooks, to) {
1752
1782
  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 +2475,11 @@ var RelayCore = class {
2445
2475
  }
2446
2476
  /**
2447
2477
  * 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
2478
+ * 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,
2479
+ * except an offline one {@link renameRow} takes over
2449
2480
  */
2450
2481
  rename(actor, params) {
2482
+ if (params.take_over !== void 0 && typeof params.take_over !== "boolean") throw new RelayError("invalid_params", "take_over is true or false");
2451
2483
  return this.scopeTx(() => {
2452
2484
  const me = this.actorRow(actor);
2453
2485
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
@@ -2455,12 +2487,15 @@ var RelayCore = class {
2455
2487
  const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2456
2488
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself");
2457
2489
  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 };
2490
+ const retired = this.renameRow(target, params.handle, actor.kind === "human", params.take_over === true);
2491
+ return { endpoint: this.whoami(target.id), from: target.handle, session: target.session, ...retired ? { retired } : {} };
2460
2492
  });
2461
2493
  }
2462
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
2463
- renameRow(row, value, byHuman) {
2494
+ /**
2495
+ * the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
2496
+ * 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
2497
+ */
2498
+ renameRow(row, value, byHuman, takeOver = false) {
2464
2499
  const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : "";
2465
2500
  if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`);
2466
2501
  if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential");
@@ -2473,14 +2508,23 @@ var RelayCore = class {
2473
2508
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`);
2474
2509
  }
2475
2510
  const held = this.byHandle(handle);
2511
+ let retired;
2476
2512
  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`);
2513
+ const offline = held.kind === "agent" && held.connections === 0 && presenceOf(held, this.now()) === "offline" && this.localRow(held);
2514
+ const same = held.harness_session !== null && held.harness_session === row.harness_session;
2515
+ if (takeOver && !byHuman && !same) throw new RelayError("scope_refused", "only the human at a terminal takes over another endpoint");
2516
+ if (takeOver && offline && !same && !harnessFits(held.harness, row.harness ?? "cli"))
2517
+ throw new RelayError("invalid_params", `${handle} is a ${held.harness} endpoint: only an endpoint of the same harness is taken over`);
2518
+ if (!offline || held.session?.startsWith("sub:") || !(same || takeOver && harnessFits(held.harness, row.harness ?? "cli"))) {
2519
+ const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
2520
+ throw new RelayError("invalid_params", `${handle} is ${how}: pick another name${offline ? ", or take it over with --take-over from a terminal" : ""}`);
2521
+ }
2522
+ retired = this.takeOver(row, held, same ? "session" : "human");
2479
2523
  }
2480
2524
  const from = row.handle;
2481
2525
  this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id);
2482
2526
  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:"]) {
2527
+ for (const prefix of this.byId(row.id).parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
2484
2528
  const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)];
2485
2529
  for (const [table, column] of [
2486
2530
  ["endpoints", "rules"],
@@ -2492,6 +2536,51 @@ var RelayCore = class {
2492
2536
  }
2493
2537
  this.renamedHandle(from, handle);
2494
2538
  this.audit("rename", row.id, null, { from, to: handle });
2539
+ return retired;
2540
+ }
2541
+ /**
2542
+ * `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
2543
+ * `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
2544
+ */
2545
+ takeOver(row, held, by) {
2546
+ if (this.ancestorsOf(row).includes(held.id))
2547
+ throw new RelayError("scope_refused", `${this.address(row)} sits under ${this.address(held)}: it cannot take over its own ancestor`);
2548
+ if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_addr !== null)
2549
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, held.parent_addr, held.linked, row.id);
2550
+ else if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_ep !== null) {
2551
+ const parent = this.byId(held.parent_ep);
2552
+ if (parent) this.link(row, parent, { by: this.address(row), auth: by });
2553
+ }
2554
+ const place = this.byId(row.id);
2555
+ const shift = (place.depth ?? 0) - (held.depth ?? 0);
2556
+ 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)`;
2557
+ const deepest = one(this.sql, `${below} SELECT MAX(depth) AS d FROM endpoints WHERE id IN (SELECT id FROM below)`, held.id)?.d;
2558
+ if (deepest != null && deepest + shift > LIMITS.depthMax)
2559
+ throw new RelayError("scope_refused", `${this.address(held)}'s children would sit deeper than ${LIMITS.depthMax} under ${this.address(place)}`);
2560
+ this.sql.all(`${below} UPDATE endpoints SET tree = ?, depth = depth + ? WHERE id IN (SELECT id FROM below)`, held.id, place.tree ?? place.id, shift);
2561
+ const children = this.sql.all(`UPDATE endpoints SET parent_ep = ? WHERE parent_ep = ? RETURNING id`, row.id, held.id).length;
2562
+ this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL`, row.id, held.id);
2563
+ this.sql.all(`UPDATE grants SET issuer = ? WHERE issuer = ?`, row.id, held.id);
2564
+ this.sql.all(`UPDATE grants SET under = ? WHERE under = ?`, row.id, held.id);
2565
+ this.sql.all(`UPDATE groups SET owner = ? WHERE owner = ?`, row.id, held.id);
2566
+ this.sql.all(
2567
+ `INSERT OR IGNORE INTO group_members (name, endpoint, added_by, at) SELECT name, ?, added_by, at FROM group_members WHERE endpoint = ?`,
2568
+ row.id,
2569
+ held.id
2570
+ );
2571
+ this.sql.all(`DELETE FROM group_members WHERE endpoint = ?`, held.id);
2572
+ this.sql.all(
2573
+ `UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ? AND harness_session IS NULL`,
2574
+ held.harness_session,
2575
+ held.harness_transcript,
2576
+ row.id
2577
+ );
2578
+ this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id);
2579
+ this.dropPeek(held.id);
2580
+ this.scopeMemo?.ancestors.clear();
2581
+ this.scopeMemo?.limited.clear();
2582
+ this.audit("take_over", row.id, null, { from: row.handle, to: held.handle, retired: held.id, children, by });
2583
+ return held.id;
2495
2584
  }
2496
2585
  /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
2497
2586
  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.3.1",
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
@@ -170,6 +170,8 @@ export interface Claim {
170
170
  session?: string
171
171
  /** the harness process the claim comes from, `pid:<pid>:<start time>`: a shim restarted in that process may rejoin its endpoint */
172
172
  process?: string
173
+ /** 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 */
174
+ harness_session?: string
173
175
  /** process keys of the claimant's harness process and its ancestors, nearest first: the nearest endpoint holding one places the claim under it */
174
176
  lineage?: string[]
175
177
  /** a `krt_` grant from `spawn-env`: places the claim at or below its lineage placement, with the grant's caps and groups */