@sigrun/sigrun 0.0.0-stage → 0.1.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Shared conversation operations (core hardening W5 Task 5, spec §9.5):
3
+ * the ONE implementation of create/submit/list/read-meta that BOTH the
4
+ * HTTP routes (src/server/routes/conversations.ts,
5
+ * routes/submissions.ts) and the extension host bridge
6
+ * (src/server/host-bridge.ts) run — extracted from the route bodies so
7
+ * an extension driving a conversation in-process gets identical
8
+ * behavior by construction: content-part resolution, the served-model
9
+ * check, activity stamping, and the durable listing are never
10
+ * re-implemented.
11
+ *
12
+ * Each op is a pure `(ctx, …) → result` function over the shared
13
+ * `ServerContext`. Client-error vocabulary: a validation failure throws
14
+ * {@link ConversationOpError} (the routes' 400 bodies); the submit
15
+ * path's `UnservedModelError` propagates for the route (and the bridge
16
+ * caller) to map — the same split the inline route bodies had.
17
+ */
18
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
+ import { accessOf, isSharing, } from "@sigrun/shared/access";
20
+ import { touchActivity } from "../harness/activity.js";
21
+ import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
22
+ import { parseSubmissionInput, resolveParts } from "./content-parts.js";
23
+ import { CommandUsageError, expandInput, EXPANDED_ENTRY_KIND, } from "../commands/expand.js";
24
+ import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, WHEN_BUSY_MODES, } from "./helpers.js";
25
+ import { DEFAULT_SESSION_META, SessionMetaDoc, } from "./session-meta.js";
26
+ /**
27
+ * One client error from a conversation op: the exact 400 body the routes
28
+ * serve (`{error: "invalid_input"}`, `{error: "invalid_field", field}`),
29
+ * so the HTTP route maps it verbatim and the bridge caller sees the
30
+ * same fail-loud vocabulary.
31
+ */
32
+ export class ConversationOpError extends Error {
33
+ body;
34
+ constructor(body) {
35
+ super(body.error);
36
+ this.body = body;
37
+ this.name = "ConversationOpError";
38
+ }
39
+ }
40
+ /** The §7.1 ceiling for `?waitMs` — a positive integer at most this. */
41
+ const WAIT_MS_MAX = 60_000;
42
+ /**
43
+ * Race `submission.wait` against a `waitMs` timer (§7.1): the wait winning
44
+ * returns the settled record; the timer winning returns whichever record is
45
+ * CURRENT (mid-run). The losing wait's eventual rejection is never left
46
+ * unhandled.
47
+ */
48
+ async function waitForSubmission(submission, waitMs) {
49
+ let timer;
50
+ try {
51
+ const timeout = new Promise((resolve) => {
52
+ timer = setTimeout(() => resolve(true), waitMs);
53
+ });
54
+ const wait = submission.wait(BACKGROUND_CONTEXT);
55
+ const winner = await Promise.race([wait, timeout]);
56
+ if (winner !== true) {
57
+ return winner;
58
+ }
59
+ // The timer won: hand back the CURRENT record; a late rejection of
60
+ // the abandoned wait must not surface as unhandled.
61
+ void wait.catch(() => undefined);
62
+ return await submission.status(BACKGROUND_CONTEXT);
63
+ }
64
+ finally {
65
+ if (timer !== undefined)
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+ /**
70
+ * Create one conversation the server-create way: ownerless ownership, the
71
+ * boot default model plus the store's agent settings (re-read per create),
72
+ * then — when ANY stamp field is present — ONE commit writing them onto
73
+ * the session-meta doc. Returns the create-response shape `{id, view}`.
74
+ */
75
+ export async function createConversationOp(ctx, input = {}) {
76
+ const { opened } = ctx;
77
+ // Validate FIRST — a rejected input never touches storage.
78
+ if (input.owner !== undefined && typeof input.owner !== "string") {
79
+ throw new ConversationOpError({ error: "invalid_field", field: "owner" });
80
+ }
81
+ if (input.sharing !== undefined && !isSharing(input.sharing)) {
82
+ throw new ConversationOpError({ error: "invalid_field", field: "sharing" });
83
+ }
84
+ if (input.name !== undefined && typeof input.name !== "string") {
85
+ throw new ConversationOpError({ error: "invalid_field", field: "name" });
86
+ }
87
+ if (input.source !== undefined && typeof input.source !== "string") {
88
+ throw new ConversationOpError({ error: "invalid_field", field: "source" });
89
+ }
90
+ const conversation = await opened.harness.createConversation({
91
+ ownership: { kind: "ownerless" },
92
+ // A conversation with no agent model cannot generate (no_model):
93
+ // server-created conversations start on the boot default model,
94
+ // like the root conversation, and carry the store's
95
+ // defaultTools/perModelThinking — re-read per create, so PATCHes
96
+ // reach new conversations without a restart.
97
+ agent: storeAgentChange(opened, opened.defaultModel),
98
+ }, BACKGROUND_CONTEXT);
99
+ const stamps = [
100
+ input.owner,
101
+ input.sharing,
102
+ input.name,
103
+ input.source,
104
+ ].filter((value) => value !== undefined);
105
+ if (stamps.length > 0) {
106
+ await conversation.commit(async (tx) => {
107
+ const draft = await tx.doc(SessionMetaDoc, conversation.id);
108
+ if (input.owner !== undefined)
109
+ draft.owner = input.owner;
110
+ // Copy-on-assignment (the tracker requires alias-free roots):
111
+ // the people-scope array is cloned; the closed scopes spread.
112
+ if (input.sharing !== undefined) {
113
+ draft.sharing =
114
+ input.sharing.scope === "people"
115
+ ? {
116
+ scope: "people",
117
+ users: [...input.sharing.users],
118
+ level: input.sharing.level,
119
+ }
120
+ : { ...input.sharing };
121
+ }
122
+ if (input.name !== undefined)
123
+ draft.name = input.name;
124
+ if (input.source !== undefined)
125
+ draft.source = input.source;
126
+ }, BACKGROUND_CONTEXT);
127
+ }
128
+ const view = await readView(conversation);
129
+ return { id: conversation.id, view };
130
+ }
131
+ /**
132
+ * Submit one input the route way: content-part resolution against the
133
+ * conversation's live workspace env, the §2/§3 served-model check
134
+ * (refresh-on-miss, `UnservedModelError` propagates to the caller),
135
+ * `whenBusy`/`waitMs` validation, admission, the activity stamp, and the
136
+ * §7.1 record — at admission when no `waitMs` is given, else the bounded
137
+ * settlement race.
138
+ */
139
+ export async function submitOp(ctx, conversation, input, opts = {}) {
140
+ const { opened } = ctx;
141
+ const parsed = parseSubmissionInput(input);
142
+ if (!parsed.ok) {
143
+ throw new ConversationOpError({ error: "invalid_input" });
144
+ }
145
+ // Spec §2/§3 binding contract: a conversation pinned to an unserved
146
+ // model fails explicitly AT SUBMIT — 4xx with the available ids —
147
+ // never a silent fallback, and never a settled no_model run surfaced
148
+ // only later. Resolution goes through the refresh-on-miss wrapper:
149
+ // ONE bounded refresh of the named provider + re-resolve on the miss,
150
+ // so a model gained upstream since the last interval tick is
151
+ // immediately usable; still absent → the UnservedModelError carrying
152
+ // the catalog (the route maps it to 400 unserved_model).
153
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
154
+ if (agent.model !== undefined) {
155
+ await resolveServedModelRefreshing(opened.models, agent.model);
156
+ }
157
+ // `waitMs` (§7.1) is the opt-in bounded settlement wait for scripts —
158
+ // a positive integer ≤ 60 000; anything else is a fail-loud client
159
+ // error BEFORE anything is admitted.
160
+ let waitMs;
161
+ if (opts.waitMs !== undefined) {
162
+ const parsedWait = typeof opts.waitMs === "number" ? opts.waitMs : Number(opts.waitMs);
163
+ if (!Number.isInteger(parsedWait) ||
164
+ parsedWait <= 0 ||
165
+ parsedWait > WAIT_MS_MAX) {
166
+ throw new ConversationOpError({ error: "invalid_field", field: "waitMs" });
167
+ }
168
+ waitMs = parsedWait;
169
+ }
170
+ // `whenBusy` is the steer affordance (spec §7 item 3) — validate it
171
+ // against pi-durable's union and pass it through; unknown values are a
172
+ // client error, never a silent default.
173
+ const whenBusy = opts.whenBusy;
174
+ if (whenBusy !== undefined &&
175
+ (typeof whenBusy !== "string" || !WHEN_BUSY_MODES.includes(whenBusy))) {
176
+ throw new ConversationOpError({ error: "invalid_field", field: "whenBusy" });
177
+ }
178
+ // Resolve path-parts to inline content NOW, against the live
179
+ // workspace: missing paths become explicit error parts; other
180
+ // workspace failures throw — fail-loud. Command expansion (Task 2c)
181
+ // runs BEFORE this: a text part starting with `/` that names a skill
182
+ // or prompt template is rewritten into the text the model sees, and
183
+ // the submission's entry carries what the user TYPED (the transcript's
184
+ // `sigrun.user.expanded` entry). Non-`/` inputs, `/command` inputs,
185
+ // and unknown skills pass through untouched — every channel and the
186
+ // HTTP API behave identically because this is the one admission path.
187
+ // A MALFORMED command line (an unterminated quote in template args) is
188
+ // ordinary user input: it maps to the same `invalid_input` 400 shape
189
+ // `parseSubmissionInput` uses, never a 500. Anything else expansion
190
+ // throws is a genuine bug and still falls through as one.
191
+ const env = await conversationEnv(opened, conversation);
192
+ let expansion;
193
+ try {
194
+ expansion = await expandInput(parsed.parts, opened.commandExpansion, env, BACKGROUND_CONTEXT);
195
+ }
196
+ catch (error) {
197
+ if (error instanceof CommandUsageError) {
198
+ throw new ConversationOpError({ error: "invalid_input" });
199
+ }
200
+ throw error;
201
+ }
202
+ const content = await resolveParts(expansion.parts, env, BACKGROUND_CONTEXT);
203
+ const draft = {
204
+ type: "input",
205
+ content,
206
+ ...(opts.requestId === undefined ? {} : { requestId: opts.requestId }),
207
+ ...(whenBusy === undefined ? {} : { whenBusy: whenBusy }),
208
+ ...(expansion.typed === undefined
209
+ ? {}
210
+ : {
211
+ entry: {
212
+ kind: EXPANDED_ENTRY_KIND,
213
+ data: {
214
+ typed: expansion.typed,
215
+ ...(expansion.command === undefined
216
+ ? {}
217
+ : { command: expansion.command }),
218
+ },
219
+ },
220
+ }),
221
+ };
222
+ // The submit pass-through is retired (§6.7): the memory seam's
223
+ // runSettled owns capture, so submissions admit directly.
224
+ const submission = await conversation.submit(draft, BACKGROUND_CONTEXT);
225
+ // Admission stamps activity: the sidebar's activity sort must move the
226
+ // moment a prompt lands — before the run settles.
227
+ await touchActivity(opened.harness, conversation.id, opened.now, BACKGROUND_CONTEXT);
228
+ // §7.1: the record AT ADMISSION (queued or placed), or — with a
229
+ // validated `waitMs` — the bounded settlement race.
230
+ return waitMs === undefined
231
+ ? await submission.status(BACKGROUND_CONTEXT)
232
+ : await waitForSubmission(submission, waitMs);
233
+ }
234
+ /**
235
+ * List conversations the durable way: the storage scan is authoritative
236
+ * (conversations this process never created all list), the listing is
237
+ * PER-CALLER (`accessOf` against each row's session-meta snapshot — hidden
238
+ * is absent, §5.9), and every row is lean by construction (scan record +
239
+ * meta snapshot alone, no per-row view read). The caller is the resolved
240
+ * principal; the host bridge passes its own trusted view (see
241
+ * host-bridge.ts).
242
+ */
243
+ export async function listConversationsOp(ctx, caller, filters = {}) {
244
+ const { opened, rootId } = ctx;
245
+ const conversations = [];
246
+ let cursor;
247
+ do {
248
+ const page = await opened.storage.scanConversations({ order: "ascending" }, SCAN_PAGE_SIZE, cursor, BACKGROUND_CONTEXT);
249
+ for (const record of page.items) {
250
+ // The session-meta owner rides the summary when stamped (defaults
251
+ // serve nothing — no owner key when unset), and so do the sidebar
252
+ // fields: the display name, the activity stamp, the archive/pin
253
+ // flags, and the create source (spec §9.5).
254
+ const meta = await opened.harness.snapshot(SessionMetaDoc, record.id, BACKGROUND_CONTEXT);
255
+ const access = accessOf(caller, meta ?? {});
256
+ // Hidden is absent from the list (spec §5.9).
257
+ if (access === "none")
258
+ continue;
259
+ // The archived filter (absent = all).
260
+ if (filters.archivedOnly !== undefined &&
261
+ (meta?.archived ?? false) !== filters.archivedOnly) {
262
+ continue;
263
+ }
264
+ // `mine`: the caller's own rows (owner === caller.user).
265
+ if (filters.mine === true && meta?.owner !== caller.user)
266
+ continue;
267
+ // The owner filter (the bridge's shape — any named owner).
268
+ if (filters.owner !== undefined && meta?.owner !== filters.owner) {
269
+ continue;
270
+ }
271
+ const name = meta?.name ?? meta?.autoName;
272
+ conversations.push({
273
+ id: record.id,
274
+ root: record.id === rootId,
275
+ access,
276
+ ...(meta?.sharing === undefined ? {} : { sharing: meta.sharing }),
277
+ ...(meta?.owner === undefined ? {} : { owner: meta.owner }),
278
+ ...(name === undefined ? {} : { name }),
279
+ ...(meta?.source === undefined ? {} : { source: meta.source }),
280
+ ...(meta?.lastActivityAt === undefined
281
+ ? {}
282
+ : { lastActivityAt: meta.lastActivityAt }),
283
+ // Fork lineage from the durable record (the scan page item);
284
+ // a root or plainly created conversation has none.
285
+ ...(record.parent === undefined
286
+ ? {}
287
+ : {
288
+ parent: {
289
+ id: record.parent.conversationId,
290
+ at: record.parent.at,
291
+ },
292
+ }),
293
+ archived: meta?.archived ?? false,
294
+ pinned: meta?.pinned ?? false,
295
+ });
296
+ }
297
+ cursor = page.next;
298
+ } while (cursor !== undefined);
299
+ return conversations;
300
+ }
301
+ /**
302
+ * Read one conversation's session meta: the committed snapshot, or the
303
+ * defaults when the doc was never written. A read NEVER materializes the
304
+ * doc (defaults are free; a write-per-read would churn a doc revision on
305
+ * every sidebar poll).
306
+ */
307
+ export async function readMetaOp(opened, id) {
308
+ return ((await opened.harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT)) ??
309
+ DEFAULT_SESSION_META);
310
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The host event hub (core hardening W3 Task 4, spec §7.4): the single
3
+ * in-process fan-out point for `HostEventWire` events. Boot creates ONE
4
+ * hub, subscribes it to the event bus's `onHostEvent` (commit-derived
5
+ * conversation/run/interaction/ambient events) and the provider refresh
6
+ * paths' `onReadinessChange` callback (`providers.changed`), and exposes
7
+ * it on `OpenedHarness.events`.
8
+ *
9
+ * The hub itself is deliberately tiny — a publish/subscribe registry.
10
+ * The listener contract mirrors the bus's: `publish` invokes subscribers
11
+ * synchronously in install order; a throwing subscriber is logged and
12
+ * the rest still receive the event (one bad consumer must never wedge
13
+ * the commit-observation path upstream of it). Delivery concern for the
14
+ * SSE route's filtering (a per-event async SessionMeta re-snapshot) lives
15
+ * in the ROUTE's own side lane, not here — the hub never blocks.
16
+ */
17
+ export class EventHub {
18
+ #listeners = [];
19
+ /** Fan one event out to every subscriber (install order). Never throws. */
20
+ publish(event) {
21
+ for (const listener of this.#listeners) {
22
+ try {
23
+ listener(event);
24
+ }
25
+ catch (error) {
26
+ console.error("events hub: subscriber failed:", error);
27
+ }
28
+ }
29
+ }
30
+ /** Subscribe; returns the unsubscribe (identity-guarded, first match). */
31
+ subscribe(listener) {
32
+ this.#listeners.push(listener);
33
+ return () => {
34
+ const index = this.#listeners.indexOf(listener);
35
+ if (index >= 0)
36
+ this.#listeners.splice(index, 1);
37
+ };
38
+ }
39
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The events-stream wire types (core hardening W3 Task 4, spec §7.4): the
3
+ * canonical declarations live in `@sigrun/shared` (`shared/src/views.ts`,
4
+ * `HostEventWire`); this module re-exports them for server-side importers
5
+ * so `src/server/events/*` and `src/server/routes/events.ts` share one
6
+ * import root.
7
+ */
8
+ export {};