@sigrun/sigrun 0.1.4 → 0.1.6

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 (82) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +24 -2
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/builders.js +125 -0
  5. package/dist/blocks/images.js +57 -0
  6. package/dist/blocks/index.js +20 -0
  7. package/dist/blocks/limits.js +26 -0
  8. package/dist/blocks/placements.js +99 -0
  9. package/dist/blocks/schema.js +559 -0
  10. package/dist/blocks/validate.js +602 -0
  11. package/dist/config.js +31 -0
  12. package/dist/extension.d.ts +476 -9
  13. package/dist/harness/agent-env.js +62 -0
  14. package/dist/harness/boot.js +8 -1
  15. package/dist/harness/event-bus.js +33 -0
  16. package/dist/harness/identity/users.js +3 -2
  17. package/dist/harness/loader.js +369 -13
  18. package/dist/harness/packages.js +71 -2
  19. package/dist/harness/seam-host.js +69 -2
  20. package/dist/harness/settings.js +5 -0
  21. package/dist/harness/workspace.js +6 -4
  22. package/dist/jobs/fire.js +11 -0
  23. package/dist/jobs/spec.js +19 -0
  24. package/dist/serve.js +29 -39
  25. package/dist/server/auth/pipeline.js +16 -1
  26. package/dist/server/conversation-ops.js +482 -10
  27. package/dist/server/gateway.js +122 -0
  28. package/dist/server/helpers.js +26 -0
  29. package/dist/server/host-bridge.js +141 -4
  30. package/dist/server/index.js +27 -0
  31. package/dist/server/notifications.js +6 -2
  32. package/dist/server/routes/conversations.js +37 -93
  33. package/dist/server/routes/events.js +39 -0
  34. package/dist/server/routes/ext-assets.js +84 -0
  35. package/dist/server/routes/extensions.js +31 -1
  36. package/dist/server/routes/files.js +38 -83
  37. package/dist/server/routes/meta.js +35 -1
  38. package/dist/server/routes/providers.js +5 -21
  39. package/dist/server/routes/submissions.js +36 -111
  40. package/dist/server/routes/surfaces.js +387 -0
  41. package/dist/server/routes/users.js +6 -1
  42. package/dist/server/session-meta.js +12 -6
  43. package/dist/server/shutdown.js +85 -0
  44. package/dist/server/surfaces/access-cache.js +43 -0
  45. package/dist/server/surfaces/dedup.js +67 -0
  46. package/dist/server/surfaces/instance.js +42 -0
  47. package/dist/server/surfaces/mime.js +73 -0
  48. package/dist/server/surfaces/rate-limit.js +63 -0
  49. package/dist/server/surfaces/service.js +493 -0
  50. package/dist/server/surfaces/watchers.js +94 -0
  51. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  67. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  68. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  69. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  70. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  71. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  72. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  73. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  74. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  75. package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
  76. package/node_modules/@sigrun/shared/package.json +10 -2
  77. package/package.json +11 -5
  78. package/webui/dist/assets/index-CiYInlLx.css +1 -0
  79. package/webui/dist/assets/index-JDfOQV9z.js +52 -0
  80. package/webui/dist/index.html +2 -2
  81. package/webui/dist/assets/index-BiSMljer.css +0 -1
  82. package/webui/dist/assets/index-hVLvsizV.js +0 -50
@@ -36,10 +36,13 @@ import type { AssistantMessage, Provider } from "@earendil-works/pi-ai";
36
36
  import type { ConversationDocToken, ConversationId, Cursor, EntryId, EntryQuery, EntryRecord, Extension, InputSubmissionDraft, JsonObject, ModelRef, Page, SubmissionId } from "@earendil-works/pi-durable";
37
37
  import type { Access, Sharing } from "@sigrun/shared/access";
38
38
  import type { AmbientBody, AmbientPlace, AmbientRecord } from "@sigrun/shared/ambient";
39
+ import type { Action, DataOps, IconName, Tree } from "@sigrun/shared/blocks";
39
40
  import type { InteractionAnswer, InteractionRecord } from "@sigrun/shared/interactions";
40
41
  import type { JobSpec } from "@sigrun/shared/jobs";
41
- import type { ConversationSummary, ConversationViewWire, HealthStatus, SessionMeta, SubmissionRecordWire, UserWire as DirectoryUserWire } from "@sigrun/shared/views";
42
- export type { Access, AmbientRecord, DirectoryUserWire, InteractionRecord, JobSpec, Sharing, };
42
+ import type { ConversationSummary, ConversationViewWire, HealthStatus, ModelEntry, SessionMeta, SlashCommand, SubmissionRecordWire, ThinkingLevelWire, UserWire as DirectoryUserWire } from "@sigrun/shared/views";
43
+ export type { Access, AmbientRecord, DirectoryUserWire, InteractionRecord, JobSpec, ModelEntry, Sharing, SlashCommand, };
44
+ type JsonParams = JsonObject;
45
+ type JsonParamValue = JsonParams[string];
43
46
  /**
44
47
  * The invocation-scoped context handlers and host APIs receive: a
45
48
  * structural view of the runtime context (its cancellation signal).
@@ -111,6 +114,14 @@ export type ExtensionLifecyclePayload = {
111
114
  readonly conversationId: ConversationId;
112
115
  readonly idleMs: number;
113
116
  };
117
+ /** Gateway C6: one terminal interaction transition — the payload mirrors
118
+ * the record's terminal envelope fields. */
119
+ interactionSettled: {
120
+ readonly conversationId: ConversationId;
121
+ readonly callId: string;
122
+ readonly kind: string;
123
+ readonly status: "answered" | "skipped" | "expired" | "cancelled";
124
+ };
114
125
  };
115
126
  /** A factory-result `runSettled` handler: called once per settled run, deadline-bounded. */
116
127
  export type ExtensionRunSettledHandler = (event: RunSettledEvent, api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
@@ -135,6 +146,8 @@ export type SigrunLifecycleHandlers = {
135
146
  readonly conversationForked?: ExtensionLifecycleHandler<"conversationForked">;
136
147
  readonly conversationArchived?: ExtensionLifecycleHandler<"conversationArchived">;
137
148
  readonly conversationIdle?: ExtensionLifecycleHandler<"conversationIdle">;
149
+ /** Terminal interaction transitions from ANY writer (gateway C6). */
150
+ readonly interactionSettled?: ExtensionLifecycleHandler<"interactionSettled">;
138
151
  readonly harnessShutdown?: ExtensionHarnessShutdownHandler;
139
152
  /** The user-removal transfer event; direct fan-out, not commit-derived. */
140
153
  readonly userDeleted?: ExtensionUserDeletedHandler;
@@ -204,8 +217,11 @@ export interface WebuiBundleContribution {
204
217
  /**
205
218
  * The resolved caller (core hardening W1): what the auth pipeline
206
219
  * stamps on every request. `method` names the proof — the empty-method
207
- * naive path (`"none"`), a verified token (`"token"`), or a trusted
208
- * forward-auth header set (`"proxy"`).
220
+ * naive path (`"none"`), a verified token (`"token"`), a trusted
221
+ * forward-auth header set (`"proxy"`), or an IN-PROCESS host-built
222
+ * principal (`"host"`, gateway C4b: `commands.run` builds it from the
223
+ * directory — no request ever carries one, but a command run through
224
+ * the host sees exactly which proof its caller had).
209
225
  */
210
226
  export interface Principal {
211
227
  /** The directory username; `undefined` under the naive method with no header. */
@@ -215,7 +231,7 @@ export interface Principal {
215
231
  readonly admin: boolean;
216
232
  /** True for the `SIGRUN_API_TOKEN` service principal. */
217
233
  readonly service: boolean;
218
- readonly method: "none" | "token" | "proxy";
234
+ readonly method: "none" | "token" | "proxy" | "host";
219
235
  }
220
236
  /** One extension HTTP route, mounted at `/v1/ext/<slug>/<path>`. */
221
237
  export interface ExtensionRouteContribution {
@@ -252,6 +268,9 @@ export interface NotificationEvent {
252
268
  readonly type: "interaction.pending" | "job.failed" | "run.settled";
253
269
  readonly conversationId?: number;
254
270
  readonly jobId?: string;
271
+ /** Gateway C6: the pending record's id — set on `interaction.pending` only
272
+ * (read the record via `host.snapshotInteraction`). */
273
+ readonly callId?: string;
255
274
  /** The recipient. */
256
275
  readonly user: string | undefined;
257
276
  readonly title: string;
@@ -266,6 +285,315 @@ export interface NotifierContribution {
266
285
  readonly host: SigrunHost;
267
286
  }): Promise<void>;
268
287
  }
288
+ /** Whether an adapter's credentials/config are present; unconfigured adapters are never started. */
289
+ export type ChannelReadiness = {
290
+ readonly status: "not_configured" | "ready";
291
+ readonly detail?: string;
292
+ };
293
+ /** What a chat platform can render — the engine formats against these. */
294
+ export interface ChannelCapabilities {
295
+ readonly chatTypes: readonly ("dm" | "group" | "thread")[];
296
+ readonly maxMessageLength: number;
297
+ readonly format: "plain" | "markdown" | "native";
298
+ /** Edit-in-place streaming; `minIntervalMs` is the platform's safe edit rate. */
299
+ readonly edit?: {
300
+ readonly minIntervalMs: number;
301
+ };
302
+ /** `labelMax` bounds button text; the engine truncates, never the adapter. */
303
+ readonly buttons?: {
304
+ readonly max: number;
305
+ readonly perRow?: number;
306
+ readonly labelMax?: number;
307
+ };
308
+ readonly media?: readonly ("image" | "audio" | "video" | "document")[];
309
+ readonly typing?: {
310
+ readonly intervalMs: number;
311
+ };
312
+ readonly replyTo?: boolean;
313
+ readonly commandMenu?: boolean;
314
+ /** One short paragraph for the prompt: what renders, what doesn't. */
315
+ readonly promptHint?: string;
316
+ }
317
+ /** One chat on a platform — where a conversation lives. */
318
+ export interface ChatRef {
319
+ readonly chatId: string;
320
+ readonly chatType: "dm" | "group" | "thread";
321
+ readonly threadId?: string;
322
+ }
323
+ /** One platform message — the edit/delete target. */
324
+ export interface MessageRef {
325
+ readonly chat: ChatRef;
326
+ readonly messageId: string;
327
+ }
328
+ /**
329
+ * One inbound media attachment. `fetch` is lazy so unwanted media is
330
+ * never downloaded; the adapter stops reading and throws once `maxBytes`
331
+ * is exceeded, so an undeclared-size file is never fully downloaded
332
+ * either.
333
+ *
334
+ * The size-limit error is an error whose `name` is
335
+ * {@link AttachmentTooLargeName} (`"AttachmentTooLarge"`): the engine
336
+ * matches it by NAME, never `instanceof` — this types-only contract
337
+ * ships no runtime subpath, so class identity could never line up across
338
+ * an adapter's own dependency tree.
339
+ */
340
+ export interface InboundAttachment {
341
+ readonly kind: "image" | "audio" | "voice" | "video" | "document" | "sticker";
342
+ readonly mime?: string;
343
+ readonly filename?: string;
344
+ readonly size?: number;
345
+ fetch(opts: {
346
+ maxBytes: number;
347
+ signal: AbortSignal;
348
+ }): Promise<Uint8Array>;
349
+ }
350
+ /** One inbound chat message, handed to the engine through `ChannelContext.receive`. */
351
+ export interface InboundEvent {
352
+ readonly messageId: string;
353
+ readonly chat: ChatRef & {
354
+ readonly title?: string;
355
+ };
356
+ readonly sender: {
357
+ readonly id: string;
358
+ readonly name?: string;
359
+ readonly isBot?: boolean;
360
+ };
361
+ /** The bot's own message echoed back; the engine drops it. */
362
+ readonly fromSelf?: boolean;
363
+ readonly text: string;
364
+ /** Group messages: the bot was @-mentioned or replied to. */
365
+ readonly mentioned?: boolean;
366
+ readonly replyTo?: {
367
+ readonly messageId: string;
368
+ readonly text?: string;
369
+ readonly authorName?: string;
370
+ readonly fromSelf?: boolean;
371
+ };
372
+ readonly attachments?: readonly InboundAttachment[];
373
+ /** Platform album/batch id (e.g. a media group); events sharing it merge into one submission. */
374
+ readonly groupId?: string;
375
+ }
376
+ /** A button tap: `data` is the opaque token the engine put on the button. */
377
+ export interface ActionEvent {
378
+ readonly chat: ChatRef;
379
+ readonly sender: {
380
+ readonly id: string;
381
+ readonly name?: string;
382
+ };
383
+ readonly message?: MessageRef;
384
+ readonly data: string;
385
+ }
386
+ /** One message to send: already formatted per `capabilities.format` and within `maxMessageLength`. */
387
+ export interface OutboundMessage {
388
+ readonly chat: ChatRef;
389
+ readonly text: string;
390
+ readonly replyTo?: string;
391
+ readonly buttons?: readonly (readonly {
392
+ readonly label: string;
393
+ readonly data: string;
394
+ readonly style?: "primary" | "danger" | "neutral";
395
+ }[])[];
396
+ readonly files?: readonly {
397
+ readonly name: string;
398
+ readonly mime?: string;
399
+ readonly bytes: Uint8Array;
400
+ }[];
401
+ }
402
+ /** Which platform messages one `send` produced (a media group, a split message). */
403
+ export interface SendReceipt {
404
+ readonly messageIds: readonly string[];
405
+ }
406
+ /** The engine-side context handed to a started adapter. */
407
+ export interface ChannelContext {
408
+ readonly signal: AbortSignal;
409
+ /**
410
+ * Resolves once the event is durably admitted, handled as a command,
411
+ * or deliberately dropped: the adapter acks the platform on any
412
+ * resolution. A rejection means the engine failed; the adapter does
413
+ * not ack, the platform redelivers, and dedupe makes the retry safe.
414
+ */
415
+ receive(event: InboundEvent): Promise<{
416
+ readonly outcome: "admitted" | "dropped" | "command";
417
+ }>;
418
+ /** A button tap. `data` is the opaque token the engine put on the button. */
419
+ action(event: ActionEvent): Promise<void>;
420
+ status(state: {
421
+ readonly state: "connecting" | "running";
422
+ readonly detail?: string;
423
+ }): void;
424
+ fail(error: unknown): void;
425
+ readonly log: {
426
+ info(msg: string): void;
427
+ warn(msg: string): void;
428
+ error(msg: string, error?: unknown): void;
429
+ };
430
+ }
431
+ /**
432
+ * One channel adapter: one chat platform's transport. The engine owns
433
+ * supervision and delivery; the adapter owns the platform.
434
+ */
435
+ export interface ChannelContribution {
436
+ /** Must match `^[a-z][a-z0-9-]*$`; unique across every loaded extension. */
437
+ readonly id: string;
438
+ readonly label: string;
439
+ readonly capabilities: ChannelCapabilities;
440
+ /** Whether credentials/config are present. Unconfigured adapters are never started. */
441
+ configured(): ChannelReadiness | Promise<ChannelReadiness>;
442
+ /**
443
+ * Connect and begin receiving. Resolves once connected; any receive
444
+ * loop keeps running until `ctx.signal` aborts. Post-connect failures
445
+ * go through `ctx.fail` so the engine's supervisor owns restarts
446
+ * (adapters never self-reconnect forever).
447
+ */
448
+ start(ctx: ChannelContext): Promise<void>;
449
+ stop?(): Promise<void>;
450
+ send(message: OutboundMessage): Promise<SendReceipt>;
451
+ edit?(ref: MessageRef, message: OutboundMessage): Promise<void>;
452
+ delete?(ref: MessageRef): Promise<void>;
453
+ typing?(chat: ChatRef): Promise<void>;
454
+ setCommands?(commands: readonly {
455
+ readonly name: string;
456
+ readonly description: string;
457
+ }[]): Promise<void>;
458
+ /** Markdown → the platform's native markup. Required when `capabilities.format` is `"native"`. */
459
+ format?(markdown: string): string;
460
+ /** Without it every send error is `"transient"`. */
461
+ classifyError?(error: unknown): "transient" | "permanent" | "chat_gone";
462
+ }
463
+ /**
464
+ * The `name` an adapter's size-limit error carries: the engine matches
465
+ * `error.name === "AttachmentTooLarge"`, never `instanceof` (no runtime
466
+ * subpath ships with the types-only contract — see
467
+ * {@link InboundAttachment.fetch}).
468
+ */
469
+ export type AttachmentTooLargeName = "AttachmentTooLarge";
470
+ /**
471
+ * The context sigrun hands the loaded engine's `start`: the flat,
472
+ * slug-attributed channel set (every loaded adapter's contribution —
473
+ * `{extension, channel}` pairs) and the shutdown signal. The signal is
474
+ * the `AbortController` the host's `gateway.stop()` aborts — an engine's
475
+ * receive loops observe it, exactly like an adapter's `ChannelContext.signal`.
476
+ */
477
+ export interface GatewayEngineContext {
478
+ readonly channels: readonly {
479
+ readonly extension: string;
480
+ readonly channel: ChannelContribution;
481
+ }[];
482
+ /** Aborted by the host's `gateway.stop()` — the engine's shutdown signal. */
483
+ readonly signal: AbortSignal;
484
+ }
485
+ /**
486
+ * One gateway engine: the chat-transport supervisor the `gateway`
487
+ * factory-result field names. ONE engine per boot (a second declarer
488
+ * quarantines at load — the one-engine rule); core ships none. `start`
489
+ * runs after the server listens; `stopIntake` runs before the event bus's
490
+ * shutdown drain (intake closes first — the drain is the last admission
491
+ * window); `stop` runs after it (adapters stop only once every settle has
492
+ * been delivered). Health is reported through `health()` — NEVER the
493
+ * factory-result `health` field (that field is the provider-contributor
494
+ * surface; a result carrying both quarantines).
495
+ */
496
+ export interface GatewayEngine {
497
+ /** Start supervision over the channels; resolves once started. */
498
+ start(ctx: GatewayEngineContext): Promise<void>;
499
+ /** Close inbound intake (admissions stop) — before the shutdown drain. */
500
+ stopIntake(): Promise<void>;
501
+ /** Stop the engine and its adapters — after the shutdown drain. */
502
+ stop(): Promise<void>;
503
+ /** The engine's synchronous health read ("ok" | "degraded"). */
504
+ health(): "ok" | "degraded";
505
+ }
506
+ /**
507
+ * The render/data access verdict a surface reports for one principal
508
+ * (spec §6.5): `none` hides the surface entirely (404 on every route).
509
+ */
510
+ export type SurfaceAccessLevel = "none" | "read" | "write";
511
+ /** The per-principal, per-instance context `render`/`data` receive. */
512
+ export interface SurfaceContext {
513
+ /** Who is looking; the access verdict is this principal's. */
514
+ readonly principal: Principal;
515
+ /** From `navigate(params)`; validated as JSON, size-capped at 4 KiB. */
516
+ readonly params: JsonParams;
517
+ /** The instance identity: `instanceKey(params)` of this instance. */
518
+ readonly key: string;
519
+ /** Aborts when the request is abandoned; observe it in long work. */
520
+ readonly signal: AbortSignal;
521
+ }
522
+ /** The full render result: the tree, its data, and the access verdict. */
523
+ export interface SurfaceRenderResult {
524
+ readonly tree: Tree;
525
+ readonly data: JsonObject;
526
+ readonly access: SurfaceAccessLevel;
527
+ }
528
+ /** The lighter `data()` result: refreshed data plus the access verdict. */
529
+ export interface SurfaceDataResult {
530
+ readonly data: JsonObject;
531
+ readonly access: SurfaceAccessLevel;
532
+ }
533
+ /** The payload of an `onUpload` handler (spec §7.1 — snif... never trusted). */
534
+ export interface UploadedFile {
535
+ /** The file field's name (the `file` field that carried the upload). */
536
+ readonly field: string;
537
+ /** Client-supplied filename, sanitised to a basename. */
538
+ readonly name: string;
539
+ /** Sniffed from the bytes, never trusted from the client. */
540
+ readonly mime: string;
541
+ /** Capped by the field's `maxBytes` and `server.maxUploadBytes`. */
542
+ readonly bytes: Uint8Array;
543
+ }
544
+ /**
545
+ * What an action handler returns (spec §6.3/§7.1): accepted (with an
546
+ * optional data update, a navigation, and a toast) or rejected (an
547
+ * operator-facing error, optionally field-scoped).
548
+ */
549
+ export type ActionResult = {
550
+ readonly ok: true;
551
+ /** Data ops the server applies to the instance's data (sharedData only keeps them; §7.3). */
552
+ readonly update?: DataOps;
553
+ /** A `navigate` action (spec §6.1): the channel routes it through the normal navigation paths. */
554
+ readonly navigate?: Extract<Action, {
555
+ type: "navigate";
556
+ }>;
557
+ readonly toast?: string;
558
+ } | {
559
+ readonly ok: false;
560
+ readonly error: {
561
+ readonly message: string;
562
+ readonly field?: string;
563
+ };
564
+ };
565
+ /**
566
+ * One declared surface (spec §7.1, verbatim shape): the placement is
567
+ * the generic parameter — `"view"` or `"panel"` (the settings sections
568
+ * of §13.3 are a separate workstream).
569
+ */
570
+ export interface SurfaceDef<P extends "view" | "panel"> {
571
+ /** Unique within the extension. */
572
+ readonly id: string;
573
+ readonly title: string;
574
+ readonly icon: IconName;
575
+ readonly placement: P;
576
+ /** Data is identical for every principal with access (§7.3). */
577
+ readonly sharedData?: boolean;
578
+ /** Channels refetch data on this interval while visible (min 5000). */
579
+ readonly refreshMs?: number;
580
+ /** §7.3; default: canonical JSON of params. */
581
+ instanceKey?(params: JsonParams): string;
582
+ render(ctx: SurfaceContext): Promise<SurfaceRenderResult>;
583
+ data?(ctx: SurfaceContext): Promise<SurfaceDataResult>;
584
+ readonly actions?: Readonly<Record<string, (ctx: SurfaceContext, payload: JsonParamValue) => Promise<ActionResult>>>;
585
+ }
586
+ /**
587
+ * The `surfaces` field of a factory result (spec §7.1): declared views
588
+ * and panels. Ids are unique across BOTH lists (one namespace — the
589
+ * loader enforces it).
590
+ */
591
+ export interface SurfacesContribution {
592
+ readonly views?: readonly SurfaceDef<"view">[];
593
+ readonly panels?: readonly SurfaceDef<"panel">[];
594
+ /** §13.3 settings sections — a separate workstream; declared for shape stability. */
595
+ readonly settings?: readonly never[];
596
+ }
269
597
  /** One wire part of a `host.conversations.submit` input array. */
270
598
  export type SubmissionInputPart = {
271
599
  readonly type: "text";
@@ -313,8 +641,16 @@ export interface SigrunHost {
313
641
  };
314
642
  /** Seed (or fully replace) one interaction record; seeding a terminal record is a no-op. */
315
643
  seedInteraction(conversationId: ConversationId, record: InteractionRecord, context: HandlerContext): Promise<void>;
316
- /** Answer a pending interaction from the extension side (the tool path). */
317
- answerInteraction(conversationId: ConversationId, callId: string, answer: InteractionAnswer, context: HandlerContext): Promise<boolean>;
644
+ /**
645
+ * Answer a pending interaction from the extension side. `opts.user`
646
+ * (gateway C5) stamps the answer HUMAN and names the user — who
647
+ * answered matters to every participant a conversation is shared
648
+ * with; access checks stay the caller's job (host code is trusted).
649
+ * Without it the answer is the extension's own (`"extension"`).
650
+ */
651
+ answerInteraction(conversationId: ConversationId, callId: string, answer: InteractionAnswer, context: HandlerContext, opts?: {
652
+ readonly user?: string;
653
+ }): Promise<boolean>;
318
654
  /** Mark a pending interaction expired/cancelled/skipped (extension-owned semantics). */
319
655
  settleInteraction(conversationId: ConversationId, callId: string, status: "skipped" | "expired" | "cancelled", context: HandlerContext): Promise<boolean>;
320
656
  /** Read one interaction record (the poll loop's read). */
@@ -331,6 +667,19 @@ export interface SigrunHost {
331
667
  }, context: HandlerContext): Promise<void>;
332
668
  /** Clear one ambient surface (empty replacement; keeps ownership). */
333
669
  clearAmbient(conversationId: ConversationId, key: string, context: HandlerContext): Promise<void>;
670
+ /**
671
+ * Publish a change for one surface instance (spec §7.3).
672
+ * `changed(id, key, update?)` tells the server that instance `key`
673
+ * of surface `id` changed: watching streams receive a `surface.changed`
674
+ * event and refetch. `key` is the string `instanceKey(params)` returned
675
+ * for that instance — params NEVER appear here. `update` carries data
676
+ * ops (kept only for `sharedData` surfaces, §7.3). Fire-and-forget:
677
+ * never awaited, and calling it for a surface without `data()` (a
678
+ * load-time contract violation) is rejected by the host.
679
+ */
680
+ readonly surfaces: {
681
+ changed(id: string, key: string, update?: DataOps): void;
682
+ };
334
683
  /**
335
684
  * Drive conversations in-process (channel bridges, jobs) over the
336
685
  * same code paths as the HTTP API. Extensions are trusted in-process
@@ -343,6 +692,15 @@ export interface SigrunHost {
343
692
  readonly sharing?: Sharing;
344
693
  readonly name?: string;
345
694
  readonly source?: string;
695
+ /**
696
+ * Pin the conversation's extension/tool selection (gateway C4):
697
+ * names resolved against the registry — an unknown name throws a
698
+ * `ConversationOpError` naming `agent.extensions`/`agent.tools`.
699
+ */
700
+ readonly agent?: {
701
+ readonly extensions?: readonly string[];
702
+ readonly tools?: readonly string[];
703
+ };
346
704
  }): Promise<ConversationId>;
347
705
  submit(id: ConversationId, input: readonly SubmissionInputPart[], opts?: {
348
706
  readonly user?: string;
@@ -355,6 +713,69 @@ export interface SigrunHost {
355
713
  readonly archived?: boolean;
356
714
  }): Promise<readonly ConversationSummary[]>;
357
715
  meta(id: ConversationId): Promise<SessionMeta>;
716
+ /**
717
+ * Withdraw/settle the conversation's active submission (gateway
718
+ * C4, the abort route's op). On an idle conversation it RESOLVES
719
+ * (chat `/stop` on an idle chat is not an error) — the HTTP route
720
+ * keeps its 404 `no_active_submission`.
721
+ */
722
+ abort(id: ConversationId): Promise<void>;
723
+ /**
724
+ * Change the model and/or thinking level (gateway C4, the configure
725
+ * route's op). The inputs are typed but validated like the route's
726
+ * body — an unknown key or malformed model is a
727
+ * `ConversationOpError`.
728
+ */
729
+ configure(id: ConversationId, opts: {
730
+ readonly model?: string;
731
+ readonly thinking?: ThinkingLevelWire;
732
+ }): Promise<void>;
733
+ /** Admit one manual compaction task (gateway C4, the compact route's op). */
734
+ compact(id: ConversationId): Promise<void>;
735
+ /**
736
+ * Fork the history at one entry (gateway C4, the fork route's op):
737
+ * absent `at` = the newest entry; `owner` stamps the fork's session
738
+ * owner (absent = the owner key removed). Returns the new id.
739
+ */
740
+ fork(id: ConversationId, opts?: {
741
+ readonly at?: EntryId;
742
+ readonly owner?: string;
743
+ }): Promise<ConversationId>;
744
+ /**
745
+ * Patch the session meta (gateway C4, the meta PATCH route's op).
746
+ * Host callers are TRUSTED: the sharing patch runs with no
747
+ * owner-or-superuser gate (the HTTP route keeps its 403).
748
+ */
749
+ patchMeta(id: ConversationId, patch: {
750
+ readonly name?: string | null;
751
+ readonly archived?: boolean;
752
+ readonly sharing?: Sharing | null;
753
+ }): Promise<SessionMeta>;
754
+ /**
755
+ * Upload one file into the conversation's upload namespace (gateway
756
+ * C4b, the raw-upload route's op): `uploads/<conversationId>/<name>`,
757
+ * the exact path a `file`/`image` submission part takes. The NAME is
758
+ * the one rule (no `/` or `\\`, not `.`/`..`, non-empty) — a bad
759
+ * name throws a `ConversationOpError` (`invalid_field` `name`).
760
+ */
761
+ upload(id: ConversationId, file: {
762
+ readonly name: string;
763
+ readonly mime?: string;
764
+ readonly bytes: Uint8Array;
765
+ }): Promise<{
766
+ readonly path: string;
767
+ }>;
768
+ /**
769
+ * Read one workspace file raw (gateway C4b, the files/raw route's
770
+ * op): workspace-confined, capped at `server.maxFileReadBytes`, with
771
+ * the extension-derived mime. Over the cap throws
772
+ * `ConversationOpError({error: "too_large"}, 413)`; a bad path
773
+ * `invalid_path`; an absent file `not_found`.
774
+ */
775
+ readFile(id: ConversationId, path: string): Promise<{
776
+ readonly bytes: Uint8Array;
777
+ readonly mime?: string;
778
+ }>;
358
779
  };
359
780
  /** The user directory (admin wire form; emails are admin material). */
360
781
  readonly users: {
@@ -378,13 +799,42 @@ export interface SigrunHost {
378
799
  readonly identity: {
379
800
  actingUser(conversationId: ConversationId): Promise<string | undefined>;
380
801
  };
802
+ /**
803
+ * The served model catalog (gateway C4b): the SAME list `GET
804
+ * /v1/models` serves (picker entries — no provider internals).
805
+ */
806
+ readonly models: {
807
+ list(): Promise<readonly ModelEntry[]>;
808
+ };
809
+ /**
810
+ * The slash-command surface (gateway C4b): the merged catalog for
811
+ * listing, and the invoke route's run for driving an extension
812
+ * command in-process (chat `/command` handling).
813
+ */
814
+ readonly commands: {
815
+ /** The merged catalog rows (core table + extension entries). */
816
+ list(): Promise<readonly SlashCommand[]>;
817
+ /**
818
+ * Run one EXTENSION command (the invoke route's resolution): an
819
+ * unknown/core name throws `command_not_found: <name>`; `opts.user`
820
+ * must name a directory user (absent → `unknown user "<user>"`) and
821
+ * becomes the run's principal with EFFECTIVE admin (the stored flag
822
+ * OR the persisted group grant) and `method: "host"`. A throwing
823
+ * run propagates (no HTTP 500 mapping — the caller is in-process).
824
+ */
825
+ run(conversationId: ConversationId, name: string, args: string, opts: {
826
+ readonly user: string;
827
+ }): Promise<{
828
+ readonly notice?: string;
829
+ }>;
830
+ };
381
831
  }
382
832
  /**
383
833
  * The documented factory-result contract: EVERY field an extension may
384
834
  * return from its default-export factory. Every field is optional —
385
835
  * a minimal extension returns only `extension`; a contributor-only
386
- * result (`providers`/`webuiBundles` without `extension`) identifies
387
- * itself by `name`.
836
+ * result (`providers`/`webuiBundles`/`channels` without `extension`)
837
+ * identifies itself by `name`.
388
838
  *
389
839
  * `routes`, `commands`, `skills`, and `notifiers` are declared here
390
840
  * ahead of their implementations (W5 Tasks 2, 3, 4, and 6); the loader
@@ -419,6 +869,23 @@ export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
419
869
  readonly skills?: readonly string[];
420
870
  /** Notification transports (lands in Task 6). */
421
871
  readonly notifiers?: readonly NotifierContribution[];
872
+ /**
873
+ * Channel adapters (gateway C1, spec §7): one per chat platform. A
874
+ * `channels`-only result is a valid contributor identified by `name`;
875
+ * channel-id collisions across extensions quarantine the later
876
+ * loader (chat bindings are keyed by channel id — never last-wins).
877
+ */
878
+ readonly channels?: readonly ChannelContribution[];
879
+ /**
880
+ * The gateway engine (gateway C2, spec §8.0): ONE per boot — a second
881
+ * declarer quarantines at load naming the first. Always carried on a
882
+ * result WITH an `extension` (an engine owns pi-durable surface); a
883
+ * contributor-only `gateway` result is not a contract form. Report
884
+ * health through `gateway.health()`, never the `health` field.
885
+ */
886
+ readonly gateway?: GatewayEngine;
887
+ /** Declared surfaces — views and panels (UI-blocks U3, spec §7.1; the service lands in Task 2). */
888
+ readonly surfaces?: SurfacesContribution;
422
889
  }
423
890
  /**
424
891
  * The extension factory: sigrun calls it at load, passing the host
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The agent shell environment (multi-user spec §8, the env-leak fix).
3
+ *
4
+ * pi-durable's `NodeExecutionEnv` runs commands with `{...process.env,
5
+ * ...shellEnv, ...options.env}` unless a call passes `inheritEnv: false`,
6
+ * and its bash tool always passes `inheritEnv: true`. So without this,
7
+ * an agent's `env` prints sigrun's own environment: `SIGRUN_API_TOKEN`,
8
+ * `SIGRUN_PROXY_SECRET`, and provider keys. `shellEnv` cannot fix it
9
+ * (it merges over `process.env`, it never subtracts), so
10
+ * {@link AgentExecutionEnv} forces every `exec` onto an allowlist built
11
+ * from sigrun's environment, plus whatever the caller set explicitly.
12
+ *
13
+ * The allowlist is the spec's list plus two variables existing installs
14
+ * depend on:
15
+ * - `NPM_CONFIG_CACHE`: the image runs with a read-only root filesystem
16
+ * and points npm's cache at `/cache/npm`; without it `npm install` in
17
+ * an agent shell fails writing `~/.npm`.
18
+ * - `SSH_AUTH_SOCK`: a socket path, not a secret; on a single-user
19
+ * workstation it is how the agent's `git push` reaches the operator's
20
+ * ssh-agent. The multi-user jail (spec §9) must not pass it to other
21
+ * users' workers.
22
+ */
23
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
24
+ /** Variables copied from sigrun's environment into every agent shell. */
25
+ export const AGENT_ENV_KEYS = [
26
+ "PATH",
27
+ "HOME",
28
+ "TMPDIR",
29
+ "LANG",
30
+ "TZ",
31
+ "TERM",
32
+ "USER",
33
+ "LOGNAME",
34
+ "SHELL",
35
+ "NPM_CONFIG_CACHE",
36
+ "SSH_AUTH_SOCK",
37
+ ];
38
+ /** Locale variables (`LC_ALL`, `LC_CTYPE`, …) pass through by prefix. */
39
+ const AGENT_ENV_PREFIXES = ["LC_"];
40
+ /** The allowlisted subset of `source` (defined values only). */
41
+ export function agentEnv(source = process.env) {
42
+ const env = {};
43
+ for (const [key, value] of Object.entries(source)) {
44
+ if (value === undefined)
45
+ continue;
46
+ if (AGENT_ENV_KEYS.includes(key) ||
47
+ AGENT_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) {
48
+ env[key] = value;
49
+ }
50
+ }
51
+ return env;
52
+ }
53
+ /**
54
+ * Upstream's local env with every command confined to {@link agentEnv}
55
+ * plus the call's own `env`. `inheritEnv` is overridden whatever the
56
+ * caller asked for: that flag is how the leak happens.
57
+ */
58
+ export class AgentExecutionEnv extends NodeExecutionEnv {
59
+ exec(command, options, context) {
60
+ return super.exec(command, { ...options, inheritEnv: false, env: { ...agentEnv(), ...options?.env } }, context);
61
+ }
62
+ }
@@ -277,7 +277,7 @@ export async function openHarness(config, options = {}) {
277
277
  const sharedStores = sharedExtensionStores(roots, env);
278
278
  const seamHosts = new Map();
279
279
  const seamKinds = new Map();
280
- const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, lifecycleBindings, workerBindings, providerContributors, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, } = await loadExtensions(loaderInputs, registry, {
280
+ const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, lifecycleBindings, workerBindings, providerContributors, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, } = await loadExtensions(loaderInputs, registry, {
281
281
  packagesDir,
282
282
  hostContextFactory: (extensionName, kinds, priorHost) => {
283
283
  // Two-phase, revised for the provisional identity (provider
@@ -430,6 +430,10 @@ export async function openHarness(config, options = {}) {
430
430
  // `typed` entry — there is no user turn to attribute).
431
431
  commandExpansion,
432
432
  envBuilder: undefined,
433
+ // Gateway C7: a deliver-carrying fire warns once per run when no
434
+ // engine loaded (core ships none — `gatewayEngine` is undefined
435
+ // unless an extension declared one).
436
+ gatewayLoaded: gatewayEngine !== undefined,
433
437
  };
434
438
  registry.install({
435
439
  name: "sigrun.jobs",
@@ -850,7 +854,10 @@ export async function openHarness(config, options = {}) {
850
854
  },
851
855
  extensionRoutes: routeBindings,
852
856
  extensionCommands: commandBindings,
857
+ extensionSurfaces: surfaceBindings,
853
858
  notifierBindings,
859
+ channelBindings,
860
+ gatewayEngine,
854
861
  skillRoots: extensionSkillRoots,
855
862
  commandExpansion,
856
863
  eventBus,