@sigrun/sigrun 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-11-02
9
+
10
+ ### Added
11
+ - **Escalations core.** Extensions send notices through `host.notify`
12
+ (validated targets, delivered on the bounded side lane), look jobs up
13
+ with `host.jobs.get`, create job-scoped conversations, and read a
14
+ conversation's history through the trusted `host.conversations.entries`
15
+ / `entry` (callers enforce access themselves).
16
+ - **Per-job state.** Each job gets a state folder under its owner's jobs
17
+ root (jail-granted to the run); the run's prompt names it. State follows
18
+ the job through delete, transfer and retire, and resets on request
19
+ (`POST /v1/jobs/{id}/state/reset`, and a Reset button in the jobs tab).
20
+ - Extension views and panels render through the block renderer (they were
21
+ a JSON placeholder in 0.5.0 — the curator view showed raw data).
22
+ - The settings page gains the `builtinExtensions.gateway` toggle.
23
+
24
+ ### Changed
25
+ - **Ownerless jobs belong to the primary admin** (the earliest-created
26
+ directory admin): run conversations, access checks, `host.jobs.get` and
27
+ job state all use that effective owner. A single-operator install with
28
+ an empty directory stays ownerless.
29
+ - The identity prompt section is multi-user and no longer names the agent
30
+ (naming is persona territory, `SOUL.md`).
31
+ - Built-in documentation says six built-ins everywhere (codemode, MCP,
32
+ tool search, gateway, OAuth, curator); everything else is optional.
33
+
34
+ ### Fixed
35
+ - The web conversation list heals after the events stream drops: it
36
+ reloads on every reconnect and when the tab becomes visible again, and
37
+ reopens an EventSource the browser gave up on (5-60 s backoff).
38
+ Conversations created on another device no longer stay invisible until
39
+ a reload.
40
+ - Leaving an extension view: the Chats rail entry works and picking a
41
+ conversation in the sidebar returns to the chat.
42
+ - Phone layout: a long URL in a user message no longer makes the chat
43
+ slide sideways; tables keep readable columns and scroll inside their
44
+ card; the composer's controls stay on one row.
45
+ - Extension surfaces: params-carrying action/data URLs, zero-width surface
46
+ pages, and cadence refreshes.
47
+ - `SIGRUN_EXTENSIONS` without a model env no longer crashes boot.
48
+
8
49
  ## [0.5.0] - 2026-11-02
9
50
 
10
51
  ### Added
@@ -32,7 +73,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
32
73
  step read `.list` off a promise). A lazy store proxy now preserves the
33
74
  sync-store contract through the pre-bind window.
34
75
 
76
+ ### Added
77
+ - `host.notify` lets extensions send validated explicit notices through
78
+ every declared notifier, with optional conversation or declared-view
79
+ deep links. Delivery is enqueued on the bounded side lane and is not
80
+ gated by `notifications.runSettled`.
81
+ - Extension host job hooks: `host.jobs.get(id)` returns a read-only live-job
82
+ projection with its effective owner, and `host.conversations.create({job})`
83
+ stamps a validated job association in the session-meta creation commit.
84
+ Unknown, retired, and owner-mismatched jobs are rejected before storage
85
+ writes; creating the conversation does not create a job run or `lastRun`.
86
+
35
87
  ### Changed
88
+ - Ownerless and orphaned-owner jobs now run as the primary admin; their
89
+ runs are no longer visible to every user. The effective owner is
90
+ resolved live without rewriting the declared record.
36
91
  - Reworded the `pi` manifest handling so it no longer implies pi
37
92
  extension compatibility. Sigrun reads `pi.extensions`/`pi.skills` only
38
93
  to LOCATE files and shares the SKILL.md format; it does not implement
package/README.md CHANGED
@@ -531,8 +531,8 @@ form when the effective auth methods include `token`; `/v1/auth/providers`
531
531
  returns an empty list on proxy-only deployments because their completed
532
532
  sign-ins could not authenticate the resulting session. The TUI continues to
533
533
  use tokens. Login providers come from extensions.
534
- The reference implementation is **`@sigrun/oauth`, the fourth built-in
535
- extension**. It is bundled and loads by default, so no `extensions` entry is
534
+ The reference implementation is **`@sigrun/oauth`, one of the six built-in
535
+ extensions**. It is bundled and loads by default, so no `extensions` entry is
536
536
  needed. Set `"builtinExtensions": {"oauth": false}` to disable it; like the
537
537
  other built-in toggles, this is restart-required. A configured extension
538
538
  whose package name is also `@sigrun/oauth` replaces the built-in. If the
@@ -1082,10 +1082,13 @@ a dashboard) are in
1082
1082
 
1083
1083
  ## Built-in extensions
1084
1084
 
1085
- Five extensions are built into core — **codemode**, **MCP**, **tool
1086
- search**, **gateway**, and **OAuth login**. The first three mirror
1087
- what pi 1.3.0 ships built-in; the gateway is sigrun's own channel-adapter
1088
- engine; OAuth provides OIDC, GitHub, and Discord login providers. They
1085
+ Six extensions are built into core — **codemode**, **MCP**, **tool
1086
+ search**, **gateway**, **OAuth login**, and the **curator**. The first
1087
+ three mirror what pi 1.3.0 ships built-in; the gateway is sigrun's own
1088
+ channel-adapter engine; OAuth provides OIDC, GitHub, and Discord login
1089
+ providers; the curator learns per-user skills (see
1090
+ [Skills and prompt templates](#skills-and-prompt-templates)). Every other extension is optional
1091
+ and loads only when `extensions` names it. The built-ins
1089
1092
  load by default on every boot, with no settings entry, and resolve from
1090
1093
  core's own `node_modules` (they are core dependencies, never installed
1091
1094
  into the packages dir — `extensions` has nothing to install for them,
@@ -1093,7 +1096,8 @@ unless a same-named package is deliberately installed to replace one;
1093
1096
  see below).
1094
1097
  A default install therefore has pi's tool surface without configuring
1095
1098
  anything. On `GET /v1/extensions` they report under the names
1096
- `codemode`, `sigrun-mcp`, `tool-search`, `gateway`, and `oauth`.
1099
+ `codemode`, `sigrun-mcp`, `tool-search`, `gateway`, `oauth`, and
1100
+ `curator`.
1097
1101
  Configure OAuth providers in `settings/oauth.json`; see the
1098
1102
  `@sigrun/oauth` package README for provider settings and secrets.
1099
1103
 
@@ -1109,7 +1113,8 @@ default. They join the offered set when an enabled MCP server's
1109
1113
  automatic decision (auto-enable only narrows the default set, never a
1110
1114
  named list).
1111
1115
  - **Turning one off** — `"builtinExtensions": {"codemode": false,
1112
- "mcp": false, "toolSearch": false, "gateway": false, "oauth": false}`.
1116
+ "mcp": false, "toolSearch": false, "gateway": false, "oauth": false,
1117
+ "curator": false}`.
1113
1118
  Each defaults to `true`; each is restart-required. A disabled built-in
1114
1119
  disappears from `GET /v1/extensions` entirely — no row, not even a
1115
1120
  skipped one — and its config is then ignored.
@@ -1136,12 +1141,13 @@ docs/extending.md).
1136
1141
  "extensions": [ // configured extensions install + load as above
1137
1142
  "@acme/sigrun-ext-example@^0.1.0"
1138
1143
  ],
1139
- "builtinExtensions": { // all five default to true; restart required
1144
+ "builtinExtensions": { // all six default to true; restart required
1140
1145
  "codemode": true,
1141
1146
  "mcp": true,
1142
1147
  "toolSearch": false,
1143
1148
  "gateway": true,
1144
- "oauth": true
1149
+ "oauth": true,
1150
+ "curator": true
1145
1151
  }
1146
1152
  }
1147
1153
  ```
@@ -1202,7 +1208,8 @@ model receives the expanded text.
1202
1208
 
1203
1209
  **The curator and learned skills.** Beyond the three roots above, every
1204
1210
  user has a private fourth root — learned skills the curator records for
1205
- them under `<data-dir>/users/<user>/skills/`. The curator reviews the
1211
+ them under `<data-dir>/users/<user>/skills/`. For skills it ranks second:
1212
+ the workspace wins over it, and it wins over the user dir and packages. The curator reviews the
1206
1213
  user's conversations (owner-authored tool calls are captured as
1207
1214
  "Noted" hints, never executed on the spot) and writes through the
1208
1215
  versioned `skill_manage` surface, so every learned skill keeps its last
@@ -38,7 +38,7 @@ import type { Access, Sharing } from "@sigrun/shared/access";
38
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
- import type { JobSpec } from "@sigrun/shared/jobs";
41
+ import type { JobSpec, JobWire } from "@sigrun/shared/jobs";
42
42
  import type { AccountLinkWire, ConversationSummary, ConversationViewWire, HealthStatus, ModelEntry, SessionMeta, SlashCommand, SubmissionRecordWire, ThinkingLevelWire, UserWire as DirectoryUserWire } from "@sigrun/shared/views";
43
43
  export type { Access, AccountLinkWire, AmbientRecord, DirectoryUserWire, InteractionRecord, JobSpec, ModelEntry, Sharing, SlashCommand, };
44
44
  type JsonParams = JsonObject;
@@ -630,6 +630,23 @@ export type RunSettledOutcome = {
630
630
  readonly status: "unanswered";
631
631
  readonly reason: string;
632
632
  };
633
+ /** One explicit notification an extension sends through `host.notify`. */
634
+ export interface ExtensionNoticeInput {
635
+ /** A directory user; `undefined` only when the directory is empty. */
636
+ readonly user: string | undefined;
637
+ /** 1..200 characters. */
638
+ readonly title: string;
639
+ /** 0..2000 characters. */
640
+ readonly body: string;
641
+ /** 1..64 characters, each `[a-z0-9.-]`. */
642
+ readonly kind: string;
643
+ readonly target?: {
644
+ readonly view: string;
645
+ readonly params?: JsonObject;
646
+ } | {
647
+ readonly conversation: number;
648
+ };
649
+ }
633
650
  /** Fields shared by every notification variant. */
634
651
  interface NotificationEventBase {
635
652
  readonly conversationId?: number;
@@ -650,6 +667,12 @@ export type NotificationEvent = NotificationEventBase & ({
650
667
  readonly callId?: string;
651
668
  } | {
652
669
  readonly type: "job.failed";
670
+ } | {
671
+ readonly type: "extension";
672
+ /** The calling extension's slug. */
673
+ readonly extension: string;
674
+ /** The extension-defined notice kind. */
675
+ readonly kind: string;
653
676
  } | {
654
677
  readonly type: "run.settled";
655
678
  /** Whether the run answered or not (model error, abort, …), with
@@ -1018,6 +1041,15 @@ export type SubmissionInputPart = {
1018
1041
  };
1019
1042
  /** How a submit behaves when a run is already live (pi-durable's `whenBusy`). */
1020
1043
  export type WhenBusyMode = NonNullable<InputSubmissionDraft["whenBusy"]>;
1044
+ /** The extension-facing, read-only projection of one live job. */
1045
+ export interface HostJobInfo {
1046
+ readonly id: string;
1047
+ readonly name: string;
1048
+ /** The effective owner (ownerless jobs resolve to the primary admin). */
1049
+ readonly owner?: string;
1050
+ readonly enabled: boolean;
1051
+ readonly state: JobWire["state"];
1052
+ }
1021
1053
  /**
1022
1054
  * The host context sigrun hands an extension factory: the scoped
1023
1055
  * extension stores, the interaction/ambient seam surfaces, and the
@@ -1146,6 +1178,14 @@ export interface SigrunHost {
1146
1178
  readonly sharing?: Sharing;
1147
1179
  readonly name?: string;
1148
1180
  readonly source?: string;
1181
+ /**
1182
+ * Associate this new conversation with one live job. The requested
1183
+ * owner must equal the job's effective owner (including both absent).
1184
+ * Unknown, retired, or mismatched jobs throw `ConversationOpError`
1185
+ * with `{error: "invalid_field", field: "job"}` before any write.
1186
+ * Success stamps `jobId`; it does not create a job run or `lastRun`.
1187
+ */
1188
+ readonly job?: string;
1149
1189
  /**
1150
1190
  * Pin the conversation's extension/tool selection (gateway C4):
1151
1191
  * names resolved against the registry — an unknown name throws a
@@ -1167,6 +1207,9 @@ export interface SigrunHost {
1167
1207
  readonly archived?: boolean;
1168
1208
  }): Promise<readonly ConversationSummary[]>;
1169
1209
  meta(id: ConversationId): Promise<SessionMeta>;
1210
+ /** The fork-aware transcript read (trusted; enforce access yourself with `host.access.accessOf`). */
1211
+ entries(id: ConversationId, query: Omit<EntryQuery, "conversationId">, limit: number, cursor?: Cursor): Promise<Page<EntryRecord, Cursor>>;
1212
+ entry(id: ConversationId, entryId: EntryId): Promise<EntryRecord | undefined>;
1170
1213
  /**
1171
1214
  * Withdraw/settle the conversation's active submission (gateway
1172
1215
  * C4, the abort route's op). On an idle conversation it RESOLVES
@@ -1233,6 +1276,16 @@ export interface SigrunHost {
1233
1276
  readonly mime?: string;
1234
1277
  }>;
1235
1278
  };
1279
+ /** Read-only job lookup; retired and unknown ids resolve to `undefined`. */
1280
+ readonly jobs: {
1281
+ get(id: string): Promise<HostJobInfo | undefined>;
1282
+ };
1283
+ /**
1284
+ * Send one explicit notice through every registered notifier. Validation
1285
+ * failures throw; once accepted, delivery is enqueued on the bounded side
1286
+ * lane and this resolves without awaiting notifier completion.
1287
+ */
1288
+ notify(input: ExtensionNoticeInput): Promise<void>;
1236
1289
  /** The user directory (admin wire form; emails are admin material). */
1237
1290
  readonly users: {
1238
1291
  list(): Promise<readonly DirectoryUserWire[]>;
@@ -30,6 +30,7 @@ import { openIdentityStores, ensureSetupCode, } from "./identity/index.js";
30
30
  import { MEMORY_SECTION_KEY, noopMemoryExtension } from "./memory-default.js";
31
31
  import { createSeamHost, } from "./seam-host.js";
32
32
  import { contextFilesExtension } from "./sections/context-files.js";
33
+ import { createJobStateExtension } from "./sections/job-state.js";
33
34
  import { modelGuidanceExtension } from "./sections/model-guidance.js";
34
35
  import { createSkillsExtension, scanFsRoot } from "../skills/index.js";
35
36
  import { SLASH_COMMANDS } from "../server/commands.js";
@@ -78,6 +79,7 @@ import { IsolationState } from "../sandbox/isolation.js";
78
79
  import { probeOsUsers } from "../sandbox/availability.js";
79
80
  import { createAdmissionGuard } from "./identity/users.js";
80
81
  import { sweepAccountLinks } from "./identity/link-sweep.js";
82
+ import { effectiveOwner as resolveEffectiveOwner } from "./identity/primary-admin.js";
81
83
  import { SessionMetaDoc } from "../server/session-meta.js";
82
84
  import { learnedSkillsDir } from "../curation/paths.js";
83
85
  import { SkillStore } from "../curation/store.js";
@@ -120,6 +122,7 @@ export const RESERVED_CORE_SECTION_OWNERS = {
120
122
  skills: "sigrun.skills",
121
123
  "tool-guidance": "sigrun.model-guidance",
122
124
  "execution-guidance": "sigrun.model-guidance",
125
+ "job-state": "sigrun.job-state",
123
126
  };
124
127
  /**
125
128
  * Open (or reopen) the durable harness at `config.storagePath`.
@@ -361,6 +364,10 @@ export async function openHarness(config, options = {}) {
361
364
  // reads it at RENDER time, long after the assignment — the skills cap
362
365
  // is a live setting (`skills.sectionMaxBytes`, default 4KB).
363
366
  let skillsSettings;
367
+ // The job-state section resolves its services only when a job run renders;
368
+ // both are constructed after the core registry installs.
369
+ let workspaceService;
370
+ let jobs;
364
371
  // Context-file sections (SOUL.md persona, AGENTS.md instructions), the
365
372
  // skills convention, and the core tool surface (plan Task 1: coding
366
373
  // tools read/write/edit/bash + search tools find/grep/ls) are all
@@ -500,6 +507,13 @@ export async function openHarness(config, options = {}) {
500
507
  },
501
508
  };
502
509
  registry.install(contextFilesExtension);
510
+ // Escalations A2: the core-owned job-state prompt section names the run's
511
+ // state folder (WorkspaceService.jobsRoot, A1); deps late-bind like
512
+ // skillsSettings — services are created further down.
513
+ registry.install(createJobStateExtension({
514
+ jobsRoot: (owner) => workspaceService?.jobsRoot(owner),
515
+ job: async (id) => await jobs?.get(id),
516
+ }));
503
517
  // Curator A8 (P9): the wrap feed — the mutable set of curation tool
504
518
  // names, seeded with the core set and grown by the loader as each
505
519
  // extension's curationTargets validate. The skills extension's
@@ -765,6 +779,9 @@ export async function openHarness(config, options = {}) {
765
779
  // deps object is shared with `fireJob`'s run-now and the JobsService
766
780
  // below (one clock, one tz, one models store).
767
781
  const jobRuntimeDeps = {
782
+ // Resolve declared-job owners against the live directory at each use
783
+ // (§6.5); no cache and no rewrite of the source's stored record.
784
+ effectiveOwner: (owner) => resolveEffectiveOwner(identity.users.list(), owner),
768
785
  // SAFETY: harness + storage are assigned right after `Harness.open`
769
786
  // returns below (the open needs the task registered, so the deps
770
787
  // object is created first with placeholders). Every read — task
@@ -912,7 +929,7 @@ export async function openHarness(config, options = {}) {
912
929
  stopProviderRefresh();
913
930
  };
914
931
  // Every tool/file/upload/job env resolves through this one decision seam.
915
- const workspaceService = createWorkspaceService({
932
+ workspaceService = createWorkspaceService({
916
933
  isolation: isolationState,
917
934
  osUsers,
918
935
  pool: sandboxPool,
@@ -1205,6 +1222,7 @@ export async function openHarness(config, options = {}) {
1205
1222
  // (an extension's declaration is its boot-time declaration).
1206
1223
  const fileJobs = () => settingsStore.read().jobs ?? [];
1207
1224
  const jobsSources = () => ({ file: fileJobs(), extensions: jobBindings });
1225
+ const jobEffectiveOwner = (owner) => resolveEffectiveOwner(identity.users.list(), owner);
1208
1226
  // The root conversation — the schedule tasks' owner (spec §8.1).
1209
1227
  // `Harness.root` is idempotent (creates with `agent` on first call,
1210
1228
  // returns the reserved root thereafter), so the server's own
@@ -1233,7 +1251,7 @@ export async function openHarness(config, options = {}) {
1233
1251
  // SSE stream computes `accessOf` per principal and adds it. A
1234
1252
  // future in-process hub subscriber must never read a fabricated
1235
1253
  // access off the hub's copy.
1236
- const { access: _dropped, ...row } = jobWireOf(jobId, record);
1254
+ const { access: _dropped, ...row } = jobWireOf(jobId, record, jobEffectiveOwner(record.owner));
1237
1255
  events.publish({
1238
1256
  type: "job.changed",
1239
1257
  job: row,
@@ -1249,7 +1267,9 @@ export async function openHarness(config, options = {}) {
1249
1267
  id: jobId,
1250
1268
  name: newest.spec.name,
1251
1269
  source: newest.source,
1252
- ...(newest.owner === undefined ? {} : { owner: newest.owner }),
1270
+ ...(jobEffectiveOwner(newest.owner) === undefined
1271
+ ? {}
1272
+ : { owner: jobEffectiveOwner(newest.owner) }),
1253
1273
  ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
1254
1274
  schedule: newest.spec.schedule,
1255
1275
  action: newest.spec.action,
@@ -1285,12 +1305,14 @@ export async function openHarness(config, options = {}) {
1285
1305
  jobRunTotals.set(outcome, (jobRunTotals.get(outcome) ?? 0) + 1);
1286
1306
  }
1287
1307
  });
1288
- const jobs = createJobsService({
1308
+ jobs = createJobsService({
1289
1309
  harness,
1290
1310
  storage,
1291
1311
  runtime: jobRuntimeDeps,
1312
+ effectiveOwner: jobEffectiveOwner,
1292
1313
  rootId: rootConversationId,
1293
1314
  sources: jobsSources,
1315
+ jobsRoot: (owner) => workspaceService?.jobsRoot(owner),
1294
1316
  settings: () => settingsStore.read(),
1295
1317
  onChange: (jobId) => {
1296
1318
  void publishJobChanged(jobId);
@@ -1311,6 +1333,7 @@ export async function openHarness(config, options = {}) {
1311
1333
  runtime: jobRuntimeDeps,
1312
1334
  rootId: rootConversationId,
1313
1335
  sources: jobsSources,
1336
+ jobsRoot: (owner) => workspaceService?.jobsRoot(owner),
1314
1337
  }, BACKGROUND_CONTEXT);
1315
1338
  // The deleted-owner warning (§8.3): a declared job whose `owner` names
1316
1339
  // a user no longer in the directory runs OWNERLESS until its source is
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Built-in extensions: codemode, MCP, tool search, OAuth and the curator
3
- * ship WITH core — present by default, disableable via settings
2
+ * Built-in extensions: codemode, MCP, tool search, the gateway, OAuth and
3
+ * the curator ship WITH core — present by default, disableable via settings
4
4
  * `builtinExtensions`, replaceable by a same-named configured extension.
5
+ * Every other extension is optional and loads only when configured.
5
6
  *
6
- * The first three mirror pi 1.3.0's built-in tool surface; OAuth adds
7
- * sigrun's standard OIDC/OAuth2 login providers without extra setup.
7
+ * The first three mirror pi 1.3.0's built-in tool surface; the gateway is
8
+ * the channel-adapter engine (hermes/openclaw parity: channels work out of
9
+ * the box); OAuth adds sigrun's standard OIDC/OAuth2 login providers
10
+ * without extra setup; the curator runs the per-user learning loops.
8
11
  *
9
12
  * Resolution root (Ruling 2): built-ins resolve through core's OWN
10
13
  * node_modules via `createRequire(import.meta.url)` — they are core
@@ -23,7 +26,8 @@
23
26
  * (`readPackageName` for path-shaped entries, `parsePackageSpecifier`
24
27
  * otherwise), never by string equality on the raw specifier.
25
28
  *
26
- * Disabled (Ruling 6): `builtinExtensions: {codemode?, mcp?, toolSearch?, oauth?}`
29
+ * Disabled (Ruling 6): `builtinExtensions: {codemode?, mcp?, toolSearch?,
30
+ * gateway?, oauth?, curator?}`
27
31
  * (each defaulting to `true`, restart-required). A disabled built-in is
28
32
  * absent from `/v1/extensions` entirely; one log line records the skip.
29
33
  *
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The primary admin is the first stored admin in directory insertion
3
+ * order. Callers pass the live directory at each use so demotions and
4
+ * deletions take effect without a cache or a record rewrite.
5
+ */
6
+ export function primaryAdmin(records) {
7
+ return records.find((record) => record.admin === true);
8
+ }
9
+ /**
10
+ * Resolve the effective owner at use time: a live declared owner wins;
11
+ * an absent or orphaned owner falls back to the primary admin. An empty
12
+ * directory (single-operator mode) stays ownerless.
13
+ */
14
+ export function effectiveOwner(records, owner) {
15
+ if (owner !== undefined && records.some((record) => record.user === owner)) {
16
+ return owner;
17
+ }
18
+ return primaryAdmin(records)?.user;
19
+ }
@@ -21,6 +21,7 @@ export const RESERVED_CORE_SECTION_KEYS = [
21
21
  "skills",
22
22
  "tool-guidance",
23
23
  "execution-guidance",
24
+ "job-state",
24
25
  ];
25
26
  /**
26
27
  * Core-owned directory names directly under a store root (multi-user M1).
@@ -31,6 +32,7 @@ export const RESERVED_CORE_SECTION_KEYS = [
31
32
  */
32
33
  export const RESERVED_EXTENSION_SLUGS = [
33
34
  "users",
35
+ "jobs",
34
36
  "workspace",
35
37
  "models",
36
38
  "sandbox",
@@ -321,6 +321,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
321
321
  ...(opts.owner === undefined ? {} : { owner: opts.owner }),
322
322
  ...(opts.sharing === undefined ? {} : { sharing: opts.sharing }),
323
323
  ...(opts.name === undefined ? {} : { name: opts.name }),
324
+ ...(opts.job === undefined ? {} : { job: opts.job }),
324
325
  ...(opts.agent === undefined ? {} : { agent: opts.agent }),
325
326
  // The source default: the CALLING extension's slug (spec §9.5).
326
327
  source: opts.source ?? identity.name,
@@ -342,6 +343,12 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
342
343
  const bridge = await hostBridge("conversations.meta");
343
344
  return bridge.conversations.meta(id);
344
345
  },
346
+ async entries(id, query, limit, cursor) {
347
+ return (await hostBridge("conversations.entries")).conversations.entries(id, query, limit, cursor);
348
+ },
349
+ async entry(id, entryId) {
350
+ return (await hostBridge("conversations.entry")).conversations.entry(id, entryId);
351
+ },
345
352
  // The C4a ops: straight delegation (the bridge resolves the id and
346
353
  // runs the op its HTTP twin runs).
347
354
  async abort(id) {
@@ -367,6 +374,11 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
367
374
  return (await hostBridge("conversations.readFile")).conversations.readFile(id, path);
368
375
  },
369
376
  },
377
+ jobs: {
378
+ async get(id) {
379
+ return (await hostBridge("jobs.get")).jobs.get(id);
380
+ },
381
+ },
370
382
  users: {
371
383
  async list() {
372
384
  return (await hostBridge("users.list")).users.list();
@@ -483,6 +495,9 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
483
495
  return (await hostBridge("commands.run")).commands.run(conversationId, name, args, opts);
484
496
  },
485
497
  },
498
+ async notify(input) {
499
+ return (await hostBridge("notify")).notify(identity.name, input);
500
+ },
486
501
  // The surfaces seam (UI-blocks U3 Task 4): `changed` awaits the
487
502
  // bridge (so a factory-body call waits, never resolves early) and
488
503
  // delegates to the server-side SurfaceService — the rev bump + the
@@ -45,10 +45,13 @@ async function readContextFile(input, fileName, context) {
45
45
  /**
46
46
  * The static `identity` section body: runtime facts only. No tool claims
47
47
  * (the system prompt already declares the actually-offered tools per
48
- * request) and no model name (it would re-emit this section on every
49
- * mid-conversation model swap, defeating the minimal-patch design).
48
+ * request), no model name (it would re-emit this section on every
49
+ * mid-conversation model swap, defeating the minimal-patch design), and
50
+ * no NAME: what the agent is called is `SOUL.md` territory, since a user
51
+ * may choose a different name for their own agent. Durability is runtime
52
+ * truth, not identity copy — it stays out of this section.
50
53
  */
51
- export const IDENTITY_TEXT = "You are sigrun, an AI assistant running as a persistent server built on the pi-durable harness. Conversations are durable: they survive restarts and can span channels (web UI, terminal UI, HTTP API). Multiple conversations may be active at once; each is independent.";
54
+ export const IDENTITY_TEXT = "You are a small but extensible multi-user AI assistant built on the pi-durable harness. Conversations can span channels (web UI, terminal UI, HTTP API), and multiple independent conversations may be active at once.";
52
55
  /**
53
56
  * The persona sigrun ships when no `SOUL.md` exists. A present `SOUL.md`
54
57
  * replaces it wholesale; the default is never merged in.
@@ -0,0 +1,24 @@
1
+ /** The persistent state folder shared by every run of one job. */
2
+ import { defineExtension, section } from "@earendil-works/pi-durable";
3
+ import { jobStateDir } from "../../jobs/state-dir.js";
4
+ import { SessionMetaDoc } from "../../server/session-meta.js";
5
+ /** Name a new-conversation job run's persistent, per-owner state folder. */
6
+ export function createJobStateExtension(deps) {
7
+ return defineExtension({
8
+ name: "sigrun.job-state",
9
+ sections: [
10
+ section("job-state", async (input, context) => {
11
+ const meta = await input.read.snapshot(SessionMetaDoc, input.conversationId, context);
12
+ if (meta?.jobId === undefined)
13
+ return undefined;
14
+ const job = await deps.job(meta.jobId);
15
+ if (job === undefined)
16
+ return undefined;
17
+ const root = deps.jobsRoot(meta.owner);
18
+ if (root === undefined)
19
+ return undefined;
20
+ return `Job state: this conversation is a run of job "${job.name}" (${meta.jobId}). Its state folder is ${jobStateDir(root, meta.jobId)} — create it if missing. Use it to keep notes between runs (for example, when an issue was first seen). Other runs of this job use the same folder; write files atomically.`;
21
+ }),
22
+ ],
23
+ });
24
+ }
@@ -1,15 +1,43 @@
1
1
  /** Resolve each conversation's ExecutionEnv: single-user, jailed, or fail-closed. */
2
- import { isAbsolute, resolve } from "node:path";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
3
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
4
  import { buildUserPolicy } from "../sandbox/policy.js";
5
5
  import { SandboxedExecutionEnv } from "../sandbox/sandboxed-env.js";
6
6
  import { UnavailableExecutionEnv } from "../sandbox/unavailable-env.js";
7
7
  import { selfPackageRoot } from "./packages.js";
8
8
  import { defaultModuleTrees } from "../sandbox/policy.js";
9
+ import { primaryAdmin } from "./identity/primary-admin.js";
9
10
  import { AgentExecutionEnv } from "./agent-env.js";
10
11
  import { lookupOsAccount } from "../sandbox/os-account.js";
12
+ /** The live owner's record, falling back to the primary admin. */
13
+ export function selectRecord(records, owner) {
14
+ return records.find((record) => record.user === owner) ?? primaryAdmin(records);
15
+ }
11
16
  export function createWorkspaceService(deps) {
12
17
  return {
18
+ jobsRoot(owner) {
19
+ if (deps === undefined)
20
+ return undefined;
21
+ const { directory, managed } = deps;
22
+ const records = directory.list();
23
+ const record = selectRecord(records, owner);
24
+ if (record === undefined)
25
+ return join(managed.roots.dataDir, "jobs");
26
+ if (record.osUser !== undefined) {
27
+ if (deps.osUsers?.available !== true)
28
+ return undefined;
29
+ try {
30
+ const account = (deps.lookupAccount ?? lookupOsAccount)(record.osUser);
31
+ return account === undefined
32
+ ? undefined
33
+ : join(account.home, ".local", "state", "sigrun", "jobs");
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ return join(managed.userAreas(record.user), "jobs");
40
+ },
13
41
  async forConversation(conversation, prefs, read, ctx) {
14
42
  const workspace = prefs.defaultWorkspace;
15
43
  if (workspace.type !== "local") {
@@ -22,8 +50,7 @@ export function createWorkspaceService(deps) {
22
50
  const owner = read === undefined
23
51
  ? undefined
24
52
  : await deps.ownerOf(conversation.id, read, ctx ?? BACKGROUND_CONTEXT);
25
- const record = records.find((candidate) => candidate.user === owner)
26
- ?? records.find((candidate) => candidate.admin);
53
+ const record = selectRecord(records, owner);
27
54
  let osAccount;
28
55
  if (record?.osUser !== undefined) {
29
56
  if (deps.osUsers?.available !== true) {
package/dist/jobs/fire.js CHANGED
@@ -216,6 +216,7 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
216
216
  : { thinkingLevel: action.thinking }),
217
217
  },
218
218
  init: async (tx, newId) => {
219
+ const owner = deps.effectiveOwner(record.owner);
219
220
  const run = {
220
221
  ...baseRun,
221
222
  conversationId: newId,
@@ -229,9 +230,9 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
229
230
  meta.jobId = id;
230
231
  if (action.deliver !== undefined)
231
232
  meta.deliver = [...action.deliver];
232
- if (record.owner !== undefined)
233
- meta.owner = record.owner;
234
- await deps.rewriteUserSelection?.(tx, newId, undefined, record.owner);
233
+ if (owner !== undefined)
234
+ meta.owner = owner;
235
+ await deps.rewriteUserSelection?.(tx, newId, undefined, owner);
235
236
  if (action.runSharing !== undefined)
236
237
  meta.sharing = action.runSharing;
237
238
  meta.name = runName(record.spec.name, deps.now(), record, deps);