@sigrun/sigrun 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-10-28
11
+
12
+ ### Added
13
+ - `GET /v1/meta` now reports the effective listener: `listener.host`,
14
+ `listener.port`, and `listener.families` — the address families the
15
+ live listener accepts, measured once at listen (the `::` wildcard
16
+ probes both loopbacks and reports exactly the families that connect;
17
+ a hostname bind reports the bound socket's family, never a concrete
18
+ peer address). Additive wire field: pre-upgrade servers omit
19
+ `listener`; clients consuming it must tolerate its absence.
20
+ - `SIGRUN_LISTEN_HOST` is now honored as the environment equivalent of
21
+ the programmatic `serve({host})` option. One host value is resolved
22
+ at the serving boundary (`options.host` → `SIGRUN_LISTEN_HOST` → `::`)
23
+ and drives the bind AND both auth derivations — the previously split
24
+ defaults (the boot auth derivation's independent env read, and the
25
+ harness-side `0.0.0.0` fallback) are gone.
26
+ - Proxy-auth rejections (401) now log one sanitized `warn` per rejected
27
+ request: the socket peer, the method, a short reason (`untrusted
28
+ proxy`, `missing or wrong shared secret`, `missing identity header`,
29
+ `untrusted identity source`), and the resolved route template — never
30
+ tokens, cookies, identity-header values, query strings, or the full
31
+ URL. Token-auth failures and successful proxy authentications stay
32
+ silent.
33
+ - The normal core build now emits real `dist/blocks/**/*.d.ts`
34
+ declarations, including the `dist/blocks/index.d.ts` named by the
35
+ `@sigrun/sigrun/blocks` export. Pack/type-resolution coverage compiles
36
+ an external consumer against those declarations; extension consumers
37
+ no longer need an ambient block-module shim.
38
+
39
+ ### Fixed
40
+ - Successful `PUT /v1/me/extensions/:slug/settings` writes now publish
41
+ through the same post-commit settings-change seam as section saves:
42
+ one per-user failure-fence clear, one observing-extension reconciliation,
43
+ and the extension slug plus changed body keys. Invalid, unknown, and
44
+ otherwise rejected writes publish nothing; repeated successful writes
45
+ retain the existing notification semantics.
46
+ - `host.peekInteraction(conversationId, callId, context)`: a
47
+ non-consuming interaction inspection read beside
48
+ `snapshotInteraction`. It returns the same stored record projection
49
+ (secret values stay masked) but NEVER touches the one-shot raw-secret
50
+ delivery — safe to repeat for notifiers, outcome edits, and answer
51
+ correlation. `snapshotInteraction` remains the ONLY read that merges
52
+ and consumes an unread delivery, reserved for the interaction's
53
+ owning extension; its first-read/second-read semantics are unchanged.
54
+ - `NotificationEvent` is now a discriminated union (shared base plus
55
+ `interaction.pending` / `job.failed` / `run.settled` variants), and
56
+ the `run.settled` variant carries a structured
57
+ `outcome: { status: "answered" } | { status: "unanswered"; reason: string }`
58
+ translated directly from the host event's existing `outcome`/`reason`
59
+ (no second answer read, no inferred status). The other variants do
60
+ not gain the field; exhaustive narrowing on `type` now typechecks.
61
+
62
+ ### Fixed (continued)
63
+ - Restored per-user worker startup by placing the selected device paths
64
+ under read-only Landlock rules. Their ordinary I/O works, while
65
+ `/dev/shm` and writes outside workspace/home/tmp remain denied.
66
+ - Preserved composer text and attachments when a slow conversation-meta
67
+ restore resolves after a local edit or clear. The interaction visual
68
+ drain now covers required secrets and multi-step forms, and the visual
69
+ e2e gate passes all 163 cases.
70
+
71
+ ### Changed
72
+ - Settled-run notifications are truthful: an answered run keeps the
73
+ `"Reply ready"` title, while an unanswered run (model error, abort,
74
+ or a missing answer) is titled `"Run ended without a reply"` with the
75
+ settle reason appended to the body — a failed run no longer
76
+ masquerades as a ready reply. Recipient, dedup, and
77
+ `notifications.runSettled` gating are unchanged.
78
+
10
79
  ## [0.2.0] - 2026-10-27
11
80
  ### Added
12
81
  - Per-user extension surface (multi-user M2): lifecycle events carry the
@@ -58,11 +127,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
58
127
  literal-content ruling.
59
128
  - Images (U7): an HMAC-signed proxy, a rebinding-safe fetcher, an LRU cache
60
129
  with `sharp` conversion, and CSP headers.
61
- - Core settings sections rebuilt on blocks (U4): one settings renderer for
62
- webui and TUI, extension-declared sections, a `/v1/settings` sections
63
- route, `pendingRestart` with an admin restart route, live/restart
64
- metadata for every core key, label search, and
65
- `settings.changed`/`onSettingsChanged`.
66
130
 
67
131
  ### Changed
68
132
  - Family versions move together to 0.2.0: `@sigrun/shared` 0.2.0
@@ -91,6 +155,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
91
155
  distinguish Landlock denials from Yama or missing-capability conditions.
92
156
 
93
157
  ## [0.1.7] - 2026-10-24
158
+
159
+ *Correction (2026-10-28): the original entry omitted substantial scope
160
+ that shipped in this release — the U4 settings rebuild, the M3a
161
+ account-link store and routes, and the accompanying `@sigrun/shared` /
162
+ `@sigrun/tui` settings-surface work. Those entries are restored below;
163
+ the Service-migration claim is also corrected (adding a secondary
164
+ family while retaining the primary is an in-place patch, not a
165
+ recreate).*
166
+
167
+ ### Added
168
+ - Core settings sections rebuilt on blocks (U4): one settings renderer
169
+ for webui and TUI, extension-declared sections, a `/v1/settings`
170
+ sections route, `pendingRestart` with an admin restart route,
171
+ live/restart metadata for every core key, label search, and
172
+ `settings.changed`/`onSettingsChanged`. The shared and TUI packages
173
+ carry the matching settings-surface and settings-form work (live
174
+ surface revs, default-on toggle handling, roster-driven tool
175
+ defaults, walk-based search).
176
+ - Account links (multi-user M3a): the core account-link store plus
177
+ routes to list and remove an account's linked external identities.
178
+
94
179
  ### Fixed
95
180
  - The deploy entry binds the dual-stack wildcard (`::`) instead of the
96
181
  IPv4-only `0.0.0.0`. On a dual-stack pod whose Service is IPv6-only
@@ -107,8 +192,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107
192
  ### Changed
108
193
  - The chart's main Service is `PreferDualStack` over `[IPv6, IPv4]`,
109
194
  so it advertises both pod addresses regardless of the cluster's
110
- default family. Converting an existing `SingleStack` Service is a
111
- recreate (`spec.clusterIPs` is immutable).
195
+ default family. Adding a secondary family while retaining the
196
+ primary is an in-place patch (`spec.ipFamilies` gains the new
197
+ family); only changing the PRIMARY family requires recreation
198
+ (`spec.clusterIPs` is immutable). *Corrected 2026-10-28: the
199
+ original entry claimed every conversion is a recreate.*
112
200
  - `sigrun run` (workstation mode) still binds `127.0.0.1` only.
113
201
 
114
202
  ## [0.1.6] - 2026-10-24
package/README.md CHANGED
@@ -1,11 +1,28 @@
1
1
  # Sigrun
2
2
 
3
- Sigrun is a durable personal-assistant harness built on
4
- [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable):
5
- long-lived conversations over a SQLite store (surviving restarts, with
6
- resumable generation), an HTTP API plus web and terminal UIs, and an
7
- extension system that installs extra tools, providers, prompt sections,
8
- and capabilities at boot.
3
+ Sigrun is a **durable, multi-user personal-assistant server** built on
4
+ [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable).
5
+ One agent loop serves every surface — a web UI, a terminal UI, an HTTP
6
+ API, and (via the gateway extension) chat channels — over long-lived
7
+ conversations stored in SQLite.
8
+ An extension model installs extra tools, providers, prompt sections,
9
+ memory systems, and login providers at boot.
10
+
11
+ **Why use it?** Conversations survive restarts and resume where they
12
+ stopped; memory and integrations are extension-owned and individually
13
+ replaceable; multi-user boundaries are explicit (real authentication plus
14
+ a per-user execution jail); and it deploys as an npm package, a Docker
15
+ compose reference, or a Helm chart. [`docs/why.md`](docs/why.md) is the
16
+ longer rationale.
17
+
18
+ **Status:** 0.x — useful and exercised, but extension and settings
19
+ contracts can still evolve between minor releases. See
20
+ [`SECURITY.md`](SECURITY.md) for the support, trust, and verification
21
+ posture, and [`CONTRIBUTING.md`](CONTRIBUTING.md) to build and test.
22
+
23
+ **Try it:** `npm i -g @sigrun/sigrun && sigrun run` — or jump to
24
+ [Install](#install) and [Quickstart](#quickstart) for the model/credential
25
+ setup every install needs.
9
26
 
10
27
  - **Durable** — conversations, submissions, and schedules live in one
11
28
  SQLite database (`SIGRUN_STORAGE_PATH`); a restart resumes where it
@@ -156,6 +173,7 @@ still override it per field.
156
173
  | `OPENAI_API_KEY` | string | — | `auth.json` (see below) | Credential for the env-driven OpenAI-compatible provider; the `auth.json` fallback after file entries. |
157
174
  | `OPENAI_BASE_URL` | URL | OpenAI | `defaultProvider.baseUrl` | Endpoint for the env-driven provider (any compatible host). |
158
175
  | `SIGRUN_PORT` | positive int | `3000` | `server.port` | HTTP listen port. |
176
+ | `SIGRUN_LISTEN_HOST` | host | `::` | — | HTTP listen host — the environment equivalent of the programmatic `serve({host})` option (the option wins when both are set). One resolved value drives the bind AND the auth defaults: a loopback bind derives the no-auth workstation posture, any other bind derives `token` auth. `GET /v1/meta` reports the effective listener (`listener.host`/`port`/`families`). |
159
177
  | `SIGRUN_STORAGE_PATH` | path | `<dataDir>/sigrun.db` | `server.storagePath` | SQLite database location. Relative values resolve against the data root (absolute values verbatim; the cwd-relative `data/sigrun.db` default is retired). |
160
178
  | `SIGRUN_WORKSPACE_PATH` | path | `<dataDir>/workspace` | `server.workspacePath` | Root of the static local workspace every conversation's tools run against. The default rides the data root (chart/compose deploys set the path explicitly); it is never persisted on first boot. |
161
179
  | `SIGRUN_DATA_DIR` | path | `$XDG_DATA_HOME/sigrun` or `~/.local/share/sigrun` | `server.dataDir` | Data root (the DB default and extension data dirs). Env/file values win verbatim. |
@@ -324,9 +342,9 @@ needed. Set `"builtinExtensions": {"oauth": false}` to disable it; like the
324
342
  other built-in toggles, this is restart-required. A configured extension
325
343
  whose package name is also `@sigrun/oauth` replaces the built-in. If the
326
344
  built-in cannot be resolved or loaded, it is quarantined without aborting
327
- boot. It remains separately installable through the normal `extensions`
328
- list (see [Extensions](#extensions)) for a deployment that wants it without
329
- the built-in. It is inert when its settings contain no valid providers.
345
+ boot. Standalone installation outside the built-in is not a supported use
346
+ case in 0.2; the package's type-only core dependency posture is accepted
347
+ for this release. It is inert when its settings contain no valid providers.
330
348
 
331
349
  Create an OAuth/OIDC client at the identity service, with the redirect URI
332
350
  shown below. Configure providers in the extension's
@@ -556,8 +574,72 @@ identity-switch capabilities once (replace `<helper>` with its path):
556
574
  sudo setcap cap_setuid,cap_setgid+ep <helper>
557
575
  ```
558
576
 
559
- The helper's boot self-test verifies the switch. This mapping is unavailable
560
- in containers, whose entrypoint drops every capability.
577
+ The helper's boot self-test verifies the switch. `SIGRUN_SANDBOX_HELPER`
578
+ points sigrun at a helper binary at a non-default absolute path (it falls back
579
+ to the installed location with one warning if the path does not exist), which is
580
+ how a staged copy gets used.
581
+
582
+ File capabilities only take effect when the capabilities they grant are
583
+ already in the **caller's** bounding set: executing the capped helper needs
584
+ `CAP_SETUID` and `CAP_SETGID` present in the process that launches it —
585
+ `CAP_SETFCAP` alone only permits writing the xattr and is not enough. A root
586
+ shell, or a host that runs sigrun as root and maps users to real OS accounts,
587
+ normally satisfies this.
588
+
589
+ This mapping is bare-metal only. The container entrypoint starts as the image's
590
+ non-root `USER` and execs straight through (one uid for the whole container), so
591
+ it holds no identity-switch capabilities to inherit — containers use the
592
+ Landlock jail alone, which is where container multi-user isolation comes from.
593
+ A root container start (`SIGRUN_UID=0`) does exist, but it is a mount-point
594
+ ownership trampoline, not an `osUsers` enabler: it needs
595
+ `CAP_CHOWN`/`CAP_SETUID`/`CAP_SETGID` for the chown-and-drop and still leaves
596
+ you with a single container uid.
597
+
598
+ #### If you insist on `osUsers` in a container — and why you shouldn't
599
+
600
+ It is possible, and two of the three pieces need no image change. A capped
601
+ helper can be staged at runtime on any writable path (a named volume, a
602
+ `tmpfs`, a bind mount), and the caps come from the container's own flags:
603
+
604
+ ```yaml
605
+ # the runtime half — no derived image required
606
+ services:
607
+ sigrun:
608
+ user: "1000:1000"
609
+ cap_add: [SETUID, SETGID] # bounding set for the helper's file caps
610
+ volumes:
611
+ - sigrun-data:/data
612
+ - helper:/opt/helper # a writable path for the capped copy
613
+ # then, once per volume: copy the helper in, chmod 755, setcap
614
+ # cap_setuid,cap_setgid+ep under --cap-add SETFCAP, and point
615
+ # SIGRUN_SANDBOX_HELPER at it
616
+ security_opt:
617
+ - no-new-privileges:false # REQUIRED — see below
618
+ ```
619
+
620
+ The blocker is the third piece: **`osUser` maps to a login name**, resolved from
621
+ `/etc/passwd`, so every user you want needs an account. The image ships exactly
622
+ two non-root accounts (`node` and `nobody`). That is what forces either a
623
+ derived image (`RUN useradd …` per user) or mounting a `passwd` file over
624
+ `/etc/passwd` through a `subPath`, which is fragile and surprises tooling.
625
+
626
+ And the default posture actively prevents it, three ways: `read_only: true`
627
+ makes `setcap` on the image layer fail with "Read-only file system",
628
+ `cap_drop: ALL` grants nothing, and `no-new-privileges: true` suppresses the
629
+ file-capability gain on `execve` even when `SETUID`/`SETGID` are in the bounding
630
+ set (the helper dies at its first privileged call). Enabling this therefore
631
+ means adding capabilities **and** permitting privilege escalation — a
632
+ deliberate threat-model relaxation, not a configuration tweak.
633
+
634
+ The reason not to is structural rather than configurational: inside one
635
+ container every `osUser` shares **one filesystem and one mount namespace**. You
636
+ would gain per-process uids and Landlock's per-process confinement, but not the
637
+ kernel-level user separation a host provides — because there is only one
638
+ container boundary to separate. You would be paying a real security cost (an
639
+ identity-switch capability held by the main process, privilege escalation
640
+ permitted) for isolation the container boundary already substantially provides.
641
+ Use the Landlock jail alone in containers; use `osUsers` on bare metal, where
642
+ the accounts and the separation are real.
561
643
 
562
644
  ### Kernel integration runner controls
563
645
 
@@ -790,7 +872,7 @@ a dashboard) are in
790
872
 
791
873
  ## Built-in extensions
792
874
 
793
- Four extensions are built into core — **codemode**, **MCP**, **tool
875
+ Five extensions are built into core — **codemode**, **MCP**, **tool
794
876
  search**, **gateway**, and **OAuth login**. The first three mirror
795
877
  what pi 1.3.0 ships built-in; the gateway is sigrun's own channel-adapter
796
878
  engine; OAuth provides OIDC, GitHub, and Discord login providers. They
@@ -833,6 +915,11 @@ default. They join the offered set when an enabled MCP server's
833
915
  The MCP servers themselves live in the MCP config file — the
834
916
  `@sigrun/ext-mcp` package's README documents its format, including each
835
917
  server's `exposure` and the `SIGRUN_MCP_CONFIG` override.
918
+ **User-supplied MCP servers are remote-only in 0.2** (URL transport);
919
+ a user-supplied stdio server fails loudly with a config error. Dynamic
920
+ jailed per-user stdio services are a post-release feature — per-user
921
+ services are declared statically by extensions (see
922
+ docs/extending.md).
836
923
 
837
924
  ```jsonc
838
925
  {
@@ -0,0 +1,13 @@
1
+ {
2
+ "algorithm": "sha256",
3
+ "sources": {
4
+ "shared/src/blocks/index.ts": "ca37dbb56a4a1a05a40a0125a06d864b9a0671c70817c83a44dd0e3962999e30",
5
+ "src/blocks/builders.ts": "a0c9835aaeab871bc2548d4b8d4426849c60cdb0cb1b20142d3bbe11e8377005",
6
+ "src/blocks/images.ts": "2e1bd54546c9c1c5bf0cfe9b488aaffd7c37979ef84bae9789168275e63e202f",
7
+ "src/blocks/index.ts": "8878a2e2269572ebdecca2cf3b95fa67f144232d8a8fe4258b120948064d0520",
8
+ "src/blocks/limits.ts": "1e913f29167114e26bed6f8c1fd04c64b119ee1db3f4e9e2ae161ddf70578683",
9
+ "src/blocks/placements.ts": "fccda42669713d91dc78d025268ee127f926891cf383727c1e235a39b9f15c6f",
10
+ "src/blocks/schema.ts": "27dbc5cdf1152a16a75bfb1a70383f1a456999a41d393308c05079b41f087a83",
11
+ "src/blocks/validate.ts": "31b9fc78f569cceff229bd1f28bfc62e2f8da437684100e051104f059a1ca19f"
12
+ }
13
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The typed builders (spec §14.1): one function per block and field,
3
+ * plus `bind(pointer)` and `tree(root)`. Builders return plain JSON —
4
+ * there is no runtime besides them.
5
+ *
6
+ * Each builder takes its block's props object and returns the block
7
+ * with the `type` literal stamped in. The literal is spread LAST, so a
8
+ * stray `type` inside a JavaScript caller's props object can never
9
+ * override it; TypeScript callers cannot pass one at all (`Props` omits
10
+ * it). The tree BLOCK builder is exported as `treeBlock`, keeping the
11
+ * `tree` name for the version-stamping root wrapper.
12
+ *
13
+ * `tree(root)` stamps {@link BLOCK_SET_VERSION} from the shared wire
14
+ * package — the version the SERVER validates against, which is why
15
+ * extensions import these builders from `@sigrun/sigrun/blocks` (the
16
+ * host-linked copy, spec §14.1) instead of bundling their own.
17
+ */
18
+ import type { Binding, Block, CalloutBlock, Card, ChartBlock, ChoiceField, CollectionBlock, ColumnsBlock, DateField, DaterangeField, DatetimeField, DetailsBlock, DiffBlock, FieldsBlock, FileField, FormBlock, GridBlock, ImageBlock, ListField, LogBlock, LongtextField, MarkdownBlock, MeterBlock, NumberField, PageBlock, SectionBlock, SecretField, StackBlock, StatBlock, TextField, ToggleField, Tree, TreeBlock } from "@sigrun/shared/blocks";
19
+ /** One block/field's props: the wire shape minus the `type` literal. */
20
+ type Props<T> = Omit<T, "type">;
21
+ /** `bind("/cards")` → `{ $: "/cards" }` — a pointer into the surface's data model (§4.2). */
22
+ export declare function bind(pointer: string): Binding;
23
+ /** Wrap a root block with the server's block-set version (§4.4). */
24
+ export declare function tree(root: Block): Tree;
25
+ export declare function page(props: Props<PageBlock>): PageBlock;
26
+ export declare function stack(props: Props<StackBlock>): StackBlock;
27
+ export declare function columns(props: Props<ColumnsBlock>): ColumnsBlock;
28
+ export declare function grid(props: Props<GridBlock>): GridBlock;
29
+ export declare function section(props: Props<SectionBlock>): SectionBlock;
30
+ export declare function markdown(props: Props<MarkdownBlock>): MarkdownBlock;
31
+ export declare function fields(props: Props<FieldsBlock>): FieldsBlock;
32
+ export declare function stat(props: Props<StatBlock>): StatBlock;
33
+ export declare function callout(props: Props<CalloutBlock>): CalloutBlock;
34
+ export declare function image(props: Props<ImageBlock>): ImageBlock;
35
+ export declare function chart(props: Props<ChartBlock>): ChartBlock;
36
+ export declare function meter(props: Props<MeterBlock>): MeterBlock;
37
+ export declare function log(props: Props<LogBlock>): LogBlock;
38
+ /** The tree BLOCK builder (§5.3) — `tree` is the root wrapper. */
39
+ export declare function treeBlock(props: Props<TreeBlock>): TreeBlock;
40
+ export declare function diff(props: Props<DiffBlock>): DiffBlock;
41
+ export declare function details(props: Props<DetailsBlock>): DetailsBlock;
42
+ export declare function collection(props: Props<CollectionBlock>): CollectionBlock;
43
+ /** The collection item template (§5.5.1) — a slot bundle, not a block. */
44
+ export declare function card(props: Card): Card;
45
+ export declare function form(props: Props<FormBlock>): FormBlock;
46
+ export declare function text(props: Props<TextField>): TextField;
47
+ export declare function longtext(props: Props<LongtextField>): LongtextField;
48
+ export declare function number(props: Props<NumberField>): NumberField;
49
+ export declare function toggle(props: Props<ToggleField>): ToggleField;
50
+ export declare function choice(props: Props<ChoiceField>): ChoiceField;
51
+ export declare function date(props: Props<DateField>): DateField;
52
+ export declare function datetime(props: Props<DatetimeField>): DatetimeField;
53
+ export declare function daterange(props: Props<DaterangeField>): DaterangeField;
54
+ export declare function secret(props: Props<SecretField>): SecretField;
55
+ export declare function file(props: Props<FileField>): FileField;
56
+ export declare function list(props: Props<ListField>): ListField;
57
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Image source rules (spec §11.1): `asset:` paths, `data:` URLs of the
3
+ * four accepted MIME types (capped at 192 KiB of base64 text), and
4
+ * `https:` URLs. SVG and AVIF are never accepted (script payload; no
5
+ * TUI decoder), and `http:` URLs are rejected. The proxy that rewrites
6
+ * and fetches is U7; this module only validates.
7
+ */
8
+ /** A source that passed the §11.1 rules. */
9
+ export type ImageSourceOk = {
10
+ ok: true;
11
+ kind: "asset" | "data" | "https";
12
+ };
13
+ /** A source the §11.1 rules reject. */
14
+ export type ImageSourceErr = {
15
+ ok: false;
16
+ message: string;
17
+ };
18
+ /** The result of checking one `src` value. */
19
+ export type ImageSourceResult = ImageSourceOk | ImageSourceErr;
20
+ /**
21
+ * Check one image `src` against the §11.1 source rules. Bindings
22
+ * (`{$: …}` objects) are not checked here — their resolved string is,
23
+ * where the data model holds one (the data-size limit already covers
24
+ * the byte cost).
25
+ */
26
+ export declare function checkImageSource(src: string): ImageSourceResult;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@sigrun/sigrun/blocks` — the extension authoring surface for the
3
+ * block set (spec §14.1): the typed builders, the wire types, and
4
+ * `validateTree` (§14.2) so an extension can unit-test its trees
5
+ * against the same validation the server runs.
6
+ *
7
+ * Extensions import this subpath, never a bundled copy: the package is
8
+ * a `peerDependency: "*"` (see docs/extending.md) and
9
+ * `@sigrun/sigrun` is a HOST RUNTIME MODULE (src/harness/packages.ts)
10
+ * — the host links its own running copy into every extension packages
11
+ * dir, so `tree(...)` always stamps the server's
12
+ * {@link BLOCK_SET_VERSION}. The `exports` map points here at
13
+ * `dist/blocks/index.js` (never `src/`), which is what the built
14
+ * server ships.
15
+ */
16
+ export * from "./builders.js";
17
+ export { validateTree, validateData, VALID_PLACEMENTS, PLACEMENT_RULES, LIMITS, } from "./validate.js";
18
+ export type { PlacementLimits, PlacementRule, ValidateDataResult, ValidateErr, ValidateOk, ValidateOptions, ValidateResult, ValidationError, } from "./validate.js";
19
+ export { BLOCK_SET_VERSION } from "@sigrun/shared/blocks";
20
+ export type * from "@sigrun/shared/blocks";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The per-placement size limits of spec §4.5: max blocks per tree, and
3
+ * max UTF-8 bytes of the serialized JSON for the tree and the data.
4
+ *
5
+ * Over a limit the server rejects with `413 surface_too_large` naming
6
+ * the limit; `validateTree`/`validateData` surface the same error
7
+ * strings. A collection counts as one block however many items it
8
+ * binds, and a `data:` image URL counts toward whichever of the tree or
9
+ * the data holds it (the sizes are measured over the serialized JSON,
10
+ * so both hold by construction).
11
+ */
12
+ import type { Placement } from "@sigrun/shared/blocks";
13
+ /** One row of the §4.5 limits table. */
14
+ export interface PlacementLimits {
15
+ /** Max blocks in one tree (a collection counts as one). */
16
+ readonly blocks: number;
17
+ /** Max UTF-8 bytes of the serialized tree JSON. */
18
+ readonly treeBytes: number;
19
+ /** Max UTF-8 bytes of the serialized data JSON. */
20
+ readonly dataBytes: number;
21
+ }
22
+ /** Spec §4.5, one row per placement. */
23
+ export declare const LIMITS: Readonly<Record<Placement, PlacementLimits>>;
24
+ /** UTF-8 byte length of a value's serialized JSON (spec §4.5 sizing). */
25
+ export declare function jsonBytes(value: unknown): number;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The placement rules of spec §5.7, as data: which blocks, which
3
+ * collection displays and which form fields each placement allows,
4
+ * plus the root-block and `page.sidebar` constraints the table carries.
5
+ *
6
+ * `validateTree` walks a tree against these rules; the tests pin the
7
+ * table's literal cells so this data cannot drift from the spec.
8
+ */
9
+ /** One row of the §5.7 table. */
10
+ export interface PlacementRule {
11
+ /** Blocks allowed anywhere in the tree. */
12
+ readonly blocks: ReadonlySet<string>;
13
+ /** The only legal root block type, when the placement pins it. */
14
+ readonly rootBlock?: "page" | "form";
15
+ /** Whether a root `page` may carry `sidebar` (panel: no). */
16
+ readonly pageSidebar: boolean;
17
+ /** Collection displays allowed (`undefined`: all five). */
18
+ readonly collectionDisplays?: readonly string[];
19
+ /** Field types allowed in `form` blocks. */
20
+ readonly fields: ReadonlySet<string>;
21
+ }
22
+ /** The placement names, kept local so this module has no imports. */
23
+ type PlacementName = "view" | "panel" | "settings" | "chat" | "interaction" | "ambient";
24
+ /** Spec §5.7, one row per placement. */
25
+ export declare const PLACEMENT_RULES: Readonly<Record<PlacementName, PlacementRule>>;
26
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The typebox wire schemas for every block and field (spec §5/§6.1),
3
+ * each compiled once into its own `Validator` (spec §14.2 validation).
4
+ *
5
+ * Design notes:
6
+ * - Schemas are the wire contract, so they deliberately do NOT set
7
+ * `additionalProperties: false`: unknown props pass through and
8
+ * channels ignore them (the block-set version governs what exists).
9
+ * - `Tone`, `IconName` and `Format` are enums/patterns built from the
10
+ * shared constants; an unknown icon name fails here, at the icon
11
+ * prop's own path.
12
+ * - Bindable props are `Union([literal, Binding])` throughout; the
13
+ * literal-ness of STRUCTURAL props (`display`, `groups`, `reorder`,
14
+ * `mode`, field `name`s, `columns`, handler names) is enforced by the
15
+ * second walk in `validate.ts`, not the schema, so the error can say
16
+ * "must be a literal" instead of an anyOf mismatch.
17
+ * - Child slots (`children`, `sidebar`, `main`, `detail`,
18
+ * `columns[].block`, `collection.item`, `form` children, a field's
19
+ * `list.item`, `page.switcher`, a bulk action's `form.root`) are
20
+ * deliberately loose here (any object): `validate.ts` walks the tree
21
+ * and validates every child node against ITS compiled validator, so
22
+ * one broken block is one precise error instead of a union mismatch
23
+ * per sibling branch.
24
+ * - `Compile` (typebox/compile) builds each validator; its errors are
25
+ * read from `instancePath`, relative to the node the validator ran
26
+ * against.
27
+ */
28
+ import type { Validator } from "typebox/compile";
29
+ /** One compiled validator per block type, keyed by its `type` literal. */
30
+ export declare const BLOCK_VALIDATORS: ReadonlyMap<string, Validator>;
31
+ /** One compiled validator per field type, keyed by its `type` literal. */
32
+ export declare const FIELD_VALIDATORS: ReadonlyMap<string, Validator>;
33
+ /** The compiled validator for `Action` objects (spec §6.1). */
34
+ export declare const ACTION_VALIDATOR: Validator;
35
+ /** The compiled validator for action buttons (spec §6.1). */
36
+ export declare const ACTION_BUTTON_VALIDATOR: Validator;
37
+ /** The compiled validator for a collection's card template (spec §5.5.1). */
38
+ export declare const CARD_VALIDATOR: Validator;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `validateTree` (spec §14.2): every error with its JSON path and a
3
+ * plain message.
4
+ *
5
+ * Two passes:
6
+ * 1. The compiled typebox validators (`schema.ts`) check each node's
7
+ * shape: the walk descends the tree, finds every block, field,
8
+ * action, button and card template, and runs the matching compiled
9
+ * validator against it. Instance paths (relative to the node) join
10
+ * the walk's path, so one broken block is one precise error
11
+ * (`root.main[2].text`), never a union mismatch per sibling.
12
+ * 2. The same walk checks what schemas cannot express: placements
13
+ * (§5.7), the literal-ness of structural props (§4.2), handler names
14
+ * (§6.1), relative bindings outside item templates (§4.2), ids
15
+ * (§4.1), `turn` outside chat (§6.2), `open` schemes (§6.1), image
16
+ * sources (§11.1), limits (§4.5), and the ambient one-line rule
17
+ * (§9.2).
18
+ */
19
+ import { type Placement, type Tree } from "@sigrun/shared/blocks";
20
+ export { PLACEMENT_RULES } from "./placements.js";
21
+ export { LIMITS } from "./limits.js";
22
+ export type { PlacementRule } from "./placements.js";
23
+ export type { PlacementLimits } from "./limits.js";
24
+ /** The placements the block set defines (spec §3). */
25
+ export declare const VALID_PLACEMENTS: readonly Placement[];
26
+ /** One validation error: where and what (spec §14.2). */
27
+ export interface ValidationError {
28
+ path: string;
29
+ message: string;
30
+ }
31
+ /** Options for `validateTree`. */
32
+ export interface ValidateOptions {
33
+ placement: Placement;
34
+ /**
35
+ * The surface's `actions` map keys (spec §6.1); when given, every
36
+ * handler-name prop must be in it. When omitted (chat), handler
37
+ * names are free labels.
38
+ */
39
+ handlers?: ReadonlySet<string>;
40
+ /** The server's block-set version; a mismatched tree is rejected (§4.4). */
41
+ serverV?: number;
42
+ /** For ambient trees: which `place` renders this body (§9.2 one-line rule). */
43
+ ambientPlace?: "status" | "footer" | "panel";
44
+ }
45
+ /** The ok result of `validateTree`: the tree, narrowed to the wire type. */
46
+ export interface ValidateOk {
47
+ ok: true;
48
+ tree: Tree;
49
+ }
50
+ /** The failure result: every error found, each with its path. */
51
+ export interface ValidateErr {
52
+ ok: false;
53
+ errors: ValidationError[];
54
+ }
55
+ /** `validateTree`'s result. */
56
+ export type ValidateResult = ValidateOk | ValidateErr;
57
+ /** The result of `validateData`. */
58
+ export type ValidateDataResult = {
59
+ ok: true;
60
+ } | {
61
+ ok: false;
62
+ errors: ValidationError[];
63
+ };
64
+ /**
65
+ * Validate a block tree against a placement (spec §14.2). Returns every
66
+ * error with its JSON path; on success, the narrowed tree.
67
+ */
68
+ export declare function validateTree(tree: unknown, opts: ValidateOptions): ValidateResult;
69
+ /**
70
+ * Validate a surface's data object: a JSON object within the placement's
71
+ * data-size limit (spec §4.5).
72
+ */
73
+ export declare function validateData(data: unknown, placement: Placement): ValidateDataResult;
@@ -349,14 +349,18 @@ export interface ExtensionCommandContribution {
349
349
  readonly notice?: string;
350
350
  } | void>;
351
351
  }
352
- /** One notification core delivers to every registered notifier (best-effort). */
353
- export interface NotificationEvent {
354
- 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 {
355
362
  readonly conversationId?: number;
356
363
  readonly jobId?: string;
357
- /** Gateway C6: the pending record's id — set on `interaction.pending` only
358
- * (read the record via `host.snapshotInteraction`). */
359
- readonly callId?: string;
360
364
  /** The recipient. */
361
365
  readonly user: string | undefined;
362
366
  readonly title: string;
@@ -364,6 +368,21 @@ export interface NotificationEvent {
364
368
  /** `${server.publicUrl}/#/c/<conversationId>` when `server.publicUrl` is set; absent otherwise. */
365
369
  readonly url?: string;
366
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
+ });
367
386
  /** One notifier transport (ntfy, Signal, web push — core ships none). */
368
387
  export interface NotifierContribution {
369
388
  readonly name: string;
@@ -800,7 +819,22 @@ export interface SigrunHost {
800
819
  }): Promise<boolean>;
801
820
  /** Mark a pending interaction expired/cancelled/skipped (extension-owned semantics). */
802
821
  settleInteraction(conversationId: ConversationId, callId: string, status: "skipped" | "expired" | "cancelled", context: HandlerContext): Promise<boolean>;
803
- /** 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
+ */
804
838
  snapshotInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
805
839
  /** Fully replace one ambient surface's state (write = replacement); the key must be unowned or yours. */
806
840
  setAmbient(conversationId: ConversationId, record: {