pi-radar 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,11 +15,13 @@ Focused subpaths are also exported: `pi-radar/client`, `pi-radar/communication`,
15
15
 
16
16
  Installing `pi-radar` directly as a Pi package loads `extension/baseline.ts`, which creates one baseline publisher and exposes its lazy runtime handle through Pi's event bus. Herdsman must request that handle rather than starting another `RadarExecutionAdapter`. Do not load the baseline extension with an older Herdsman integration that still creates its own publisher; first migrate Herdsman to the runtime handle.
17
17
 
18
- The baseline reports session presence through one flat request. A fresh `session_start` creates a claim; heartbeat/reconnect retries reuse it. `publication.presence.management()` reports `managed`, `duplicate`, `pending` or `unmanaged`; `session_shutdown` awaits release of the exact claim. A duplicate remains rejected until a fresh Pi session load. `subject()` returns Radar's current subject only after a grant; no session marker is written to the Pi transcript.
18
+ The baseline reports session presence through one flat request. The first managed `session_start` uses its pre-minted launch claim; subsequent session loads create fresh claims. Heartbeat/reconnect retries reuse the same claim. `publication.presence.management()` reports `managed`, `duplicate`, `pending` or `unmanaged`; `session_shutdown` awaits release of the exact claim. A duplicate remains rejected until a fresh Pi session load. `subject()` returns Radar's current subject only after a grant; no session marker is written to the Pi transcript.
19
19
 
20
20
  ## Daemon contract
21
21
 
22
- The client speaks protocol 2 and requires wire revision 4. Revision 3 made lifecycle `subject_id` a stable logical identity and exposed the exact current `execution_id` and `u32` generation separately; revision 4 added Pi presence (`agent.presence`/`agent.release`), which is how a session acquires its subject, so a revision-3 daemon would accept the handshake and then leave every Pi session unmanaged. Handoff methods additionally require the independently negotiated `communication.handoff.v1` contract; its timestamps remain exactly representable as JSON numbers. Clients that control managed executions must not target the legacy per-execution ID semantics. It reads `revision` and the public `contracts` list from `daemon.ping`, preserves unfamiliar contract names, and preflights only contracts needed for a multi-step or versioned operation. Backend `mux.*` primitives are private to the daemon; direct mux methods are dispatched and return the daemon's backend refusal when unavailable.
22
+ **0.6.0 upgrade:** update Radar and pi-radar together. Lifecycle controls require both `subject_id` and `claim_id`; handoff identities and spawn edges use `claim_id`, with no execution generation. Do not pass an agent's subject id where an attempt is required. Existing private launch records remain readable.
23
+
24
+ pi-radar 0.6.0 speaks protocol 2 and requires wire revision 5. Revision 4 added Pi presence (`agent.presence`/`agent.release`), which is how a session acquires its subject. Revision 5 names the agent `subject_id` and one attempt `claim_id`, drops the execution-generation ordinal, and requires lifecycle controls to name the claim they were issued against, so a revision-4 client would read those fields with the old meanings: the floor is 5. Handoff methods additionally require the independently negotiated `communication.handoff.v1` contract; its timestamps remain exactly representable as JSON numbers. Clients that control managed executions must not target the legacy per-execution ID semantics. It reads `revision` and the public `contracts` list from `daemon.ping`, preserves unfamiliar contract names, and preflights only contracts needed for a multi-step or versioned operation. Backend `mux.*` primitives are private to the daemon; direct mux methods are dispatched and return the daemon's backend refusal when unavailable.
23
25
 
24
26
  The client uses `registry.v1` for registry operations and additionally requires `registry.facts.v1` for the facts channel and assignment extensions. Managed children require `spawn.v1`, child-pane removal requires `spawn.close.v1`, event reads require `events.v1`, and handoff operations require `communication.handoff.v1`. The expected wire examples are maintained in this repository's `docs/control-plane.fixture.jsonl` and `docs/agent-registration.fixture.jsonl` and are exercised by the package tests.
25
27
 
@@ -41,6 +43,74 @@ Lifecycle execution generations are bounded to `u32` and remain exactly represen
41
43
 
42
44
  Run `npm pack` to build the bundled runtime entrypoints before creating the tarball.
43
45
 
46
+ ## Testing against this SDK
47
+
48
+ Fake the port, not the daemon. Every consumer-facing class here depends on a
49
+ narrow `Pick` of the client, so a test double implements a handful of methods and
50
+ no socket, framing, IDs, error envelope or durable state:
51
+
52
+ | Port | Methods | Where |
53
+ | --- | --- | --- |
54
+ | `RadarPublicationClient` | `ping`, `presence`, `releasePresence`, `acquire`, `publish`, `retire` | `pi-radar/publication` |
55
+ | `RadarHandoffPort` | `publish`, `inbox`, `get`, `ack` | `pi-radar/communication` |
56
+ | anything else | `Pick<RadarClient, ...>` of the reads you call | `pi-radar/client` |
57
+
58
+ ```ts
59
+ import type { RadarPublicationClient } from "pi-radar/publication";
60
+ import { createRadarPublication } from "pi-radar/publication";
61
+
62
+ const client: RadarPublicationClient = {
63
+ ping: async () => ({
64
+ ok: true,
65
+ value: {
66
+ protocol: 2, backend: "tmux", revision: 4, instance: null,
67
+ contracts: ["registry.v1"], tasks: { available: false, error: null },
68
+ },
69
+ }),
70
+ presence: async (report) => ({
71
+ ok: true,
72
+ value: {
73
+ registration_id: REGISTRATION,
74
+ claim_id: report.claim_id,
75
+ standing: "granted",
76
+ subject_id: SUBJECT,
77
+ },
78
+ }),
79
+ releasePresence: async (release) => ({
80
+ ok: true,
81
+ value: {
82
+ registration_id: release.registration_id,
83
+ claim_id: release.claim_id,
84
+ standing: "released",
85
+ },
86
+ }),
87
+ acquire: async () => ({
88
+ ok: true,
89
+ value: {
90
+ handle: "writer", source: "consumer", incarnation: INCARNATION,
91
+ generation: 1, sequence: 0,
92
+ },
93
+ }),
94
+ publish: async ({ sequence }) => ({ ok: true, value: { sequence } }),
95
+ retire: async () => ({ ok: true, value: undefined }),
96
+ };
97
+
98
+ const publication = createRadarPublication({
99
+ dataRoot, client, registration: { source: "consumer", label: "lead" },
100
+ });
101
+ ```
102
+
103
+ A socket-level fake daemon is a second implementation of Radar's protocol
104
+ maintained by someone who does not own it. It drifts silently: it keeps passing
105
+ while the real daemon refuses the same call, which is worse than having no fake
106
+ at all. That is not hypothetical — a consumer's fake still answered the retired
107
+ registration methods after the daemon had moved to presence, and its suite stayed
108
+ green through the whole contract change.
109
+
110
+ Wire-level proof belongs to one integration test against the real daemon (a
111
+ temporary socket path and state root are enough; `tests/agent_publisher.py` in
112
+ the Radar repository shows the shape), not to a fake every consumer re-implements.
113
+
44
114
  ## Releasing
45
115
 
46
116
  Publishing is one tag push and it runs in CI; no npm token is stored anywhere.
@@ -8,10 +8,11 @@ import { lstatSync } from "node:fs";
8
8
  import { createConnection } from "node:net";
9
9
  import { dirname, join } from "node:path";
10
10
  var RADAR_PROTOCOL_VERSION = 2;
11
- var RADAR_MINIMUM_REVISION = 4;
11
+ var RADAR_MINIMUM_REVISION = 5;
12
12
  var RADAR_REGISTRY_CONTRACT = "registry.v1";
13
13
  var RADAR_REGISTRY_FACTS_CONTRACT = "registry.facts.v1";
14
14
  var RADAR_SPAWN_CONTRACT = "spawn.v1";
15
+ var RADAR_SPAWN_LIST_CONTRACT = "spawn.list.v1";
15
16
  var RADAR_SPAWN_CLOSE_CONTRACT = "spawn.close.v1";
16
17
  var RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT = "spawn.launch_context.v1";
17
18
  var RADAR_EVENTS_CONTRACT = "events.v1";
@@ -146,22 +147,20 @@ function handoffAt(value) {
146
147
  const version = numberAt(value, "version");
147
148
  const id = stringAt(value, "id");
148
149
  const sender = recordAt(value, "sender");
149
- const senderGeneration = numberAt(sender, "execution_generation");
150
150
  const target = stringAt(value, "recipient_subject_id");
151
151
  const summary = stringAt(value, "summary");
152
152
  const content = stringAt(value, "content");
153
153
  const published = numberAt(value, "published_at_unix_ms");
154
154
  const correlation = typeof value === "object" && value !== null ? value.correlation_id : void 0;
155
- if (version !== 1 || !isUuid(id) || !isUuid(stringAt(sender, "registration_id")) || !isUuid(stringAt(sender, "session_id")) || !isUuid(stringAt(sender, "subject_id")) || !isUuid(stringAt(sender, "execution_id")) || !Number.isSafeInteger(senderGeneration) || (senderGeneration ?? 0) < 1 || !isUuid(target) || summary === void 0 || content === void 0 || published === void 0 || !Number.isSafeInteger(published) || published < 0 || correlation !== void 0 && correlation !== null && (typeof correlation !== "string" || correlation.length === 0 || Buffer.byteLength(correlation, "utf8") > 256 || new RegExp("\\p{Cc}", "u").test(correlation))) return void 0;
155
+ if (version !== 1 || !isUuid(id) || !isUuid(stringAt(sender, "registration_id")) || !isUuid(stringAt(sender, "session_id")) || !isUuid(stringAt(sender, "subject_id")) || !isUuid(stringAt(sender, "claim_id")) || !isUuid(target) || summary === void 0 || content === void 0 || published === void 0 || !Number.isSafeInteger(published) || published < 0 || correlation !== void 0 && correlation !== null && (typeof correlation !== "string" || correlation.length === 0 || Buffer.byteLength(correlation, "utf8") > 256 || new RegExp("\\p{Cc}", "u").test(correlation))) return void 0;
156
156
  return value;
157
157
  }
158
158
  function handoffReceiptAt(value) {
159
159
  const version = numberAt(value, "version");
160
160
  const handoffId = stringAt(value, "handoff_id");
161
161
  const recipient = recordAt(value, "recipient");
162
- const generation = numberAt(recipient, "execution_generation");
163
162
  const received = numberAt(value, "received_at_unix_ms");
164
- if (version !== 1 || !isUuid(handoffId) || !isUuid(stringAt(recipient, "registration_id")) || !isUuid(stringAt(recipient, "session_id")) || !isUuid(stringAt(recipient, "subject_id")) || !isUuid(stringAt(recipient, "execution_id")) || !Number.isSafeInteger(generation) || (generation ?? 0) < 1 || received === void 0 || !Number.isSafeInteger(received) || received < 0) return void 0;
163
+ if (version !== 1 || !isUuid(handoffId) || !isUuid(stringAt(recipient, "registration_id")) || !isUuid(stringAt(recipient, "session_id")) || !isUuid(stringAt(recipient, "subject_id")) || !isUuid(stringAt(recipient, "claim_id")) || received === void 0 || !Number.isSafeInteger(received) || received < 0) return void 0;
165
164
  return value;
166
165
  }
167
166
  function writerBinding(value) {
@@ -621,6 +620,7 @@ function createRadarClient(options = {}) {
621
620
  };
622
621
  return answer;
623
622
  };
623
+ let processRegistrationId;
624
624
  let handshake;
625
625
  const negotiated = async () => {
626
626
  handshake ??= probe();
@@ -702,6 +702,7 @@ function createRadarClient(options = {}) {
702
702
  const record = answer.value.registration;
703
703
  const agentId = stringAt(record, "registration_id");
704
704
  if (!isUuid(agentId)) return protocol("registration reply carries no registration_id");
705
+ processRegistrationId = agentId;
705
706
  return { ok: true, value: { registration_id: agentId } };
706
707
  });
707
708
  },
@@ -835,6 +836,31 @@ function createRadarClient(options = {}) {
835
836
  }
836
837
  );
837
838
  },
839
+ async spawnList(request) {
840
+ const registrationId = processRegistrationId;
841
+ if (registrationId === void 0)
842
+ return { ok: false, code: "refused", message: "spawn.list requires this client's process registration" };
843
+ return withContract(RADAR_SPAWN_LIST_CONTRACT, async () => {
844
+ const params = {
845
+ registration_id: registrationId,
846
+ ...request.after !== void 0 ? { after: request.after } : {},
847
+ ...request.limit !== void 0 ? { limit: request.limit } : {}
848
+ };
849
+ const answer = await call("spawn.list", { ...params });
850
+ if (answer.ok === false) return answer;
851
+ const spawns = recordAt(answer.value, "spawns");
852
+ const next = recordAt(answer.value, "next");
853
+ if (!Array.isArray(spawns) || next != null && !isUuid(next))
854
+ return protocol("spawn.list reply carries no valid page");
855
+ for (const spawn of spawns) {
856
+ const decoded = spawnTopology(spawn);
857
+ if (decoded.ok === false) return decoded;
858
+ if (typeof recordAt(spawn, "version") !== "number" || typeof recordAt(spawn, "recorded_at") !== "string" || !["unbound", "bound", "unresolved"].includes(String(recordAt(spawn, "state"))))
859
+ return protocol("spawn.list reply carries an incomplete topology");
860
+ }
861
+ return { ok: true, value: answer.value };
862
+ });
863
+ },
838
864
  async spawnGet(requestId) {
839
865
  if (!isUuid(requestId))
840
866
  return {
@@ -1822,6 +1848,11 @@ function createPresenceReporter(input) {
1822
1848
  await releasePending();
1823
1849
  const registration = await input.registration();
1824
1850
  if (!registration?.process || state !== claim || stopped) return void 0;
1851
+ if (claim.initial_load) {
1852
+ if (registration.spawn_token) claim.claim_id = registration.spawn_token;
1853
+ claim.initial_load = false;
1854
+ if (!save()) return void 0;
1855
+ }
1825
1856
  const answer = await client.presence({
1826
1857
  ...registration,
1827
1858
  process: registration.process,
@@ -1860,7 +1891,7 @@ function createPresenceReporter(input) {
1860
1891
  releases.push({ registration_id: state.registration_id, claim_id: state.claim_id });
1861
1892
  }
1862
1893
  standing = void 0;
1863
- state = { version: 2, session, claim_id: randomUUID2(), releases };
1894
+ state = { version: 2, session, claim_id: randomUUID2(), initial_load: state === void 0, releases };
1864
1895
  save();
1865
1896
  stopped = false;
1866
1897
  void ensure();
@@ -4,10 +4,11 @@ import { lstatSync } from "node:fs";
4
4
  import { createConnection } from "node:net";
5
5
  import { dirname, join } from "node:path";
6
6
  var RADAR_PROTOCOL_VERSION = 2;
7
- var RADAR_MINIMUM_REVISION = 4;
7
+ var RADAR_MINIMUM_REVISION = 5;
8
8
  var RADAR_REGISTRY_CONTRACT = "registry.v1";
9
9
  var RADAR_REGISTRY_FACTS_CONTRACT = "registry.facts.v1";
10
10
  var RADAR_SPAWN_CONTRACT = "spawn.v1";
11
+ var RADAR_SPAWN_LIST_CONTRACT = "spawn.list.v1";
11
12
  var RADAR_SPAWN_CLOSE_CONTRACT = "spawn.close.v1";
12
13
  var RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT = "spawn.launch_context.v1";
13
14
  var RADAR_EVENTS_CONTRACT = "events.v1";
@@ -142,22 +143,20 @@ function handoffAt(value) {
142
143
  const version = numberAt(value, "version");
143
144
  const id = stringAt(value, "id");
144
145
  const sender = recordAt(value, "sender");
145
- const senderGeneration = numberAt(sender, "execution_generation");
146
146
  const target = stringAt(value, "recipient_subject_id");
147
147
  const summary = stringAt(value, "summary");
148
148
  const content = stringAt(value, "content");
149
149
  const published = numberAt(value, "published_at_unix_ms");
150
150
  const correlation = typeof value === "object" && value !== null ? value.correlation_id : void 0;
151
- if (version !== 1 || !isUuid(id) || !isUuid(stringAt(sender, "registration_id")) || !isUuid(stringAt(sender, "session_id")) || !isUuid(stringAt(sender, "subject_id")) || !isUuid(stringAt(sender, "execution_id")) || !Number.isSafeInteger(senderGeneration) || (senderGeneration ?? 0) < 1 || !isUuid(target) || summary === void 0 || content === void 0 || published === void 0 || !Number.isSafeInteger(published) || published < 0 || correlation !== void 0 && correlation !== null && (typeof correlation !== "string" || correlation.length === 0 || Buffer.byteLength(correlation, "utf8") > 256 || new RegExp("\\p{Cc}", "u").test(correlation))) return void 0;
151
+ if (version !== 1 || !isUuid(id) || !isUuid(stringAt(sender, "registration_id")) || !isUuid(stringAt(sender, "session_id")) || !isUuid(stringAt(sender, "subject_id")) || !isUuid(stringAt(sender, "claim_id")) || !isUuid(target) || summary === void 0 || content === void 0 || published === void 0 || !Number.isSafeInteger(published) || published < 0 || correlation !== void 0 && correlation !== null && (typeof correlation !== "string" || correlation.length === 0 || Buffer.byteLength(correlation, "utf8") > 256 || new RegExp("\\p{Cc}", "u").test(correlation))) return void 0;
152
152
  return value;
153
153
  }
154
154
  function handoffReceiptAt(value) {
155
155
  const version = numberAt(value, "version");
156
156
  const handoffId = stringAt(value, "handoff_id");
157
157
  const recipient = recordAt(value, "recipient");
158
- const generation = numberAt(recipient, "execution_generation");
159
158
  const received = numberAt(value, "received_at_unix_ms");
160
- if (version !== 1 || !isUuid(handoffId) || !isUuid(stringAt(recipient, "registration_id")) || !isUuid(stringAt(recipient, "session_id")) || !isUuid(stringAt(recipient, "subject_id")) || !isUuid(stringAt(recipient, "execution_id")) || !Number.isSafeInteger(generation) || (generation ?? 0) < 1 || received === void 0 || !Number.isSafeInteger(received) || received < 0) return void 0;
159
+ if (version !== 1 || !isUuid(handoffId) || !isUuid(stringAt(recipient, "registration_id")) || !isUuid(stringAt(recipient, "session_id")) || !isUuid(stringAt(recipient, "subject_id")) || !isUuid(stringAt(recipient, "claim_id")) || received === void 0 || !Number.isSafeInteger(received) || received < 0) return void 0;
161
160
  return value;
162
161
  }
163
162
  function writerBinding(value) {
@@ -679,6 +678,7 @@ function createRadarClient(options = {}) {
679
678
  };
680
679
  return answer;
681
680
  };
681
+ let processRegistrationId;
682
682
  let handshake;
683
683
  const negotiated = async () => {
684
684
  handshake ??= probe();
@@ -760,6 +760,7 @@ function createRadarClient(options = {}) {
760
760
  const record = answer.value.registration;
761
761
  const agentId = stringAt(record, "registration_id");
762
762
  if (!isUuid(agentId)) return protocol("registration reply carries no registration_id");
763
+ processRegistrationId = agentId;
763
764
  return { ok: true, value: { registration_id: agentId } };
764
765
  });
765
766
  },
@@ -893,6 +894,31 @@ function createRadarClient(options = {}) {
893
894
  }
894
895
  );
895
896
  },
897
+ async spawnList(request) {
898
+ const registrationId = processRegistrationId;
899
+ if (registrationId === void 0)
900
+ return { ok: false, code: "refused", message: "spawn.list requires this client's process registration" };
901
+ return withContract(RADAR_SPAWN_LIST_CONTRACT, async () => {
902
+ const params = {
903
+ registration_id: registrationId,
904
+ ...request.after !== void 0 ? { after: request.after } : {},
905
+ ...request.limit !== void 0 ? { limit: request.limit } : {}
906
+ };
907
+ const answer = await call("spawn.list", { ...params });
908
+ if (answer.ok === false) return answer;
909
+ const spawns = recordAt(answer.value, "spawns");
910
+ const next = recordAt(answer.value, "next");
911
+ if (!Array.isArray(spawns) || next != null && !isUuid(next))
912
+ return protocol("spawn.list reply carries no valid page");
913
+ for (const spawn of spawns) {
914
+ const decoded = spawnTopology(spawn);
915
+ if (decoded.ok === false) return decoded;
916
+ if (typeof recordAt(spawn, "version") !== "number" || typeof recordAt(spawn, "recorded_at") !== "string" || !["unbound", "bound", "unresolved"].includes(String(recordAt(spawn, "state"))))
917
+ return protocol("spawn.list reply carries an incomplete topology");
918
+ }
919
+ return { ok: true, value: answer.value };
920
+ });
921
+ },
896
922
  async spawnGet(requestId) {
897
923
  if (!isUuid(requestId))
898
924
  return {
@@ -1250,6 +1276,7 @@ export {
1250
1276
  RADAR_SPAWN_CLOSE_CONTRACT,
1251
1277
  RADAR_SPAWN_CONTRACT,
1252
1278
  RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT,
1279
+ RADAR_SPAWN_LIST_CONTRACT,
1253
1280
  RADAR_SPAWN_TOKEN_ENV,
1254
1281
  boundedRadarAssignment,
1255
1282
  boundedRadarFacts,
@@ -702,6 +702,11 @@ function createPresenceReporter(input) {
702
702
  await releasePending();
703
703
  const registration = await input.registration();
704
704
  if (!registration?.process || state !== claim || stopped) return void 0;
705
+ if (claim.initial_load) {
706
+ if (registration.spawn_token) claim.claim_id = registration.spawn_token;
707
+ claim.initial_load = false;
708
+ if (!save()) return void 0;
709
+ }
705
710
  const answer = await client.presence({
706
711
  ...registration,
707
712
  process: registration.process,
@@ -740,7 +745,7 @@ function createPresenceReporter(input) {
740
745
  releases.push({ registration_id: state.registration_id, claim_id: state.claim_id });
741
746
  }
742
747
  standing = void 0;
743
- state = { version: 2, session, claim_id: randomUUID(), releases };
748
+ state = { version: 2, session, claim_id: randomUUID(), initial_load: state === void 0, releases };
744
749
  save();
745
750
  stopped = false;
746
751
  void ensure();
@@ -707,6 +707,11 @@ function createPresenceReporter(input) {
707
707
  await releasePending();
708
708
  const registration = await input.registration();
709
709
  if (!registration?.process || state !== claim || stopped) return void 0;
710
+ if (claim.initial_load) {
711
+ if (registration.spawn_token) claim.claim_id = registration.spawn_token;
712
+ claim.initial_load = false;
713
+ if (!save()) return void 0;
714
+ }
710
715
  const answer = await client.presence({
711
716
  ...registration,
712
717
  process: registration.process,
@@ -745,7 +750,7 @@ function createPresenceReporter(input) {
745
750
  releases.push({ registration_id: state.registration_id, claim_id: state.claim_id });
746
751
  }
747
752
  standing = void 0;
748
- state = { version: 2, session, claim_id: randomUUID(), releases };
753
+ state = { version: 2, session, claim_id: randomUUID(), initial_load: state === void 0, releases };
749
754
  save();
750
755
  stopped = false;
751
756
  void ensure();
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ __export(radar_client_exports, {
22
22
  RADAR_SPAWN_CLOSE_CONTRACT: () => RADAR_SPAWN_CLOSE_CONTRACT,
23
23
  RADAR_SPAWN_CONTRACT: () => RADAR_SPAWN_CONTRACT,
24
24
  RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT: () => RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT,
25
+ RADAR_SPAWN_LIST_CONTRACT: () => RADAR_SPAWN_LIST_CONTRACT,
25
26
  RADAR_SPAWN_TOKEN_ENV: () => RADAR_SPAWN_TOKEN_ENV,
26
27
  boundedRadarAssignment: () => boundedRadarAssignment,
27
28
  boundedRadarFacts: () => boundedRadarFacts,
@@ -39,10 +40,11 @@ import { lstatSync } from "node:fs";
39
40
  import { createConnection } from "node:net";
40
41
  import { dirname, join } from "node:path";
41
42
  var RADAR_PROTOCOL_VERSION = 2;
42
- var RADAR_MINIMUM_REVISION = 4;
43
+ var RADAR_MINIMUM_REVISION = 5;
43
44
  var RADAR_REGISTRY_CONTRACT = "registry.v1";
44
45
  var RADAR_REGISTRY_FACTS_CONTRACT = "registry.facts.v1";
45
46
  var RADAR_SPAWN_CONTRACT = "spawn.v1";
47
+ var RADAR_SPAWN_LIST_CONTRACT = "spawn.list.v1";
46
48
  var RADAR_SPAWN_CLOSE_CONTRACT = "spawn.close.v1";
47
49
  var RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT = "spawn.launch_context.v1";
48
50
  var RADAR_EVENTS_CONTRACT = "events.v1";
@@ -177,22 +179,20 @@ function handoffAt(value) {
177
179
  const version = numberAt(value, "version");
178
180
  const id = stringAt(value, "id");
179
181
  const sender = recordAt(value, "sender");
180
- const senderGeneration = numberAt(sender, "execution_generation");
181
182
  const target = stringAt(value, "recipient_subject_id");
182
183
  const summary = stringAt(value, "summary");
183
184
  const content = stringAt(value, "content");
184
185
  const published = numberAt(value, "published_at_unix_ms");
185
186
  const correlation = typeof value === "object" && value !== null ? value.correlation_id : void 0;
186
- if (version !== 1 || !isUuid(id) || !isUuid(stringAt(sender, "registration_id")) || !isUuid(stringAt(sender, "session_id")) || !isUuid(stringAt(sender, "subject_id")) || !isUuid(stringAt(sender, "execution_id")) || !Number.isSafeInteger(senderGeneration) || (senderGeneration ?? 0) < 1 || !isUuid(target) || summary === void 0 || content === void 0 || published === void 0 || !Number.isSafeInteger(published) || published < 0 || correlation !== void 0 && correlation !== null && (typeof correlation !== "string" || correlation.length === 0 || Buffer.byteLength(correlation, "utf8") > 256 || new RegExp("\\p{Cc}", "u").test(correlation))) return void 0;
187
+ if (version !== 1 || !isUuid(id) || !isUuid(stringAt(sender, "registration_id")) || !isUuid(stringAt(sender, "session_id")) || !isUuid(stringAt(sender, "subject_id")) || !isUuid(stringAt(sender, "claim_id")) || !isUuid(target) || summary === void 0 || content === void 0 || published === void 0 || !Number.isSafeInteger(published) || published < 0 || correlation !== void 0 && correlation !== null && (typeof correlation !== "string" || correlation.length === 0 || Buffer.byteLength(correlation, "utf8") > 256 || new RegExp("\\p{Cc}", "u").test(correlation))) return void 0;
187
188
  return value;
188
189
  }
189
190
  function handoffReceiptAt(value) {
190
191
  const version = numberAt(value, "version");
191
192
  const handoffId = stringAt(value, "handoff_id");
192
193
  const recipient = recordAt(value, "recipient");
193
- const generation = numberAt(recipient, "execution_generation");
194
194
  const received = numberAt(value, "received_at_unix_ms");
195
- if (version !== 1 || !isUuid(handoffId) || !isUuid(stringAt(recipient, "registration_id")) || !isUuid(stringAt(recipient, "session_id")) || !isUuid(stringAt(recipient, "subject_id")) || !isUuid(stringAt(recipient, "execution_id")) || !Number.isSafeInteger(generation) || (generation ?? 0) < 1 || received === void 0 || !Number.isSafeInteger(received) || received < 0) return void 0;
195
+ if (version !== 1 || !isUuid(handoffId) || !isUuid(stringAt(recipient, "registration_id")) || !isUuid(stringAt(recipient, "session_id")) || !isUuid(stringAt(recipient, "subject_id")) || !isUuid(stringAt(recipient, "claim_id")) || received === void 0 || !Number.isSafeInteger(received) || received < 0) return void 0;
196
196
  return value;
197
197
  }
198
198
  function writerBinding(value) {
@@ -714,6 +714,7 @@ function createRadarClient(options = {}) {
714
714
  };
715
715
  return answer;
716
716
  };
717
+ let processRegistrationId;
717
718
  let handshake;
718
719
  const negotiated = async () => {
719
720
  handshake ??= probe();
@@ -795,6 +796,7 @@ function createRadarClient(options = {}) {
795
796
  const record = answer.value.registration;
796
797
  const agentId = stringAt(record, "registration_id");
797
798
  if (!isUuid(agentId)) return protocol("registration reply carries no registration_id");
799
+ processRegistrationId = agentId;
798
800
  return { ok: true, value: { registration_id: agentId } };
799
801
  });
800
802
  },
@@ -928,6 +930,31 @@ function createRadarClient(options = {}) {
928
930
  }
929
931
  );
930
932
  },
933
+ async spawnList(request) {
934
+ const registrationId = processRegistrationId;
935
+ if (registrationId === void 0)
936
+ return { ok: false, code: "refused", message: "spawn.list requires this client's process registration" };
937
+ return withContract(RADAR_SPAWN_LIST_CONTRACT, async () => {
938
+ const params = {
939
+ registration_id: registrationId,
940
+ ...request.after !== void 0 ? { after: request.after } : {},
941
+ ...request.limit !== void 0 ? { limit: request.limit } : {}
942
+ };
943
+ const answer = await call("spawn.list", { ...params });
944
+ if (answer.ok === false) return answer;
945
+ const spawns = recordAt(answer.value, "spawns");
946
+ const next = recordAt(answer.value, "next");
947
+ if (!Array.isArray(spawns) || next != null && !isUuid(next))
948
+ return protocol("spawn.list reply carries no valid page");
949
+ for (const spawn of spawns) {
950
+ const decoded = spawnTopology(spawn);
951
+ if (decoded.ok === false) return decoded;
952
+ if (typeof recordAt(spawn, "version") !== "number" || typeof recordAt(spawn, "recorded_at") !== "string" || !["unbound", "bound", "unresolved"].includes(String(recordAt(spawn, "state"))))
953
+ return protocol("spawn.list reply carries an incomplete topology");
954
+ }
955
+ return { ok: true, value: answer.value };
956
+ });
957
+ },
931
958
  async spawnGet(requestId) {
932
959
  if (!isUuid(requestId))
933
960
  return {
@@ -1944,6 +1971,11 @@ function createPresenceReporter(input) {
1944
1971
  await releasePending();
1945
1972
  const registration = await input.registration();
1946
1973
  if (!registration?.process || state !== claim || stopped) return void 0;
1974
+ if (claim.initial_load) {
1975
+ if (registration.spawn_token) claim.claim_id = registration.spawn_token;
1976
+ claim.initial_load = false;
1977
+ if (!save()) return void 0;
1978
+ }
1947
1979
  const answer = await client.presence({
1948
1980
  ...registration,
1949
1981
  process: registration.process,
@@ -1982,7 +2014,7 @@ function createPresenceReporter(input) {
1982
2014
  releases.push({ registration_id: state.registration_id, claim_id: state.claim_id });
1983
2015
  }
1984
2016
  standing = void 0;
1985
- state = { version: 2, session, claim_id: randomUUID2(), releases };
2017
+ state = { version: 2, session, claim_id: randomUUID2(), initial_load: state === void 0, releases };
1986
2018
  save();
1987
2019
  stopped = false;
1988
2020
  void ensure();
@@ -151,8 +151,7 @@ export interface HandoffSerialize {
151
151
  * generation identify the exact execution that took the action.
152
152
  */
153
153
  export interface HandoffIdentity {
154
- execution_generation: number;
155
- execution_id: string;
154
+ claim_id: string;
156
155
  registration_id: string;
157
156
  session_id: string;
158
157
  subject_id: string;
@@ -172,8 +171,7 @@ export interface HandoffReceiptSerialize {
172
171
  * generation identify the exact execution that took the action.
173
172
  */
174
173
  export interface HandoffIdentity {
175
- execution_generation: number;
176
- execution_id: string;
174
+ claim_id: string;
177
175
  registration_id: string;
178
176
  session_id: string;
179
177
  subject_id: string;
@@ -902,6 +900,7 @@ export interface RegistryLocation {
902
900
  */
903
901
  export interface ManagedSubject {
904
902
  child_close: ChildCloseTarget | null;
903
+ claim_id: string;
905
904
  identity_unresolved: boolean;
906
905
  metadata: ManagedMetadata;
907
906
  process: ProcessIdentity;
@@ -938,10 +937,10 @@ export interface ManagedMetadata {
938
937
  export interface SpawnBinding {
939
938
  child_incarnation: string | null;
940
939
  child_source: string | null;
940
+ claim_id: string | null;
941
941
  parent: string;
942
942
  request_id: string;
943
943
  state: SpawnState;
944
- subject_id: string | null;
945
944
  }
946
945
  /**
947
946
  * What this machine knows about that process. Filled by the collector
@@ -1385,3 +1384,107 @@ export interface ObservationTooLarge {
1385
1384
  limit_bytes: number;
1386
1385
  revision: number;
1387
1386
  }
1387
+ /**
1388
+ * Input accepted by `spawn.list`.
1389
+ *
1390
+ * A caller presents itself; the parent whose launches it reads is derived from
1391
+ * the claim it holds. There is deliberately no parent parameter: naming another
1392
+ * subject would let any client read its launches.
1393
+ */
1394
+ export interface SpawnListParamsDeserialize {
1395
+ after?: string | null;
1396
+ limit?: number | null;
1397
+ registration_id?: string | null;
1398
+ }
1399
+ /**
1400
+ * What a completed request did.
1401
+ */
1402
+ export type SpawnListPageSerializeRequestOutcome = "completed" | "refused" | "unknown";
1403
+ /**
1404
+ * What a spawn topology read can say about a child's process exit.
1405
+ */
1406
+ export type SpawnListPageSerializePublicSpawnExitObservation =
1407
+ | {
1408
+ state: "unknown";
1409
+ }
1410
+ | {
1411
+ code: number | null;
1412
+ observed_at: string;
1413
+ signal: number | null;
1414
+ state: "exited";
1415
+ };
1416
+ /**
1417
+ * Freshness is independent of process identity or completion.
1418
+ */
1419
+ export type SpawnListPageSerializeFreshness = "fresh" | "stale";
1420
+ /**
1421
+ * The state of one edge in the recorded topology: the strongest statement a
1422
+ * read can make about it, and nothing beyond the records and this read's own
1423
+ * evidence.
1424
+ */
1425
+ export type SpawnListPageSerializeSpawnState = "unbound" | "bound" | "unresolved";
1426
+
1427
+ /**
1428
+ * One page of spawn edges. `next` names the request id the page ended on.
1429
+ */
1430
+ export interface SpawnListPageSerialize {
1431
+ next?: string | null;
1432
+ spawns: SpawnListPageSerializePublicSpawnTopology[];
1433
+ }
1434
+ /**
1435
+ * One edge as a topology read reports it: the stored facts, plus the two things
1436
+ * a read derives instead of inferring.
1437
+ */
1438
+ export interface SpawnListPageSerializePublicSpawnTopology {
1439
+ bound?: SpawnListPageSerializeBoundChild | null;
1440
+ claim_id?: string | null;
1441
+ created: SpawnListPageSerializeRequestOutcome;
1442
+ exit_observation?: SpawnListPageSerializePublicSpawnExitObservation | null;
1443
+ /**
1444
+ * Freshness of the recorded location against this read's evidence, in the
1445
+ * registry's one freshness vocabulary: `fresh` when the backend reports the
1446
+ * location now, `stale` when it does not — which is never evidence that the
1447
+ * child stopped. Absent when the edge recorded no location, because there is
1448
+ * nothing to verify.
1449
+ */
1450
+ freshness?: SpawnListPageSerializeFreshness | null;
1451
+ launched: SpawnListPageSerializeRequestOutcome;
1452
+ location?: SpawnListPageSerializeRegistryLocation | null;
1453
+ parent: string;
1454
+ parent_session_id?: string | null;
1455
+ parent_subject_id?: string | null;
1456
+ recorded_at: string;
1457
+ request_id: string;
1458
+ state: SpawnListPageSerializeSpawnState;
1459
+ version: number;
1460
+ }
1461
+ /**
1462
+ * The child a token bound: the registering incarnation's own registry identity.
1463
+ */
1464
+ export interface SpawnListPageSerializeBoundChild {
1465
+ /**
1466
+ * When the edge bound to this incarnation.
1467
+ */
1468
+ bound_at: string;
1469
+ incarnation: string;
1470
+ /**
1471
+ * Exact registry identity returned to the registering child. Absent in legacy records.
1472
+ */
1473
+ registration_id?: string | null;
1474
+ source: string;
1475
+ }
1476
+ /**
1477
+ * Where an agent's incarnation lives on one backend, in that backend's own
1478
+ * coordinates.
1479
+ *
1480
+ * The backend name is free-form so a source Radar does not ship yet is
1481
+ * preserved rather than rejected; the rest are the coordinates as reported, and
1482
+ * each is optional because a backend need not name every level.
1483
+ */
1484
+ export interface SpawnListPageSerializeRegistryLocation {
1485
+ backend: string;
1486
+ instance?: string | null;
1487
+ pane?: string | null;
1488
+ tab?: string | null;
1489
+ workspace?: string | null;
1490
+ }