@sigrun/sigrun 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -35,12 +35,12 @@
35
35
  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
- import type { AmbientBody, AmbientPlace, AmbientRecord } from "@sigrun/shared/ambient";
38
+ import type { AmbientPlace, AmbientRecord } from "@sigrun/shared/ambient";
39
39
  import type { Action, DataOps, IconName, Tree } from "@sigrun/shared/blocks";
40
40
  import type { InteractionAnswer, InteractionRecord } from "@sigrun/shared/interactions";
41
41
  import type { JobSpec } from "@sigrun/shared/jobs";
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, };
42
+ import type { AccountLinkWire, ConversationSummary, ConversationViewWire, HealthStatus, ModelEntry, SessionMeta, SlashCommand, SubmissionRecordWire, ThinkingLevelWire, UserWire as DirectoryUserWire } from "@sigrun/shared/views";
43
+ export type { Access, AccountLinkWire, AmbientRecord, DirectoryUserWire, InteractionRecord, JobSpec, ModelEntry, Sharing, SlashCommand, };
44
44
  type JsonParams = JsonObject;
45
45
  type JsonParamValue = JsonParams[string];
46
46
  /**
@@ -63,6 +63,8 @@ export interface HandlerContext {
63
63
  */
64
64
  export type RunSettledEvent = {
65
65
  readonly conversationId: ConversationId;
66
+ /** The conversation owner at delivery; absent when the conversation is ownerless. */
67
+ readonly owner?: string;
66
68
  readonly inputs: readonly SubmissionId[];
67
69
  /** Text blocks of each input's entry (`pi.user`, or an expanded input's
68
70
  * `sigrun.user.expanded` — the ADMITTED text), concatenated;
@@ -100,19 +102,24 @@ export type MemoryEventApi = {
100
102
  export type ExtensionLifecyclePayload = {
101
103
  conversationCreated: {
102
104
  readonly conversationId: ConversationId;
105
+ readonly owner?: string;
103
106
  };
104
107
  conversationForked: {
105
108
  readonly childId: ConversationId;
106
109
  readonly parentId: ConversationId;
107
110
  readonly at: EntryId;
111
+ /** The CHILD's owner at delivery; absent when the child is ownerless. */
112
+ readonly owner?: string;
108
113
  };
109
114
  conversationArchived: {
110
115
  readonly conversationId: ConversationId;
111
116
  readonly archived: boolean;
117
+ readonly owner?: string;
112
118
  };
113
119
  conversationIdle: {
114
120
  readonly conversationId: ConversationId;
115
121
  readonly idleMs: number;
122
+ readonly owner?: string;
116
123
  };
117
124
  /** Gateway C6: one terminal interaction transition — the payload mirrors
118
125
  * the record's terminal envelope fields. */
@@ -121,6 +128,11 @@ export type ExtensionLifecyclePayload = {
121
128
  readonly callId: string;
122
129
  readonly kind: string;
123
130
  readonly status: "answered" | "skipped" | "expired" | "cancelled";
131
+ /** The answering user (C5 attribution), when a human answered. */
132
+ readonly answeredByUser?: string;
133
+ /** The STORED (masked-by-construction) answer values — render-safe. */
134
+ readonly answer?: Readonly<Record<string, unknown>>;
135
+ readonly owner?: string;
124
136
  };
125
137
  };
126
138
  /** A factory-result `runSettled` handler: called once per settled run, deadline-bounded. */
@@ -151,6 +163,17 @@ export type SigrunLifecycleHandlers = {
151
163
  readonly harnessShutdown?: ExtensionHarnessShutdownHandler;
152
164
  /** The user-removal transfer event; direct fan-out, not commit-derived. */
153
165
  readonly userDeleted?: ExtensionUserDeletedHandler;
166
+ /**
167
+ * UI-blocks U4 (spec §13.2): called after a settings write COMMITS —
168
+ * the keys are the flattened paths the write touched (core keys in
169
+ * the §13.1 dotted vocabulary, this extension's own top-level keys
170
+ * for a `settings/<slug>.json` write). Fired for BOTH scopes: a
171
+ * server-scope write notifies every loaded extension; an extension's
172
+ * own write notifies it (and every other extension — settings are
173
+ * shared operator surface). Errors are caught and logged; the write
174
+ * already succeeded and stays successful.
175
+ */
176
+ onSettingsChanged?(keys: readonly string[], user?: string): void | Promise<void>;
154
177
  };
155
178
  /**
156
179
  * The lifecycle event names a worker's `wakeOn` may declare.
@@ -182,6 +205,31 @@ export interface ExtensionWorker {
182
205
  /** The work; rejects (or overruns the worker deadline) to fail the run. */
183
206
  run(context: HandlerContext, wake?: WorkerWake): Promise<void>;
184
207
  }
208
+ /** The command and extra environment/read rights for one per-user process. */
209
+ export interface ServiceSpec {
210
+ readonly command: string;
211
+ readonly args: string[];
212
+ readonly env?: Record<string, string>;
213
+ readonly cwd?: string;
214
+ readonly readPaths?: string[];
215
+ }
216
+ /** One factory-declared long-lived per-user service. */
217
+ export interface UserService {
218
+ readonly name: string;
219
+ start(user: string, ctx: HandlerContext): Promise<ServiceSpec | undefined>;
220
+ readonly idleMinutes?: number;
221
+ }
222
+ /** A shared-stdio attachment to a running per-user service. */
223
+ export interface ServiceHandle {
224
+ readonly stdin: WritableStream<Uint8Array>;
225
+ readonly stdout: ReadableStream<Uint8Array>;
226
+ readonly stderr: ReadableStream<Uint8Array>;
227
+ readonly exited: Promise<{
228
+ code: number | null;
229
+ signal: string | null;
230
+ }>;
231
+ release(): void;
232
+ }
185
233
  /** Three-state per-provider readiness: not configured, degraded, or ready. */
186
234
  export type ProviderReadinessStatus = "not_configured" | "degraded" | "ready";
187
235
  /** One readiness report: the status plus optional operator-facing detail. */
@@ -207,6 +255,42 @@ export interface ProviderContribution {
207
255
  /** The per-provider readiness tracker (`/v1/auth/check` reports it verbatim). */
208
256
  readonly readiness?: ReadinessTracker;
209
257
  }
258
+ /** Core-owned state for one external login, with provider-private scratch data. */
259
+ export type LoginFlow = {
260
+ state: string;
261
+ callbackUrl: string;
262
+ data: Record<string, string>;
263
+ };
264
+ /** The identity proof a login provider returns after its callback. */
265
+ export type LoginResult = {
266
+ subject: string;
267
+ profile: {
268
+ username?: string;
269
+ name?: string;
270
+ email?: string;
271
+ emailVerified?: boolean;
272
+ };
273
+ groups?: string[];
274
+ };
275
+ /** One extension identity provider; core owns the flow, state and callback. */
276
+ export interface LoginProviderContribution {
277
+ readonly id: string;
278
+ readonly label: string;
279
+ readonly icon?: string;
280
+ /** Provider-declared provisioning policy, enforced by core. */
281
+ readonly policy?: {
282
+ readonly autoProvision?: boolean;
283
+ readonly allowedGroups?: string[];
284
+ readonly allowedDomains?: string[];
285
+ };
286
+ begin(flow: LoginFlow, ctx: HandlerContext): Promise<{
287
+ redirectUrl: string;
288
+ }>;
289
+ complete(flow: LoginFlow, callback: {
290
+ query: Record<string, string>;
291
+ body?: unknown;
292
+ }, ctx: HandlerContext): Promise<LoginResult>;
293
+ }
210
294
  /** One contributed webui bundle: a selection name plus the bundle root directory. */
211
295
  export interface WebuiBundleContribution {
212
296
  /** The selection key (`server.webui.bundle`); must match `^[a-z][a-z0-9_-]*$`. */
@@ -231,7 +315,9 @@ export interface Principal {
231
315
  readonly admin: boolean;
232
316
  /** True for the `SIGRUN_API_TOKEN` service principal. */
233
317
  readonly service: boolean;
234
- readonly method: "none" | "token" | "proxy" | "host";
318
+ readonly method: "none" | "token" | "session" | "proxy" | "host";
319
+ /** The login provider that issued a session principal. */
320
+ readonly provider?: string;
235
321
  }
236
322
  /** One extension HTTP route, mounted at `/v1/ext/<slug>/<path>`. */
237
323
  export interface ExtensionRouteContribution {
@@ -263,14 +349,18 @@ export interface ExtensionCommandContribution {
263
349
  readonly notice?: string;
264
350
  } | void>;
265
351
  }
266
- /** One notification core delivers to every registered notifier (best-effort). */
267
- export interface NotificationEvent {
268
- readonly type: "interaction.pending" | "job.failed" | "run.settled";
352
+ /** The settled-run outcome: the notification-safe projection of the host
353
+ * event's `outcome`/`reason` (status and reason only — no answer text). */
354
+ export type RunSettledOutcome = {
355
+ readonly status: "answered";
356
+ } | {
357
+ readonly status: "unanswered";
358
+ readonly reason: string;
359
+ };
360
+ /** Fields shared by every notification variant. */
361
+ interface NotificationEventBase {
269
362
  readonly conversationId?: number;
270
363
  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;
274
364
  /** The recipient. */
275
365
  readonly user: string | undefined;
276
366
  readonly title: string;
@@ -278,6 +368,21 @@ export interface NotificationEvent {
278
368
  /** `${server.publicUrl}/#/c/<conversationId>` when `server.publicUrl` is set; absent otherwise. */
279
369
  readonly url?: string;
280
370
  }
371
+ /** One notification core delivers to every registered notifier (best-effort). */
372
+ export type NotificationEvent = NotificationEventBase & ({
373
+ readonly type: "interaction.pending";
374
+ /** Gateway C6: the pending record's id — set on `interaction.pending` only
375
+ * (inspect it via `host.peekInteraction`; the OWNING extension's
376
+ * delivery read is `host.snapshotInteraction`). */
377
+ readonly callId?: string;
378
+ } | {
379
+ readonly type: "job.failed";
380
+ } | {
381
+ readonly type: "run.settled";
382
+ /** Whether the run answered or not (model error, abort, …), with
383
+ * the wire reason verbatim. Never infer success from the body. */
384
+ readonly outcome: RunSettledOutcome;
385
+ });
281
386
  /** One notifier transport (ntfy, Signal, web push — core ships none). */
282
387
  export interface NotifierContribution {
283
388
  readonly name: string;
@@ -564,10 +669,12 @@ export type ActionResult = {
564
669
  };
565
670
  /**
566
671
  * 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).
672
+ * the generic parameter — `"view"`, `"panel"`, or `"settings"` (the
673
+ * §13.3 settings sections adapt to settings-placement surfaces; the
674
+ * host builds those — extensions declare `SettingsSectionDef`s, never
675
+ * `SurfaceDef<"settings">` directly).
569
676
  */
570
- export interface SurfaceDef<P extends "view" | "panel"> {
677
+ export interface SurfaceDef<P extends "view" | "panel" | "settings"> {
571
678
  /** Unique within the extension. */
572
679
  readonly id: string;
573
680
  readonly title: string;
@@ -577,22 +684,51 @@ export interface SurfaceDef<P extends "view" | "panel"> {
577
684
  readonly sharedData?: boolean;
578
685
  /** Channels refetch data on this interval while visible (min 5000). */
579
686
  readonly refreshMs?: number;
580
- /** §7.3; default: canonical JSON of params. */
581
- instanceKey?(params: JsonParams): string;
687
+ /** §7.3; default: canonical JSON of params. The principal argument
688
+ * lets principal-owned surfaces isolate their revision/action identity. */
689
+ instanceKey?(params: JsonParams, principal: Principal): string;
582
690
  render(ctx: SurfaceContext): Promise<SurfaceRenderResult>;
583
691
  data?(ctx: SurfaceContext): Promise<SurfaceDataResult>;
584
692
  readonly actions?: Readonly<Record<string, (ctx: SurfaceContext, payload: JsonParamValue) => Promise<ActionResult>>>;
585
693
  }
586
694
  /**
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).
695
+ * One declared settings section (spec §13.3, verbatim shape): a static
696
+ * `settings`-placement form bound to the extension's settings object
697
+ * (`settings/<slug>.json`). The host adapts it to a surface under the
698
+ * extension's own `/v1/ext/<slug>/surfaces/` mount — core's own
699
+ * sections use the same renderer, under the reserved `core` slug.
700
+ */
701
+ export interface SettingsSectionDef {
702
+ /** Unique within the extension. */
703
+ readonly id: string;
704
+ readonly title: string;
705
+ readonly icon?: IconName;
706
+ /** A root `form` block; the placement is `settings` (spec §5.7). */
707
+ readonly form: Tree;
708
+ /** Declared secrets, rendered as `secret` fields backed by the §7.7 secrets routes. */
709
+ readonly secrets?: readonly {
710
+ key: string;
711
+ label: string;
712
+ help?: string;
713
+ }[];
714
+ /** Only admins see and edit it; default true for shared sections.
715
+ * A user-scope section is visible to every signed-in user unless this
716
+ * is explicitly true. */
717
+ readonly admin?: boolean;
718
+ /** `"user"` reads/writes the calling user's settings; absent/`"server"`
719
+ * keeps the extension's shared settings file. */
720
+ readonly scope?: "server" | "user";
721
+ }
722
+ /**
723
+ * The `surfaces` field of a factory result (spec §7.1): declared views,
724
+ * panels, and settings sections. Ids are unique across ALL THREE lists
725
+ * (one namespace — the loader enforces it).
590
726
  */
591
727
  export interface SurfacesContribution {
592
728
  readonly views?: readonly SurfaceDef<"view">[];
593
729
  readonly panels?: readonly SurfaceDef<"panel">[];
594
- /** §13.3 settings sections — a separate workstream; declared for shape stability. */
595
- readonly settings?: readonly never[];
730
+ /** §13.3 settings sections (UI-blocks U4 Task 3). */
731
+ readonly settings?: readonly SettingsSectionDef[];
596
732
  }
597
733
  /** One wire part of a `host.conversations.submit` input array. */
598
734
  export type SubmissionInputPart = {
@@ -620,8 +756,38 @@ export type WhenBusyMode = NonNullable<InputSubmissionDraft["whenBusy"]>;
620
756
  * factory body.
621
757
  */
622
758
  export interface SigrunHost {
759
+ /**
760
+ * Publish tools for ONE user (multi-user M2). Only factory results that
761
+ * declared `perUserTools: true` may call this; other extensions throw
762
+ * `perUserTools not declared`. An empty list uninstalls the user's entry.
763
+ * Core wraps every tool with a committed-owner check at execute time.
764
+ */
765
+ readonly tools: {
766
+ setUserTools(user: string, tools: readonly import("@earendil-works/pi-durable").ToolRegistration[]): void;
767
+ };
768
+ /** Connect to one declared per-user service; core owns its process lifecycle. */
769
+ readonly services: {
770
+ connect(name: string, user: string, ctx: HandlerContext): Promise<ServiceHandle>;
771
+ };
623
772
  /** The scoped extension-stores handle (your secrets/settings sections + raw data/cache/state paths). */
624
773
  readonly stores: {
774
+ /** One directory user's secrets/settings/paths, still scoped to your slug. */
775
+ user(user: string): {
776
+ readonly secrets: {
777
+ get(key: string): Promise<string | undefined>;
778
+ set(key: string, value: string): Promise<void>;
779
+ delete(key: string): Promise<void>;
780
+ };
781
+ readonly settings: {
782
+ get(): Promise<Record<string, unknown>>;
783
+ set(value: Record<string, unknown>): Promise<void>;
784
+ };
785
+ readonly paths: {
786
+ readonly data: string;
787
+ readonly state: string;
788
+ readonly cache: string;
789
+ };
790
+ };
625
791
  /** Your section of secrets.json. */
626
792
  readonly secrets: {
627
793
  get(key: string): Promise<string | undefined>;
@@ -653,7 +819,22 @@ export interface SigrunHost {
653
819
  }): Promise<boolean>;
654
820
  /** Mark a pending interaction expired/cancelled/skipped (extension-owned semantics). */
655
821
  settleInteraction(conversationId: ConversationId, callId: string, status: "skipped" | "expired" | "cancelled", context: HandlerContext): Promise<boolean>;
656
- /** Read one interaction record (the poll loop's read). */
822
+ /**
823
+ * Inspect one interaction record WITHOUT consuming anything: returns
824
+ * the same stored projection as {@link SigrunHost.snapshotInteraction}
825
+ * (secret values stay `SECRET_MASK`), but never touches the one-shot
826
+ * secret-delivery `take` path. Safe to repeat; neither extends nor
827
+ * shortens secret TTL; creates, settles, and mutates nothing. Use for
828
+ * inspection/rendering (notifiers, outcome edits, correlation).
829
+ */
830
+ peekInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
831
+ /**
832
+ * The OWNING extension's delivery read: the first applicable read of
833
+ * an answered secret record merges its raw secret values back ONCE;
834
+ * subsequent reads see the stored mask. Inspection-only callers must
835
+ * use {@link SigrunHost.peekInteraction} instead — a consuming read
836
+ * by a non-owner silently destroys the owner's one-shot delivery.
837
+ */
657
838
  snapshotInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
658
839
  /** Fully replace one ambient surface's state (write = replacement); the key must be unowned or yours. */
659
840
  setAmbient(conversationId: ConversationId, record: {
@@ -662,7 +843,7 @@ export interface SigrunHost {
662
843
  readonly maxRows?: number;
663
844
  readonly overflow?: "drop" | "wrap" | "summarize";
664
845
  readonly refreshMs?: number;
665
- readonly body?: AmbientBody;
846
+ readonly body?: Tree;
666
847
  readonly details?: JsonObject;
667
848
  }, context: HandlerContext): Promise<void>;
668
849
  /** Clear one ambient surface (empty replacement; keeps ownership). */
@@ -729,6 +910,8 @@ export interface SigrunHost {
729
910
  configure(id: ConversationId, opts: {
730
911
  readonly model?: string;
731
912
  readonly thinking?: ThinkingLevelWire;
913
+ /** Exact extension selection; another user's `<slug>@<user>` entry is rejected. */
914
+ readonly extensions?: readonly string[];
732
915
  }): Promise<void>;
733
916
  /** Admit one manual compaction task (gateway C4, the compact route's op). */
734
917
  compact(id: ConversationId): Promise<void>;
@@ -782,6 +965,25 @@ export interface SigrunHost {
782
965
  list(): Promise<readonly DirectoryUserWire[]>;
783
966
  get(user: string): Promise<DirectoryUserWire | undefined>;
784
967
  };
968
+ /**
969
+ * External identities linked to directory users (multi-user §11.3): the
970
+ * one table for chat senders (`chat:<channel>`) and login subjects
971
+ * (`login:<slug>:<id>`), shared with the gateway.
972
+ *
973
+ * Core owns the file — extensions go through this surface, never
974
+ * `accounts.json` directly. One link per `(provider, subject)`: a
975
+ * re-link to the same user is a no-op returning the existing record, a
976
+ * link to a DIFFERENT user rejects with an `Error` whose message starts
977
+ * `account_link_conflict:` (the message never names the current
978
+ * holder — extensions match the prefix, as they do `command_not_found:`).
979
+ * An unknown user rejects `unknown user "<user>"`.
980
+ */
981
+ readonly accounts: {
982
+ lookup(provider: string, subject: string): Promise<AccountLinkWire | undefined>;
983
+ link(provider: string, subject: string, user: string, via: AccountLinkWire["via"]): Promise<AccountLinkWire>;
984
+ unlink(provider: string, subject: string): Promise<boolean>;
985
+ listForUser(user: string): Promise<readonly AccountLinkWire[]>;
986
+ };
785
987
  /**
786
988
  * Resolve one principal's access to one shared resource (the whole
787
989
  * sharing contract for extension-owned records — core stores nothing
@@ -841,12 +1043,18 @@ export interface SigrunHost {
841
1043
  * begins reading each when its task lands.
842
1044
  */
843
1045
  export interface SigrunExtensionResult extends SigrunLifecycleHandlers {
1046
+ /** Long-lived per-user sibling processes, launched and supervised by core. */
1047
+ readonly userServices?: readonly UserService[];
1048
+ /** Declare the per-user tool surface (`host.tools.setUserTools`). */
1049
+ readonly perUserTools?: boolean;
844
1050
  /** The pi-durable `Extension` to install; its declared name is the slug source. */
845
1051
  readonly extension?: Extension;
846
1052
  /** The contributor-only identity (required when contributing without an `extension`). */
847
1053
  readonly name?: string;
848
1054
  /** Provider contributions, merged into the boot model catalog (last-registered-wins, collisions recorded). */
849
1055
  readonly providers?: readonly ProviderContribution[];
1056
+ /** External identity providers; core qualifies ids as `login:<slug>:<id>`. */
1057
+ readonly loginProviders?: readonly LoginProviderContribution[];
850
1058
  /** Webui-bundle contributions; each `dir` anchors at the extension ENTRY's directory. */
851
1059
  readonly webuiBundles?: readonly WebuiBundleContribution[];
852
1060
  /** The surface's health producer, aggregated into `/v1/meta` `health` (any `"degraded"` degrades). */
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The ambient seam (spec: docs/approval-ux-seams.md, "Ambient surfaces"):
3
- * core-owned, channel-neutral records for persistent read-mostly state —
4
- * statusline slots and panel widgets — written by extensions through the
5
- * host context and rendered by channels generically.
2
+ * The ambient seam (spec: docs/approval-ux-seams.md, "Ambient surfaces";
3
+ * the block mapping: UI-blocks design §9.2): core-owned,
4
+ * channel-neutral records for persistent read-mostly state — statusline
5
+ * slots and panel widgets — written by extensions through the host
6
+ * context and rendered by channels generically.
6
7
  *
7
8
  * SPLIT (build-and-webui Task 1): the canonical TYPE declarations live in
8
9
  * the `@sigrun/shared` package (`shared/src/ambient.ts` — the records ride
@@ -16,10 +17,22 @@
16
17
  * not silent composition — the pi-zentui chrome-skin lesson).
17
18
  *
18
19
  * Layering mirrors the interaction seam: strict envelope (key, place,
19
- * budget, refresh, expires) around a body composed only from the closed
20
- * ambient primitives (line, rows, gauge, ledger), with opaque `details`.
20
+ * budget, refresh, expires) around a block-tree body (placement
21
+ * `ambient`; `status`/`footer` allow one line), with opaque `details`.
22
+ * Legacy-shaped bodies FAIL with paths (ruling 16).
21
23
  */
22
24
  import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable";
25
+ import { validateTree } from "../blocks/validate.js";
26
+ /**
27
+ * Narrow a stored record to the public wire record. SAFETY: the two
28
+ * shapes are identical at runtime — a stored body passed `validateTree`
29
+ * (placement `ambient`) at write time, and a validated tree IS the JSON
30
+ * the mirror describes; the cast only restores the type-level naming
31
+ * `JsonObject`-erasure removed.
32
+ */
33
+ export function asAmbientRecord(value) {
34
+ return value;
35
+ }
23
36
  /**
24
37
  * The ambient record family: one record per surface key, per conversation.
25
38
  * (Session-scoped statuslines that outlive one conversation are a v2
@@ -48,9 +61,12 @@ export const AmbientOwnersDoc = defineDoc({
48
61
  });
49
62
  const PLACES = new Set(["status", "footer", "panel"]);
50
63
  const OVERFLOWS = new Set(["drop", "wrap", "summarize"]);
51
- const FORMATS = new Set(["compact", "usd", "percent"]);
52
- const ZONES = new Set(["left", "middle", "right"]);
53
- /** Structural validation of one ambient record. Returns the error text or null. */
64
+ /**
65
+ * Structural validation of one ambient record: the envelope's own checks,
66
+ * then the body tree through `validateTree({placement: "ambient"})` with
67
+ * the one-line rule for `status`/`footer` (§9.2). Returns the error text
68
+ * (with the tree paths) or null.
69
+ */
54
70
  export function validateAmbient(value) {
55
71
  if (typeof value !== "object" || value === null) {
56
72
  return "ambient record must be an object";
@@ -80,182 +96,20 @@ export function validateAmbient(value) {
80
96
  const body = r.body;
81
97
  if (body === undefined)
82
98
  return null; // an empty write clears the surface
83
- if (typeof body !== "object" || body === null) {
84
- return "body must be an object";
85
- }
86
- const b = body;
87
- const known = new Set(["line", "rows", "gauge", "ledger"]);
88
- const unknownKey = Object.keys(b).find((k) => !known.has(k));
89
- if (unknownKey !== undefined) {
90
- return `body.${unknownKey} is outside the closed primitives (move it to details)`;
91
- }
92
- if (b.line !== undefined) {
93
- const problem = validateLine(b.line);
94
- if (problem !== null)
95
- return problem;
96
- }
97
- if (b.rows !== undefined) {
98
- if (!Array.isArray(b.rows))
99
- return "body.rows must be an array";
100
- for (const row of b.rows) {
101
- const problem = validateRow(row);
102
- if (problem !== null)
103
- return problem;
104
- }
105
- }
106
- if (b.gauge !== undefined) {
107
- const problem = validateGauge(b.gauge);
108
- if (problem !== null)
109
- return problem;
110
- }
111
- if (b.ledger !== undefined) {
112
- if (!Array.isArray(b.ledger))
113
- return "body.ledger must be an array";
114
- for (const e of b.ledger) {
115
- const problem = validateLedgerEntry(e);
116
- if (problem !== null)
117
- return problem;
118
- }
119
- }
120
- return null;
121
- }
122
- function validateLine(value) {
123
- if (typeof value !== "object" || value === null)
124
- return "line must be an object";
125
- const l = value;
126
- if (l.text === undefined && l.segments === undefined) {
127
- return "line needs text or segments";
128
- }
129
- if (l.text !== undefined && typeof l.text !== "string") {
130
- return "line.text must be a string";
131
- }
132
- if (l.segments !== undefined) {
133
- if (!Array.isArray(l.segments))
134
- return "line.segments must be an array";
135
- for (const s of l.segments) {
136
- if (typeof s !== "object" || s === null)
137
- return "segment must be an object";
138
- const seg = s;
139
- if (typeof seg.id !== "string" || seg.id.length === 0) {
140
- return "segment.id must be a non-empty string";
141
- }
142
- if (typeof seg.text !== "string")
143
- return "segment.text must be a string";
144
- if (seg.place !== undefined && !ZONES.has(String(seg.place))) {
145
- return `segment.place must be one of left|middle|right (got ${String(seg.place)})`;
146
- }
147
- }
148
- }
149
- return null;
150
- }
151
- function validateRow(value) {
152
- if (typeof value !== "object" || value === null)
153
- return "row must be an object";
154
- const r = value;
155
- if (typeof r.id !== "string" || r.id.length === 0) {
156
- return "row.id must be a non-empty string";
157
- }
158
- if (typeof r.label !== "string")
159
- return "row.label must be a string";
160
- if (r.status !== undefined && typeof r.status !== "string") {
161
- return "row.status must be a string";
162
- }
163
- if (r.severity !== undefined &&
164
- r.severity !== 0 &&
165
- r.severity !== 1 &&
166
- r.severity !== 2) {
167
- return "row.severity must be 0, 1, or 2";
168
- }
169
- if (r.quiet !== undefined && typeof r.quiet !== "boolean") {
170
- return "row.quiet must be a boolean";
171
- }
172
- if (r.detail !== undefined && typeof r.detail !== "string") {
173
- return "row.detail must be a string";
174
- }
175
- if (r.depth !== undefined && typeof r.depth !== "number") {
176
- return "row.depth must be a number";
177
- }
178
- if (r.deps !== undefined && !Array.isArray(r.deps)) {
179
- return "row.deps must be an array of ids";
180
- }
181
- if (Array.isArray(r.deps) && r.deps.some((d) => typeof d !== "string")) {
182
- return "row.deps must be an array of ids";
183
- }
184
- if (r.spinnerLabel !== undefined && typeof r.spinnerLabel !== "string") {
185
- return "row.spinnerLabel must be a string";
186
- }
187
- if (r.metrics !== undefined) {
188
- const problem = validateMetrics(r.metrics);
189
- if (problem !== null)
190
- return problem;
191
- }
192
- return null;
193
- }
194
- function validateMetrics(value) {
195
- if (typeof value !== "object" || value === null)
196
- return "metrics must be an object";
197
- const m = value;
198
- for (const k of ["tokens", "cost", "current", "total", "completed"]) {
199
- if (m[k] !== undefined && typeof m[k] !== "number") {
200
- return `metrics.${k} must be a number`;
201
- }
202
- }
203
- if (m.format !== undefined && !FORMATS.has(String(m.format))) {
204
- return `metrics.format must be one of compact|usd|percent (got ${String(m.format)})`;
205
- }
206
- return null;
207
- }
208
- function validateGauge(value) {
209
- if (typeof value !== "object" || value === null)
210
- return "gauge must be an object";
211
- const g = value;
212
- if (typeof g.label !== "string")
213
- return "gauge.label must be a string";
214
- if (g.percent !== undefined && typeof g.percent !== "number") {
215
- return "gauge.percent must be a number";
216
- }
217
- if (g.phase !== undefined && typeof g.phase !== "string") {
218
- return "gauge.phase must be a string";
219
- }
220
- if (g.elapsedMs !== undefined && typeof g.elapsedMs !== "number") {
221
- return "gauge.elapsedMs must be a number";
222
- }
223
- if (g.recentOutput !== undefined && !Array.isArray(g.recentOutput)) {
224
- return "gauge.recentOutput must be an array of strings";
225
- }
226
- if (Array.isArray(g.recentOutput) && g.recentOutput.some((l) => typeof l !== "string")) {
227
- return "gauge.recentOutput must be an array of strings";
228
- }
229
- if (g.checks !== undefined && !Array.isArray(g.checks)) {
230
- return "gauge.checks must be row-shaped";
231
- }
232
- if (Array.isArray(g.checks)) {
233
- for (const row of g.checks) {
234
- const problem = validateRow(row);
235
- if (problem !== null)
236
- return `gauge.checks: ${problem}`;
237
- }
238
- }
239
- return null;
240
- }
241
- function validateLedgerEntry(value) {
242
- if (typeof value !== "object" || value === null)
243
- return "ledger entry must be an object";
244
- const e = value;
245
- if (typeof e.id !== "string" || e.id.length === 0) {
246
- return "ledger.id must be a non-empty string";
247
- }
248
- if (typeof e.at !== "number")
249
- return "ledger.at must be a number";
250
- if (typeof e.kind !== "string")
251
- return "ledger.kind must be a string";
252
- if (typeof e.summary !== "string")
253
- return "ledger.summary must be a string";
254
- if (e.detail !== undefined && !Array.isArray(e.detail)) {
255
- return "ledger.detail must be an array of strings";
256
- }
257
- if (Array.isArray(e.detail) && e.detail.some((d) => typeof d !== "string")) {
258
- return "ledger.detail must be an array of strings";
99
+ // Ruling 16 (no compatibility layer): legacy-shaped bodies (objects
100
+ // keyed line/rows/gauge/ledger rather than {v, root}) fail HERE, with
101
+ // the path — a tree is {v, root}.
102
+ const result = validateTree(body, {
103
+ placement: "ambient",
104
+ ambientPlace: r.place,
105
+ });
106
+ if (!result.ok) {
107
+ const first = result.errors[0];
108
+ if (first === undefined)
109
+ return "body must be a block tree";
110
+ // The tree-relative path joins the record's field ("body.root…")
111
+ // so the error names where the record is wrong, not just the tree.
112
+ return `body is not a valid ambient tree: body.${first.path} ${first.message}`;
259
113
  }
260
114
  return null;
261
115
  }