@intx/hub-sessions 0.1.2 → 0.2.2

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.
Files changed (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. package/tsconfig.tsbuildinfo +0 -1
package/src/skill-kind.ts DELETED
@@ -1,350 +0,0 @@
1
- import { type } from "arktype";
2
- import { getLogger } from "@intx/log";
3
- import { glob, repoActionToGrantVerb } from "@intx/hub-common";
4
- import {
5
- UserPrincipal,
6
- type AuthorizeFn,
7
- type KindHandler,
8
- type Principal,
9
- type ValidatePushResult,
10
- } from "./repo-store";
11
-
12
- const logger = getLogger(["hub-sessions", "skill-kind"]);
13
-
14
- export type SkillHubPrincipal = { readonly kind: "hub" };
15
-
16
- export type SkillSidecarPrincipal = {
17
- readonly kind: "sidecar";
18
- readonly agentId: string;
19
- };
20
-
21
- export type SkillPrincipal = SkillHubPrincipal | SkillSidecarPrincipal;
22
-
23
- /**
24
- * arktype schema for the SKILL.md frontmatter. Required fields are
25
- * `name` and `description`; the Claude Code superset of optional fields
26
- * (`when_to_use`, `allowed-tools`, `paths`, `model`, ...) is accepted
27
- * via `onUndeclaredKey("ignore")` but not enforced.
28
- *
29
- * The forbidden-name narrow rejects `"anthropic"` and `"claude"`
30
- * because those values are reserved by the upstream agentskills.io
31
- * spec for vendor-owned skill packs.
32
- */
33
- export const skillFrontmatterSchema = type({
34
- name: type(/^[a-z0-9]+(-[a-z0-9]+)*$/)
35
- .and("string<=64")
36
- .narrow((n: string, ctx) => {
37
- if (n === "anthropic" || n === "claude") {
38
- return ctx.mustBe(`not the reserved name "anthropic" or "claude"`);
39
- }
40
- return true;
41
- }),
42
- description: type("1 <= string <= 1024").and(type(/^(?!.*<[^>]+>).*$/s)),
43
- }).onUndeclaredKey("ignore");
44
-
45
- export type SkillFrontmatter = typeof skillFrontmatterSchema.infer;
46
-
47
- export type SkillIndexEntry = {
48
- /** Equal to the containing directory name and to `frontmatter.name`. */
49
- name: string;
50
- description: string;
51
- /** Full parsed frontmatter, including any accepted optional fields. */
52
- frontmatter: Record<string, unknown>;
53
- /** Path of the skill subdirectory relative to the asset mount, with trailing slash. */
54
- workspaceSubpath: string;
55
- };
56
-
57
- type CacheKey = string;
58
-
59
- function cacheKey(assetId: string, ref: string): CacheKey {
60
- return `${assetId}\u0000${ref}`;
61
- }
62
-
63
- const skillIndex = new Map<CacheKey, SkillIndexEntry[]>();
64
- const pendingIndex = new Map<CacheKey, SkillIndexEntry[]>();
65
-
66
- /**
67
- * Returns the parsed skill index for `(assetId, ref)`, or an empty
68
- * array if no index has been populated yet. The index is refreshed by
69
- * the kind handler's `onRefUpdated` hook after each successful write.
70
- */
71
- export function getSkillIndex(assetId: string, ref: string): SkillIndexEntry[] {
72
- return skillIndex.get(cacheKey(assetId, ref)) ?? [];
73
- }
74
-
75
- const FRONTMATTER_DELIMITER = "---";
76
-
77
- type ParsedSkillMd = {
78
- frontmatter: Record<string, unknown>;
79
- };
80
-
81
- const YamlMapping = type("Record<string, unknown>");
82
-
83
- function parseSkillMd(body: string): ParsedSkillMd {
84
- const lines = body.split(/\r?\n/);
85
- if (lines[0] !== FRONTMATTER_DELIMITER) {
86
- throw new Error("SKILL.md is missing YAML frontmatter delimiter");
87
- }
88
- let endIdx = -1;
89
- for (let i = 1; i < lines.length; i += 1) {
90
- if (lines[i] === FRONTMATTER_DELIMITER) {
91
- endIdx = i;
92
- break;
93
- }
94
- }
95
- if (endIdx === -1) {
96
- throw new Error("SKILL.md frontmatter has no closing delimiter");
97
- }
98
- const yamlText = lines.slice(1, endIdx).join("\n");
99
- const parsed: unknown = Bun.YAML.parse(yamlText);
100
- const validated = YamlMapping(parsed);
101
- if (validated instanceof type.errors) {
102
- throw new Error(
103
- `SKILL.md frontmatter must be a YAML mapping: ${validated.summary}`,
104
- );
105
- }
106
- return { frontmatter: validated };
107
- }
108
-
109
- type ParseOutcome =
110
- | { ok: true; entry: SkillIndexEntry }
111
- | { ok: false; reason: string };
112
-
113
- async function parseSkillEntry(
114
- subdir: string,
115
- readBlob: (path: string) => Promise<Uint8Array>,
116
- ): Promise<ParseOutcome> {
117
- const skillPath = `${subdir}/SKILL.md`;
118
- let raw: Uint8Array;
119
- try {
120
- raw = await readBlob(skillPath);
121
- } catch (cause) {
122
- return {
123
- ok: false,
124
- reason: `skill ${subdir} is missing SKILL.md: ${
125
- cause instanceof Error ? cause.message : String(cause)
126
- }`,
127
- };
128
- }
129
-
130
- const body = new TextDecoder().decode(raw);
131
- let parsed: ParsedSkillMd;
132
- try {
133
- parsed = parseSkillMd(body);
134
- } catch (cause) {
135
- return {
136
- ok: false,
137
- reason: `skill ${subdir} frontmatter parse failed: ${
138
- cause instanceof Error ? cause.message : String(cause)
139
- }`,
140
- };
141
- }
142
-
143
- const result = skillFrontmatterSchema(parsed.frontmatter);
144
- if (result instanceof type.errors) {
145
- return {
146
- ok: false,
147
- reason: `skill ${subdir} frontmatter is invalid: ${result.summary}`,
148
- };
149
- }
150
-
151
- if (result.name !== subdir) {
152
- return {
153
- ok: false,
154
- reason: `skill ${subdir} frontmatter.name ${JSON.stringify(
155
- result.name,
156
- )} does not match directory name`,
157
- };
158
- }
159
-
160
- return {
161
- ok: true,
162
- entry: {
163
- name: result.name,
164
- description: result.description,
165
- frontmatter: parsed.frontmatter,
166
- workspaceSubpath: `${subdir}/`,
167
- },
168
- };
169
- }
170
-
171
- async function buildSkillIndex(
172
- topLevelTreePaths: string[],
173
- readBlob: (path: string) => Promise<Uint8Array>,
174
- ): Promise<
175
- { ok: true; entries: SkillIndexEntry[] } | { ok: false; reason: string }
176
- > {
177
- const entries: SkillIndexEntry[] = [];
178
- // Sort so the index ordering is deterministic across reads.
179
- const subdirs = [...topLevelTreePaths].sort();
180
- for (const subdir of subdirs) {
181
- const outcome = await parseSkillEntry(subdir, readBlob);
182
- if (!outcome.ok) {
183
- return { ok: false, reason: outcome.reason };
184
- }
185
- entries.push(outcome.entry);
186
- }
187
- return { ok: true, entries };
188
- }
189
-
190
- export const skillKindHandler: KindHandler = {
191
- kind: "skill",
192
- directoryPrefix: "assets/skill",
193
- async validatePush({
194
- repoId,
195
- ref,
196
- topLevelTreePaths,
197
- readBlob,
198
- }): Promise<ValidatePushResult> {
199
- // Drop any staged entry from a previous attempt first. The
200
- // substrate calls validatePush before advancing the ref, so a prior
201
- // validation that was accepted but never followed by onRefUpdated
202
- // (e.g. the commit step threw after validation succeeded) would
203
- // otherwise leave a stale entry that a later rejected attempt
204
- // would silently inherit.
205
- const key = cacheKey(repoId.id, ref);
206
- pendingIndex.delete(key);
207
-
208
- const result = await buildSkillIndex(topLevelTreePaths, readBlob);
209
- if (!result.ok) {
210
- logger.debug`skill validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${result.reason}`;
211
- return { ok: false, reason: result.reason };
212
- }
213
- // Stage the parsed index against (repoId.id, ref). The substrate
214
- // calls onRefUpdated immediately after the ref is advanced and we
215
- // promote the staged entry into the live cache there.
216
- pendingIndex.set(key, result.entries);
217
- return { ok: true };
218
- },
219
- onRefUpdated({ repoId, ref }) {
220
- const key = cacheKey(repoId.id, ref);
221
- const staged = pendingIndex.get(key);
222
- if (staged === undefined) {
223
- throw new Error(
224
- `skillKindHandler.onRefUpdated: no validated tree pending for ${repoId.id} @ ${ref}`,
225
- );
226
- }
227
- pendingIndex.delete(key);
228
- skillIndex.set(key, staged);
229
- },
230
- };
231
-
232
- const SidecarPrincipal = type({
233
- kind: "'sidecar'",
234
- agentId: "string",
235
- });
236
-
237
- export const skillAuthorize: AuthorizeFn = (
238
- principal: Principal,
239
- repoId,
240
- ref,
241
- action,
242
- ) => {
243
- if (repoId.kind !== "skill") {
244
- return {
245
- allowed: false,
246
- reason: `skill authorize received non-skill repo ${repoId.kind}/${repoId.id}`,
247
- };
248
- }
249
-
250
- if (principal.kind === "hub") {
251
- return { allowed: true };
252
- }
253
-
254
- if (principal.kind === "sidecar") {
255
- const parsed = SidecarPrincipal(principal);
256
- if (parsed instanceof type.errors) {
257
- return {
258
- allowed: false,
259
- reason: `sidecar principal is malformed: ${parsed.summary}`,
260
- };
261
- }
262
- switch (action) {
263
- case "createPack":
264
- case "resolveRef":
265
- return { allowed: true };
266
- case "init":
267
- case "writeTree":
268
- case "receivePack":
269
- return {
270
- allowed: false,
271
- reason: `sidecars may only read skill assets, not ${action}`,
272
- };
273
- default: {
274
- const _exhaustive: never = action;
275
- return {
276
- allowed: false,
277
- reason: `unhandled action: ${String(_exhaustive)}`,
278
- };
279
- }
280
- }
281
- }
282
-
283
- if (principal.kind === "user") {
284
- // The route layer has already pre-resolved the grant verdict and
285
- // attached it as `authz`. The substrate does NOT re-query the
286
- // grant store here; it (a) checks the bearer-token's claims
287
- // bound the requested (ref, action) and have not expired, and
288
- // (b) sanity-checks that the pre-resolved verdict targets this
289
- // exact resource and grant verb. Both gates must pass before the
290
- // verdict's `effect` is honoured.
291
- const parsed = UserPrincipal(principal);
292
- if (parsed instanceof type.errors) {
293
- return {
294
- allowed: false,
295
- reason: `user principal is malformed: ${parsed.summary}`,
296
- };
297
- }
298
- if (!parsed.tokenClaims.actions.includes(action)) {
299
- return {
300
- allowed: false,
301
- reason: `token does not grant action ${action}`,
302
- };
303
- }
304
- // `ref === "*"` is the substrate's sentinel for a bulk read used
305
- // by `listRefs`. The advertise-refs layer applies the per-ref
306
- // refPattern filter on the listing it returns, so gating the bulk
307
- // read on refPattern here would prevent any token with a
308
- // narrow-pattern (e.g. `refs/heads/main`) from listing refs at
309
- // all. Only the action and expiry are enforced for the bulk read;
310
- // every other call site passes a concrete ref and is gated below.
311
- if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
312
- return {
313
- allowed: false,
314
- reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
315
- };
316
- }
317
- if (Date.now() >= parsed.tokenClaims.expiresAt) {
318
- return {
319
- allowed: false,
320
- reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
321
- };
322
- }
323
- const expectedResource = `asset:${repoId.id}`;
324
- if (parsed.authz.resource !== expectedResource) {
325
- return {
326
- allowed: false,
327
- reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
328
- };
329
- }
330
- const expectedGrantVerb = repoActionToGrantVerb(action);
331
- if (parsed.authz.grantVerb !== expectedGrantVerb) {
332
- return {
333
- allowed: false,
334
- reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
335
- };
336
- }
337
- if (parsed.authz.effect === "allow") {
338
- return { allowed: true };
339
- }
340
- return {
341
- allowed: false,
342
- reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
343
- };
344
- }
345
-
346
- return {
347
- allowed: false,
348
- reason: `unknown principal kind: ${principal.kind}`,
349
- };
350
- };
package/src/ws/index.ts DELETED
@@ -1,18 +0,0 @@
1
- export {
2
- createSidecarRouter,
3
- type SidecarRouter,
4
- type SidecarRouterConfig,
5
- type SidecarConnection,
6
- type SendPackOptions,
7
- type WsHandle,
8
- } from "./sidecar-handler";
9
- export {
10
- createSidecarEmitter,
11
- type SidecarEventEmitter,
12
- type SidecarEventMap,
13
- type SidecarEventType,
14
- type SidecarEventListener,
15
- type SidecarLookups,
16
- type SidecarMailPersistedRow,
17
- type SidecarMailPersistedPayload,
18
- } from "./sidecar-events";
@@ -1,96 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { createSidecarEmitter } from "./sidecar-events";
3
-
4
- describe("createSidecarEmitter", () => {
5
- test("delivers events to subscribed listeners", () => {
6
- const emitter = createSidecarEmitter();
7
- const seen: { addr: string; sid: string }[] = [];
8
- emitter.on("agent.event", ({ agentAddress, sessionId }) => {
9
- seen.push({ addr: agentAddress, sid: sessionId });
10
- });
11
-
12
- emitter.emit("agent.event", {
13
- agentAddress: "a@x",
14
- sessionId: "s-1",
15
- event: { type: "x" },
16
- });
17
-
18
- expect(seen).toEqual([{ addr: "a@x", sid: "s-1" }]);
19
- });
20
-
21
- test("supports multiple listeners on the same event", () => {
22
- const emitter = createSidecarEmitter();
23
- const order: string[] = [];
24
- emitter.on("sidecar.disconnect", () => {
25
- order.push("a");
26
- });
27
- emitter.on("sidecar.disconnect", () => {
28
- order.push("b");
29
- });
30
-
31
- emitter.emit("sidecar.disconnect", { agentAddresses: [] });
32
-
33
- expect(order).toEqual(["a", "b"]);
34
- });
35
-
36
- test("unsubscribe removes the listener", () => {
37
- const emitter = createSidecarEmitter();
38
- let count = 0;
39
- const unsubscribe = emitter.on("sidecar.disconnect", () => {
40
- count++;
41
- });
42
-
43
- emitter.emit("sidecar.disconnect", { agentAddresses: [] });
44
- unsubscribe();
45
- emitter.emit("sidecar.disconnect", { agentAddresses: [] });
46
-
47
- expect(count).toBe(1);
48
- });
49
-
50
- test("emit swallows listener errors and continues", () => {
51
- const emitter = createSidecarEmitter();
52
- const seen: string[] = [];
53
- emitter.on("sidecar.disconnect", () => {
54
- throw new Error("boom");
55
- });
56
- emitter.on("sidecar.disconnect", () => {
57
- seen.push("ran");
58
- });
59
-
60
- emitter.emit("sidecar.disconnect", { agentAddresses: [] });
61
-
62
- expect(seen).toEqual(["ran"]);
63
- });
64
-
65
- test("emitAndAwait runs listeners sequentially and rethrows the first failure", async () => {
66
- const emitter = createSidecarEmitter();
67
- const seen: string[] = [];
68
- emitter.on("agent.reconnected", async () => {
69
- seen.push("a");
70
- });
71
- emitter.on("agent.reconnected", async () => {
72
- seen.push("b");
73
- throw new Error("listener b failed");
74
- });
75
- emitter.on("agent.reconnected", async () => {
76
- seen.push("c");
77
- });
78
-
79
- await expect(
80
- emitter.emitAndAwait("agent.reconnected", { agentAddress: "a@x" }),
81
- ).rejects.toThrow(/listener b failed/);
82
-
83
- expect(seen).toEqual(["a", "b"]);
84
- });
85
-
86
- test("listenerCount reflects current subscriptions", () => {
87
- const emitter = createSidecarEmitter();
88
- expect(emitter.listenerCount("agent.reconnected")).toBe(0);
89
-
90
- const off = emitter.on("agent.reconnected", () => undefined);
91
- expect(emitter.listenerCount("agent.reconnected")).toBe(1);
92
-
93
- off();
94
- expect(emitter.listenerCount("agent.reconnected")).toBe(0);
95
- });
96
- });
@@ -1,231 +0,0 @@
1
- // Typed event emitter for the sidecar router.
2
- //
3
- // The router emits events at the points where wire-layer frame handling
4
- // completes and a host-side decision or side effect is required. Two
5
- // emission shapes are exposed:
6
- //
7
- // - `emit(type, payload)` — notification semantics. Each listener runs
8
- // inside its own try/catch; a thrown error is logged and does not
9
- // affect other listeners or the wire layer. Used for events whose
10
- // outcome does not feed back into protocol behavior.
11
- //
12
- // - `emitAndAwait(type, payload)` — sequential await semantics.
13
- // Listeners run in registration order; the first rejection propagates
14
- // to the caller and stops the chain. Used for events whose outcome
15
- // affects subsequent wire-layer state (e.g. reconnect rollback).
16
- //
17
- // The TSDoc on each entry in `SidecarEventMap` records which semantic
18
- // applies. Mixing the two on a single event is intentional: today's
19
- // wire layer already has both behaviors, and pretending otherwise
20
- // would silently change failure handling.
21
-
22
- import type { PackRejectReason, RepoId } from "@intx/types/sidecar";
23
- import type { ConnectorThreadState } from "@intx/types/runtime";
24
- import { getLogger } from "@intx/log";
25
-
26
- const logger = getLogger(["hub", "ws", "sidecar", "events"]);
27
-
28
- export type SidecarMailPersistedRow = {
29
- id: string;
30
- createdAt: Date;
31
- direction: "inbound" | "outbound";
32
- instanceId: string | null;
33
- address: string;
34
- };
35
-
36
- export type SidecarMailPersistedPayload = SidecarMailPersistedRow & {
37
- raw: Uint8Array;
38
- };
39
-
40
- export type SidecarEventMap = {
41
- /** Notification. Emitted for every agent.event frame the wire layer
42
- * decodes. The wire layer also forwards the event to in-process agent
43
- * subscribers registered via `router.subscribeAgent`; this event is
44
- * the host-side observation point. */
45
- "agent.event": {
46
- agentAddress: string;
47
- sessionId: string;
48
- event: unknown;
49
- };
50
-
51
- /** Notification. Emitted once when a sidecar's connection closes,
52
- * carrying the set of agent addresses that were registered on that
53
- * connection. */
54
- "sidecar.disconnect": {
55
- agentAddresses: string[];
56
- };
57
-
58
- /** Notification. Emitted when a mail.outbound frame from a sidecar
59
- * names recipients that the wire layer could not deliver locally and
60
- * could not enqueue for a disconnected agent. The host is free to
61
- * relay it onto an external transport or drop it. */
62
- "mail.outbound.undelivered": {
63
- rawMessage: string;
64
- recipients: string[];
65
- };
66
-
67
- /** Notification. Emitted once per row produced by the host's
68
- * `persistMail` lookup. The wire layer calls `persistMail` to obtain
69
- * the rows; this event fires for each so subscribers can react
70
- * per-row (e.g. dispatch a delivered event). */
71
- "mail.persisted": SidecarMailPersistedPayload;
72
-
73
- /** Awaited. Emitted when an agent.deploy.ack frame arrives. Rejection
74
- * fails the pending deploy with the listener's error. */
75
- "agent.deploy.ack": {
76
- agentAddress: string;
77
- publicKey: string;
78
- };
79
-
80
- /** Notification. Emitted when the sidecar reports a change to an
81
- * agent's connector-thread state. The wire layer caches the state
82
- * per agent so the host can read it via
83
- * `router.getConnectorState(agentAddress)`; this event is for hosts
84
- * that want to observe transitions directly. `connectorState` is
85
- * `null` when the agent has no active connector thread. */
86
- "connector.state.changed": {
87
- agentAddress: string;
88
- connectorState: ConnectorThreadState | null;
89
- };
90
-
91
- /** Awaited. Emitted per address after challenge verification
92
- * succeeds and before the disconnect queue is flushed. Rejection
93
- * rolls that address back from the routing table; earlier listeners
94
- * in registration order have already executed and their side effects
95
- * are not undone. A subsequent reconnect arriving mid-flight may
96
- * supersede this one, so listeners must be idempotent. */
97
- "agent.reconnected": {
98
- agentAddress: string;
99
- };
100
-
101
- /** Awaited. Emitted per address after the wire layer has confirmed
102
- * the sidecar's deploy ref is stale relative to the hub's current
103
- * ref. The listener's job is to push a fresh deploy pack. The wire
104
- * layer fires this only when staleness is confirmed; subscribing
105
- * without a `lookupDeployRef` configured on the router will never
106
- * deliver. */
107
- "deploy.ref.stale": {
108
- agentAddress: string;
109
- };
110
- };
111
-
112
- export type SidecarEventType = keyof SidecarEventMap;
113
-
114
- export type SidecarEventListener<T extends SidecarEventType> = (
115
- payload: SidecarEventMap[T],
116
- ) => void | Promise<void>;
117
-
118
- export type SidecarEventEmitter = {
119
- on<T extends SidecarEventType>(
120
- type: T,
121
- listener: SidecarEventListener<T>,
122
- ): () => void;
123
- emit<T extends SidecarEventType>(type: T, payload: SidecarEventMap[T]): void;
124
- emitAndAwait<T extends SidecarEventType>(
125
- type: T,
126
- payload: SidecarEventMap[T],
127
- ): Promise<void>;
128
- /** Number of listeners registered for `type`. Wire-layer callers use
129
- * this to skip an `await` when nothing is listening, preserving the
130
- * synchronous scheduling of unconfigured-handler paths. */
131
- listenerCount(type: SidecarEventType): number;
132
- };
133
-
134
- export function createSidecarEmitter(): SidecarEventEmitter {
135
- const listeners: { [K in SidecarEventType]: Set<SidecarEventListener<K>> } = {
136
- "agent.event": new Set(),
137
- "sidecar.disconnect": new Set(),
138
- "mail.outbound.undelivered": new Set(),
139
- "mail.persisted": new Set(),
140
- "agent.deploy.ack": new Set(),
141
- "agent.reconnected": new Set(),
142
- "deploy.ref.stale": new Set(),
143
- "connector.state.changed": new Set(),
144
- };
145
-
146
- function on<T extends SidecarEventType>(
147
- type: T,
148
- listener: SidecarEventListener<T>,
149
- ): () => void {
150
- listeners[type].add(listener);
151
- return () => {
152
- listeners[type].delete(listener);
153
- };
154
- }
155
-
156
- function emit<T extends SidecarEventType>(
157
- type: T,
158
- payload: SidecarEventMap[T],
159
- ): void {
160
- const set = listeners[type];
161
- if (set.size === 0) return;
162
- for (const listener of [...set]) {
163
- try {
164
- const result = listener(payload);
165
- if (result instanceof Promise) {
166
- result.catch((err: unknown) => {
167
- logger.warn`Listener for ${type} threw: ${
168
- err instanceof Error ? err.message : String(err)
169
- }`;
170
- });
171
- }
172
- } catch (err) {
173
- logger.warn`Listener for ${type} threw: ${
174
- err instanceof Error ? err.message : String(err)
175
- }`;
176
- }
177
- }
178
- }
179
-
180
- async function emitAndAwait<T extends SidecarEventType>(
181
- type: T,
182
- payload: SidecarEventMap[T],
183
- ): Promise<void> {
184
- const set = listeners[type];
185
- if (set.size === 0) return;
186
- for (const listener of [...set]) {
187
- await listener(payload);
188
- }
189
- }
190
-
191
- function listenerCount(type: SidecarEventType): number {
192
- return listeners[type].size;
193
- }
194
-
195
- return { on, emit, emitAndAwait, listenerCount };
196
- }
197
-
198
- export type SidecarLookups = {
199
- /** Returns the hex-encoded Ed25519 public key stored for the address,
200
- * or `null` if the address is unknown. Used during the reconnect
201
- * challenge to verify the sidecar's signature. */
202
- lookupPublicKey?: (agentAddress: string) => Promise<string | null>;
203
-
204
- /** Returns the hub's current deploy ref for the address, or `null` if
205
- * no deploy state is tracked. The wire layer compares this against
206
- * the sidecar's reported ref during reconnect and emits
207
- * `deploy.ref.stale` only on mismatch. */
208
- lookupDeployRef?: (agentAddress: string) => Promise<string | null>;
209
-
210
- /** Persists a delivered outbound mail frame. Returns one row per
211
- * persisted record; the wire layer attaches `raw` to each row and
212
- * emits a `mail.persisted` event. */
213
- persistMail?: (args: {
214
- senderAddress: string;
215
- recipients: string[];
216
- raw: Uint8Array;
217
- }) => Promise<SidecarMailPersistedRow[]>;
218
-
219
- /** Ingests a received state pack and returns whether the wire layer
220
- * should ack or reject the pack to the sidecar. `repoId` identifies the
221
- * source repo at the hub (e.g. `{ kind: "agent-state", id: agentAddress }`
222
- * for the agent-state flow). */
223
- receiveStatePack?: (
224
- repoId: RepoId,
225
- pack: Uint8Array,
226
- ref: string,
227
- commitSha: string,
228
- ) => Promise<
229
- { accepted: true } | { accepted: false; reason: PackRejectReason }
230
- >;
231
- };