@sigrun/sigrun 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +105 -18
  3. package/dist/blocks/build-stamp.json +2 -2
  4. package/dist/blocks/schema.js +5 -0
  5. package/dist/cli.js +85 -22
  6. package/dist/curation/host.js +46 -21
  7. package/dist/curation/migrate.js +96 -0
  8. package/dist/curation/overlays.js +52 -16
  9. package/dist/curation/paths.js +22 -0
  10. package/dist/curation/runs.js +34 -17
  11. package/dist/curation/skill-tool.js +34 -9
  12. package/dist/curation/store.js +382 -254
  13. package/dist/curation/targets.js +21 -0
  14. package/dist/curation/wrap.js +1 -6
  15. package/dist/extension.d.ts +12 -3
  16. package/dist/harness/boot.js +143 -6
  17. package/dist/harness/event-bus.js +19 -0
  18. package/dist/harness/loader.js +22 -0
  19. package/dist/harness/packages.js +7 -0
  20. package/dist/harness/reserved-sections.js +2 -0
  21. package/dist/harness/seam-host.js +71 -16
  22. package/dist/harness/workspace.js +34 -1
  23. package/dist/history/git.js +707 -0
  24. package/dist/history/trailers.js +145 -0
  25. package/dist/history/workspace-history.js +47 -0
  26. package/dist/jobs/index.js +5 -3
  27. package/dist/jobs/reconcile.js +2 -3
  28. package/dist/jobs/state-dir.js +9 -0
  29. package/dist/jobs/transfer.js +6 -6
  30. package/dist/sandbox/grants.js +11 -0
  31. package/dist/sandbox/launch.js +19 -9
  32. package/dist/sandbox/policy.js +6 -1
  33. package/dist/serve.js +3 -0
  34. package/dist/server/auth/proxy-only.js +5 -4
  35. package/dist/server/core-views/index.js +26 -0
  36. package/dist/server/core-views/jobs.js +661 -0
  37. package/dist/server/core-views/skills-tree.js +628 -0
  38. package/dist/server/core-views/skills.js +1090 -0
  39. package/dist/server/index.js +88 -0
  40. package/dist/server/jobs-access.js +62 -0
  41. package/dist/server/notifications.js +24 -12
  42. package/dist/server/routes/extensions.js +9 -1
  43. package/dist/server/routes/jobs.js +19 -52
  44. package/dist/server/routes/meta.js +9 -0
  45. package/dist/server/routes/onboarding.js +13 -1
  46. package/dist/server/surfaces/service.js +38 -0
  47. package/dist/skills/index.js +1 -1
  48. package/dist/skills/roots.js +301 -0
  49. package/node_modules/@sigrun/shared/CHANGELOG.md +13 -0
  50. package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
  51. package/node_modules/@sigrun/shared/dist/auth.js +26 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
  53. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
  54. package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
  55. package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
  56. package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
  57. package/node_modules/@sigrun/shared/package.json +9 -1
  58. package/package.json +7 -7
  59. package/webui/dist/assets/index-BI5k-Oci.js +55 -0
  60. package/webui/dist/assets/index-EGluyRXk.css +1 -0
  61. package/webui/dist/favicon.svg +7 -5
  62. package/webui/dist/index.html +3 -3
  63. package/webui/dist/assets/index-BR0di0OG.css +0 -1
  64. package/webui/dist/assets/index-BzrA1T4g.js +0 -55
@@ -13,7 +13,16 @@
13
13
  * extension's unlisted direct write tools (a "remember this" path the
14
14
  * extension owns itself) are never intercepted, never gated, never
15
15
  * undone by the curator.
16
+ *
17
+ * `SKILL_MANAGE_READ_ACTIONS` (exported for the A8 wrap and the tool
18
+ * itself — ONE literal, the A7 dedupe): the `skill_manage` actions that
19
+ * are safe anywhere (`list`/`view` pass through outside a curator run).
16
20
  */
21
+ /** `skill_manage`'s read actions — safe anywhere, never intercepted. */
22
+ export const SKILL_MANAGE_READ_ACTIONS = new Set([
23
+ "list",
24
+ "view",
25
+ ]);
17
26
  /**
18
27
  * The registered tool names that are curation tools by core ownership.
19
28
  * Exported for the A8 wrap feed's seed (the feed starts with the core
@@ -63,6 +72,7 @@ export class CurationRegistry {
63
72
  export function buildCurationRegistry(bindings) {
64
73
  const targets = [];
65
74
  const seen = new Set();
75
+ const toolOwner = new Map();
66
76
  for (const binding of bindings) {
67
77
  for (const target of binding.targets) {
68
78
  const id = `${binding.extension}:${target.name}`;
@@ -70,6 +80,17 @@ export function buildCurationRegistry(bindings) {
70
80
  throw new Error(`duplicate curation target id "${id}" — the loader already rejected this; a second arrival is a boot bug`);
71
81
  }
72
82
  seen.add(id);
83
+ // A tool names ONE target (A8): two targets listing the same
84
+ // tool make `byTool` first-wins arbitrary and an undoReview
85
+ // silently un-actionable for the shadowed target. Fail loud
86
+ // at boot, the same discipline as the duplicate-id check.
87
+ for (const tool of target.tools) {
88
+ const owner = toolOwner.get(tool);
89
+ if (owner !== undefined) {
90
+ throw new Error(`curation tool "${tool}" is listed by both ${owner} and ${id} — a tool names one curation target`);
91
+ }
92
+ toolOwner.set(tool, id);
93
+ }
73
94
  targets.push({ ...target, id, extension: binding.extension });
74
95
  }
75
96
  }
@@ -41,7 +41,7 @@ import { copyJson } from "@earendil-works/chord";
41
41
  import { defineDoc, LiveDoc } from "@earendil-works/pi-durable";
42
42
  import { authorOfSubmission } from "./attribution.js";
43
43
  import { curationRun } from "./run-doc.js";
44
- import { CORE_CURATION_TOOLS } from "./targets.js";
44
+ import { CORE_CURATION_TOOLS, SKILL_MANAGE_READ_ACTIONS } from "./targets.js";
45
45
  import { SessionMetaDoc } from "../server/session-meta.js";
46
46
  /** The answer an owner-authored call receives (contract text). */
47
47
  export const HINT_NOTED_TEXT = "Noted. The curator will review this after your reply.";
@@ -63,11 +63,6 @@ export const HintsDoc = defineDoc({
63
63
  fork: "current",
64
64
  initial: () => ({ hints: [] }),
65
65
  });
66
- /** `skill_manage`'s read actions — safe anywhere, never intercepted. */
67
- const SKILL_MANAGE_READ_ACTIONS = new Set([
68
- "list",
69
- "view",
70
- ]);
71
66
  /**
72
67
  * Whether one call passes through to the original outside a curator
73
68
  * run: only `skill_manage`'s read actions. Every other curation-tool
@@ -330,12 +330,14 @@ export type CurationSkillChange = {
330
330
  };
331
331
  /**
332
332
  * One change-feed entry (structural mirror of the store's `FeedEntry`).
333
- * The undo-relevant fields (`before`, `after`) are opaque version ids.
333
+ * `id`, `before` and `after` are commit SHAs in the user's curation
334
+ * history repo (tabs+skills Task 9); `actor` widens to include
335
+ * `"outside"` for catch-up commits the store itself did not author.
334
336
  */
335
337
  export type CurationFeedEntry = {
336
338
  readonly id: string;
337
339
  readonly at: string;
338
- readonly actor: "curator" | "agent" | "user";
340
+ readonly actor: "curator" | "agent" | "user" | "outside";
339
341
  readonly action: string;
340
342
  readonly skill: string;
341
343
  readonly reason?: string;
@@ -655,7 +657,14 @@ interface NotificationEventBase {
655
657
  readonly user: string | undefined;
656
658
  readonly title: string;
657
659
  readonly body: string;
658
- /** `${server.publicUrl}/#/c/<conversationId>` when `server.publicUrl` is set; absent otherwise. */
660
+ /**
661
+ * A deep link into the webui, only ever set when `server.publicUrl` is
662
+ * configured: `${server.publicUrl}/#/c/<conversationId>` for
663
+ * conversation-carrying events, or the
664
+ * `${server.publicUrl}/#/ext/<slug>/<view>` link for an extension
665
+ * notice whose target is a declared view (with `?params=` when the
666
+ * target carries params). Absent otherwise.
667
+ */
659
668
  readonly url?: string;
660
669
  }
661
670
  /** One notification core delivers to every registered notifier (best-effort). */
@@ -71,6 +71,7 @@ import { packagesDirOf, selfPackageRoot } from "./packages.js";
71
71
  import { resolveHelper } from "../sandbox/helper.js";
72
72
  import { launchInJail } from "../sandbox/launch.js";
73
73
  import { buildUserPolicy, defaultModuleTrees } from "../sandbox/policy.js";
74
+ import { validateGrantPath } from "../sandbox/grants.js";
74
75
  import { lookupOsAccount } from "../sandbox/os-account.js";
75
76
  import { agentEnv } from "./agent-env.js";
76
77
  import { UserServices } from "./user-services.js";
@@ -81,10 +82,13 @@ import { createAdmissionGuard } from "./identity/users.js";
81
82
  import { sweepAccountLinks } from "./identity/link-sweep.js";
82
83
  import { effectiveOwner as resolveEffectiveOwner } from "./identity/primary-admin.js";
83
84
  import { SessionMetaDoc } from "../server/session-meta.js";
84
- import { learnedSkillsDir } from "../curation/paths.js";
85
+ import { learnedSkillsDir, userRepoSpec } from "../curation/paths.js";
85
86
  import { SkillStore } from "../curation/store.js";
86
87
  import { OverlayStore } from "../curation/overlays.js";
87
88
  import { UsageCounters } from "../curation/usage.js";
89
+ import { GitHistory } from "../history/git.js";
90
+ import { workspaceHistoryFor, workspaceRepoSpec, } from "../history/workspace-history.js";
91
+ import { migrateUserCuration } from "../curation/migrate.js";
88
92
  import { buildCurationRegistry } from "../curation/targets.js";
89
93
  import { createCurationHost } from "../curation/host.js";
90
94
  import { createCurationToolFeed, wrapExtensionCurationTools, } from "../curation/wrap.js";
@@ -286,6 +290,35 @@ export async function openHarness(config, options = {}) {
286
290
  coreRoot,
287
291
  userAreas: (user) => join(roots.dataDir, "users", user),
288
292
  };
293
+ // Curation seal revalidation (tabs/skills Task 8): stored workspace or
294
+ // grant paths that fail TODAY's validator (e.g. a pre-seal record whose
295
+ // workspace is the bare user area, which contains curation/) are
296
+ // refused per use by buildUserPolicy, silently stripping the agent's
297
+ // tools. Boot names every such record loudly with its repair (Task 14
298
+ // documents PATCH /v1/users/<u> {"workspace": "<userArea>/workspace"}).
299
+ for (const record of identity.users.list()) {
300
+ if (record.workspace !== undefined) {
301
+ const result = validateGrantPath(record.workspace, {
302
+ managed: managedPaths,
303
+ user: record.user,
304
+ });
305
+ if (!result.ok) {
306
+ console.warn(`identity: user "${record.user}" workspace ${record.workspace}: ${result.reason} (agent tools unavailable until repaired; re-point to ${managedPaths.userAreas(record.user)}/workspace)`);
307
+ }
308
+ }
309
+ for (const grant of record.grants ?? []) {
310
+ const result = validateGrantPath(grant.path, {
311
+ managed: managedPaths,
312
+ user: record.user,
313
+ ...(grant.inheritedFrom === undefined
314
+ ? {}
315
+ : { inheritedFrom: grant.inheritedFrom }),
316
+ });
317
+ if (!result.ok) {
318
+ console.warn(`identity: user "${record.user}" grant ${grant.path}: ${result.reason} (grant refused until repaired)`);
319
+ }
320
+ }
321
+ }
289
322
  const userServices = new UserServices({
290
323
  launch: (policy, command, args, serviceEnv) => {
291
324
  if (helper === undefined)
@@ -410,6 +443,13 @@ export async function openHarness(config, options = {}) {
410
443
  const skillStores = new Map();
411
444
  const overlayStores = new Map();
412
445
  const usageCounters = new Map();
446
+ const curationHistories = new Map();
447
+ // Per-user migration promise (review I3): store/overlay mutations
448
+ // await it via their `ready` seam, so a write during a pending
449
+ // migration lands as its OWN commit after the import — never
450
+ // swallowed by the migration's discard.
451
+ const migrations = new Map();
452
+ const gitHomeDir = join(roots.stateDir, "git-home");
413
453
  const curationOf = (user) => {
414
454
  if (user === undefined)
415
455
  return undefined;
@@ -423,6 +463,34 @@ export async function openHarness(config, options = {}) {
423
463
  return undefined;
424
464
  }
425
465
  const userArea = managedPaths.userAreas(user);
466
+ let history = curationHistories.get(user);
467
+ if (history === undefined) {
468
+ history = new GitHistory(userRepoSpec(userArea), {
469
+ gitHome: gitHomeDir,
470
+ now: () => clockNow(),
471
+ log: (line) => console.warn(`curation history (${user}): ${line}`),
472
+ });
473
+ curationHistories.set(user, history);
474
+ }
475
+ // Migration (tabs+skills Task 9): the boot sweep below covers the
476
+ // directory users boot saw; a user created AFTER boot migrates on
477
+ // this first construction. Never-in-a-render: curationOf is the
478
+ // store factory, not a render path.
479
+ if (!migrations.has(user)) {
480
+ // Once per process: the settled promise STAYS in the map. The
481
+ // "nothing" branch writes the marker itself (review C1), so the
482
+ // on-disk state guards restarts; the in-process map guards
483
+ // within one boot. Only a FAILURE re-arms (the next curationOf
484
+ // retries).
485
+ const migration = migrateUserCuration(userArea, history).then(() => undefined, (error) => {
486
+ console.warn(`curation migration (${user}) failed: ${String(error)}`);
487
+ migrations.delete(user);
488
+ });
489
+ migrations.set(user, migration);
490
+ }
491
+ const ready = async () => {
492
+ await migrations.get(user);
493
+ };
426
494
  let store = skillStores.get(user);
427
495
  if (store === undefined) {
428
496
  store = new SkillStore({
@@ -441,6 +509,8 @@ export async function openHarness(config, options = {}) {
441
509
  return undefined;
442
510
  },
443
511
  userExists: () => identity.users.get(user) !== undefined,
512
+ history,
513
+ ready,
444
514
  });
445
515
  skillStores.set(user, store);
446
516
  }
@@ -450,7 +520,8 @@ export async function openHarness(config, options = {}) {
450
520
  userArea,
451
521
  user,
452
522
  userExists: () => identity.users.get(user) !== undefined,
453
- feed: store,
523
+ history,
524
+ ready,
454
525
  });
455
526
  overlayStores.set(user, overlays);
456
527
  }
@@ -468,8 +539,55 @@ export async function openHarness(config, options = {}) {
468
539
  });
469
540
  usageCounters.set(user, usage);
470
541
  }
471
- return { store, overlays, usage };
542
+ return { store, overlays, usage, history };
543
+ };
544
+ // Workspace skills history (tabs+skills Task 10): the SECOND repo per
545
+ // user — `<workspace>/skills` under git, caught up on settle. Cached
546
+ // per (user, workspace path): a changed path is a new instance on the
547
+ // same gitDir, so one log follows the user across workspace moves.
548
+ const workspaceHistories = new Map();
549
+ const workspaceHistoryOf = (user) => {
550
+ const workspace = workspaceService?.workspaceRoot(user);
551
+ return workspaceHistoryFor(user, workspace, {
552
+ cache: workspaceHistories,
553
+ create: (resolved) => {
554
+ const history = new GitHistory(workspaceRepoSpec(managedPaths.userAreas(user), resolved), {
555
+ gitHome: gitHomeDir,
556
+ now: () => clockNow(),
557
+ log: (line) => console.warn(`workspace history (${user}): ${line}`),
558
+ });
559
+ // Task 12 race fix: settle the repo AT CONSTRUCTION, not on
560
+ // first use. Two concurrent first-uses (the view's catch-up +
561
+ // a write) each run prepare()'s init before either's commit:
562
+ // the later init lands between the earlier init and ITS
563
+ // commit — status reads an empty index, and the write lands
564
+ // with no commit. An eager empty catchUp (no-op on an
565
+ // existing repo, the import commit on a fresh one) makes
566
+ // every later use see a settled repo. Awaited BEFORE the
567
+ // instance returns so no first use can interleave with the
568
+ // init. Fail-soft like everything here: errors warn, never
569
+ // throw.
570
+ void (async () => {
571
+ try {
572
+ // settle(), never a catch-up: an init that COMMITTED
573
+ // would absorb a racing first edit under the init
574
+ // summary (the 2026-11-03 core/skills failure).
575
+ await history.settle();
576
+ }
577
+ catch (error) {
578
+ console.warn(`workspace history (${user}) init failed: ${String(error)}`);
579
+ }
580
+ })();
581
+ return history;
582
+ },
583
+ });
472
584
  };
585
+ // Curation history migration (tabs+skills Task 9): every directory
586
+ // user boot sees imports once. Users created after boot migrate on
587
+ // their first curationOf (the migratedUsers seam above).
588
+ for (const record of identity.users.list()) {
589
+ curationOf(record.user);
590
+ }
473
591
  const curationSkillsOptions = {
474
592
  ...skillsOptions,
475
593
  skillStore: (user) => curationOf(user)?.store,
@@ -665,6 +783,15 @@ export async function openHarness(config, options = {}) {
665
783
  },
666
784
  });
667
785
  perUserSlugs.push(...loadedPerUserSlugs);
786
+ // Fail-fast vs wait (polish A1, spec §5.2): the caller declared a
787
+ // server WILL bind the host bridge — mark every loaded seam host so
788
+ // pre-bind calls keep the bounded wait instead of rejecting at once.
789
+ // `createServer` repeats this on entry (idempotent) for rigs that
790
+ // never passed the option.
791
+ if (options.expectHostBridge === true) {
792
+ for (const host of seamHosts.values())
793
+ host.expectBridge();
794
+ }
668
795
  // The curation-target registry (curator P7): the loader's validated,
669
796
  // slug-attributed bindings folded into the ONE registry — the A8
670
797
  // wrapping feed (isCurationTool) and the A9 host.curation surface
@@ -935,6 +1062,7 @@ export async function openHarness(config, options = {}) {
935
1062
  pool: sandboxPool,
936
1063
  directory: identity.users,
937
1064
  managed: managedPaths,
1065
+ defaultWorkspacePath: config.workspace.path,
938
1066
  ownerOf: async (conversationId, read, ctx) => (await read.snapshot(SessionMetaDoc, conversationId, ctx))?.owner,
939
1067
  });
940
1068
  const workspacePrefs = defaultWorkspacePrefs(config.workspace.path);
@@ -1261,15 +1389,17 @@ export async function openHarness(config, options = {}) {
1261
1389
  const [newest] = await listRevisions(storage, jobId, BACKGROUND_CONTEXT);
1262
1390
  if (newest === undefined)
1263
1391
  return;
1392
+ // Resolve once: the primary admin can move between the two reads
1393
+ // (a live directory), so a single snapshot keeps the row
1394
+ // self-consistent.
1395
+ const effectiveOwner = jobEffectiveOwner(newest.owner);
1264
1396
  events.publish({
1265
1397
  type: "job.changed",
1266
1398
  job: {
1267
1399
  id: jobId,
1268
1400
  name: newest.spec.name,
1269
1401
  source: newest.source,
1270
- ...(jobEffectiveOwner(newest.owner) === undefined
1271
- ? {}
1272
- : { owner: jobEffectiveOwner(newest.owner) }),
1402
+ ...(effectiveOwner === undefined ? {} : { owner: effectiveOwner }),
1273
1403
  ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
1274
1404
  schedule: newest.spec.schedule,
1275
1405
  action: newest.spec.action,
@@ -1389,6 +1519,10 @@ export async function openHarness(config, options = {}) {
1389
1519
  for (const host of seamHosts.values())
1390
1520
  host.bindBridge(bridge);
1391
1521
  },
1522
+ expectHostBridge: () => {
1523
+ for (const host of seamHosts.values())
1524
+ host.expectBridge();
1525
+ },
1392
1526
  extensionRoutes: routeBindings,
1393
1527
  extensionCommands: commandBindings,
1394
1528
  extensionSurfaces: surfaceBindings,
@@ -1399,6 +1533,9 @@ export async function openHarness(config, options = {}) {
1399
1533
  skillRoots: extensionSkillRoots,
1400
1534
  curation,
1401
1535
  curationHost,
1536
+ curationOf,
1537
+ workspaceRootOf: (owner) => workspaceService?.workspaceRoot(owner),
1538
+ history: { workspace: workspaceHistoryOf },
1402
1539
  commandExpansion,
1403
1540
  eventBus,
1404
1541
  events,
@@ -66,6 +66,7 @@ import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions
66
66
  import { AmbientDoc } from "./ambient.js";
67
67
  import { toDurableContext } from "./context.js";
68
68
  import { beginUserDeletion } from "./stores.js";
69
+ import { HostBridgeUnavailableError } from "./seam-host.js";
69
70
  /** Project a SessionMeta doc value into the tracked basis. */
70
71
  function metaBasisOf(value) {
71
72
  const basis = {
@@ -121,6 +122,13 @@ export class EventBus {
121
122
  }
122
123
  #queueMax;
123
124
  #hookDeadlineMs;
125
+ /**
126
+ * The `${label}:${member}` pairs whose HostBridgeUnavailableError
127
+ * already logged (polish A1, spec §5.2): a serverless process fails
128
+ * fast on EVERY bridged handler call, so only the first per pair
129
+ * warns — repeats are swallowed (still resolving the handler slot).
130
+ */
131
+ #unavailableLogged = new Set();
124
132
  #idleThresholdMs;
125
133
  #queue = [];
126
134
  #settleHandlers = [];
@@ -1390,6 +1398,17 @@ export class EventBus {
1390
1398
  // A handler may throw synchronously; that is the same failure as a
1391
1399
  // rejected promise and must not escape into the drain loop.
1392
1400
  const failed = (error) => {
1401
+ if (error instanceof HostBridgeUnavailableError) {
1402
+ const key = `${label}:${error.member}`;
1403
+ if (this.#unavailableLogged.has(key)) {
1404
+ finish();
1405
+ return;
1406
+ }
1407
+ this.#unavailableLogged.add(key);
1408
+ console.warn("event-bus: %s handler failed: %s (further repeats for this member suppressed)", label, error.message);
1409
+ finish();
1410
+ return;
1411
+ }
1393
1412
  console.error("event-bus: %s handler failed:", label, error);
1394
1413
  finish();
1395
1414
  };
@@ -1536,6 +1536,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1536
1536
  // target, so a duplicate quarantines the later declarer instead of
1537
1537
  // shadowing (never last-wins).
1538
1538
  const curationIds = new Set();
1539
+ /** The qualified target id owning each claimed curation tool (A8's one-target-per-tool rule). */
1540
+ const curationToolOwners = new Map();
1539
1541
  // Channel id → slug (gateway C1): the same claimed-only-by-LOADED pattern
1540
1542
  // as `slugs`. Chat bindings are keyed by channel id, so a shadowing
1541
1543
  // adapter would inherit another adapter's chats — the collision
@@ -1770,6 +1772,10 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1770
1772
  // (the same name twice in one result) hits the check on
1771
1773
  // its SECOND arrival, quarantining before any install.
1772
1774
  const claimed = new Set();
1775
+ // This result's tool claims (the A8 one-target rule's
1776
+ // intra-list half — merged into the cross-extension map
1777
+ // only when the whole result survives).
1778
+ const claimedTools = new Map();
1773
1779
  for (const target of resolved.curationTargets) {
1774
1780
  const id = `${slug}:${target.name}`;
1775
1781
  if (curationIds.has(id) || claimed.has(id)) {
@@ -1780,6 +1786,19 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1780
1786
  if (unregistered !== undefined) {
1781
1787
  throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
1782
1788
  }
1789
+ // A tool names ONE target (A8): two targets — across any
1790
+ // extensions — listing the same tool leave `byTool`
1791
+ // first-wins arbitrary and the shadowed target's undos
1792
+ // un-actionable. Quarantine the later declarer. Intra-list
1793
+ // claims are LOCAL (`claimedTools`) until the whole result
1794
+ // validates — a quarantined extension never leaks a claim.
1795
+ for (const tool of target.tools) {
1796
+ const owner = curationToolOwners.get(tool) ?? claimedTools.get(tool);
1797
+ if (owner !== undefined) {
1798
+ throw new Error(`curation tool "${tool}" is listed by both ${owner} and ${id} — a tool names one curation target`);
1799
+ }
1800
+ claimedTools.set(tool, id);
1801
+ }
1783
1802
  }
1784
1803
  }
1785
1804
  if (resolved.gateway !== undefined) {
@@ -1883,6 +1902,9 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1883
1902
  });
1884
1903
  for (const target of resolved.curationTargets) {
1885
1904
  curationIds.add(`${slug}:${target.name}`);
1905
+ for (const tool of target.tools) {
1906
+ curationToolOwners.set(tool, `${slug}:${target.name}`);
1907
+ }
1886
1908
  }
1887
1909
  }
1888
1910
  if (resolved.jobs !== undefined) {
@@ -85,6 +85,13 @@ export const HOST_RUNTIME_MODULES = [
85
85
  "@earendil-works/pi-durable",
86
86
  "@earendil-works/chord",
87
87
  "typebox",
88
+ // The shared workspace is host-shipped too (wire + access contracts
89
+ // every extension family type against; @sigrun/recall is the first
90
+ // packages-dir extension importing it at RUNTIME — value imports like
91
+ // accessOf, not just types). Linked like the Earendil host modules so
92
+ // a packages-dir extension's bare `@sigrun/shared/*` import resolves
93
+ // against the copy the host runs (one shared, never a second copy).
94
+ "@sigrun/shared",
88
95
  SELF_MODULE_NAME,
89
96
  ];
90
97
  /**
@@ -45,5 +45,7 @@ export const RESERVED_EXTENSION_SLUGS = [
45
45
  "prompts",
46
46
  "packages",
47
47
  "git",
48
+ "git-home",
49
+ "history",
48
50
  "core",
49
51
  ];
@@ -29,6 +29,24 @@ export class SeamError extends Error {
29
29
  this.name = "SeamError";
30
30
  }
31
31
  }
32
+ /**
33
+ * A bridge-backed `host.*` member was called in a process that will never
34
+ * bind the host bridge (no `createServer`, no `expectHostBridge: true`).
35
+ * Fails fast instead of waiting out the bind deadline: the member and the
36
+ * calling extension are named so the embedder can either start a server
37
+ * or stop calling bridged members.
38
+ */
39
+ export class HostBridgeUnavailableError extends Error {
40
+ member;
41
+ caller;
42
+ constructor(member, caller) {
43
+ super(`seam host: ${member} — no server will bind the host bridge (caller: ${caller}); ` +
44
+ `start createServer or pass expectHostBridge: true to wait for a bind`);
45
+ this.name = "HostBridgeUnavailableError";
46
+ this.member = member;
47
+ this.caller = caller;
48
+ }
49
+ }
32
50
  /**
33
51
  * Build the seam host for one extension. `kinds` gates writes: a record
34
52
  * whose `kind`/`key` the extension never declared is rejected — the
@@ -59,7 +77,7 @@ function syncStoreForwarder(pending, member, user) {
59
77
  },
60
78
  });
61
79
  }
62
- export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services) {
80
+ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services, options) {
63
81
  const interactionKinds = new Set(kinds.interaction ?? []);
64
82
  const ambientKinds = new Set(kinds.ambient ?? []);
65
83
  // The scoped stores view, keyed on the CURRENT identity — the same
@@ -101,31 +119,57 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
101
119
  const bridgePromise = new Promise((resolve) => {
102
120
  bridgeReleased = resolve;
103
121
  });
122
+ // Fail-fast vs wait (polish A1, spec §5.2): `expectBridge()` declares a
123
+ // server WILL bind (fanned out from `OpenedHarness.expectHostBridge`,
124
+ // which `createServer` calls synchronously on entry). Until then a
125
+ // bridge-less process is treated as a serverless embed — calls reject
126
+ // at once instead of stalling the full hook deadline.
127
+ let bridgeBound = false;
128
+ let bridgeExpected = false;
104
129
  // The pre-bind bound: a bridge call that waits PAST this long fails
105
130
  // loud instead of hanging forever (the jobs-task fake-clock hang's
106
- // lesson). Production never sees it — `createServer` binds the bridge
107
- // at boot, long inside the bound — but a serverless embed that never
108
- // builds a server would otherwise await an eternally pending promise.
109
- const bridgeDeadlineMs = 60_000;
131
+ // lesson). Only reachable when a server is expected — `createServer`
132
+ // binds the bridge at boot, long inside the bound. Injectable so a
133
+ // test can exercise the deadline without a 60-second wait.
134
+ const bridgeDeadlineMs = options?.bridgeDeadlineMs ?? 60_000;
110
135
  const hostBridge = async (member) => {
111
136
  await bind.harness();
112
- return await Promise.race([
113
- bridgePromise,
114
- new Promise((_, reject) => {
115
- const timer = setTimeout(() => {
116
- reject(new Error(`seam host: ${member} — the bridge never bound after ${bridgeDeadlineMs}ms; is createServer running? (caller: ${identity.name})`));
117
- }, bridgeDeadlineMs);
118
- // The rejection timer never keeps the process alive.
119
- if (typeof timer === "object" && "unref" in timer)
120
- timer.unref();
121
- }),
122
- ]);
137
+ if (bridgeBound)
138
+ return await bridgePromise;
139
+ if (!bridgeExpected) {
140
+ throw new HostBridgeUnavailableError(member, identity.name);
141
+ }
142
+ // The deadline lane's timer is cleared the moment the bridge
143
+ // resolves — a successful bind never leaves a live 60-second
144
+ // rejection timer holding the process's event loop open.
145
+ let timer;
146
+ try {
147
+ return await Promise.race([
148
+ bridgePromise,
149
+ new Promise((_, reject) => {
150
+ timer = setTimeout(() => {
151
+ reject(new Error(`seam host: ${member} — the bridge never bound after ${bridgeDeadlineMs}ms; is createServer running? (caller: ${identity.name})`));
152
+ }, bridgeDeadlineMs);
153
+ // The rejection timer never keeps the process alive.
154
+ if (typeof timer === "object" && "unref" in timer)
155
+ timer.unref();
156
+ }),
157
+ ]);
158
+ }
159
+ finally {
160
+ if (timer !== undefined)
161
+ clearTimeout(timer);
162
+ }
123
163
  };
124
164
  // The curation surface's settled bridge host (curator P6/A9): memoized
125
165
  // HERE — in the host closure — so every `host.curation` getter call (a
126
166
  // FRESH proxy per read) shares one settled reference. See the getter's
127
167
  // own comment for the sync-member discipline this serves.
128
168
  let curationHost;
169
+ // The surfaces fire-and-forget lane logs an unavailable bridge ONCE per
170
+ // member (polish A1): the same fail-fast error every call, so repeats
171
+ // are noise — other failures keep their per-call console.error.
172
+ let surfacesUnavailableLogged = false;
129
173
  return {
130
174
  get stores() {
131
175
  return { ...stores, user: userStores };
@@ -519,12 +563,23 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
519
563
  bridge.surfaces.changed(identity.name, id, key, update);
520
564
  })
521
565
  .catch((error) => {
566
+ if (error instanceof HostBridgeUnavailableError) {
567
+ if (surfacesUnavailableLogged)
568
+ return;
569
+ surfacesUnavailableLogged = true;
570
+ console.warn(`seam host: surfaces.changed (${identity.name}) — ${error.message} (further repeats suppressed)`);
571
+ return;
572
+ }
522
573
  console.error(`seam host: surfaces.changed (${identity.name}/${id}) failed:`, error);
523
574
  });
524
575
  },
525
576
  },
526
577
  bindBridge(bridge) {
578
+ bridgeBound = true;
527
579
  bridgeReleased(bridge);
528
580
  },
581
+ expectBridge() {
582
+ bridgeExpected = true;
583
+ },
529
584
  };
530
585
  }
@@ -1,7 +1,7 @@
1
1
  /** Resolve each conversation's ExecutionEnv: single-user, jailed, or fail-closed. */
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
- import { buildUserPolicy } from "../sandbox/policy.js";
4
+ import { buildUserPolicy, validateUserPath } 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";
@@ -38,6 +38,39 @@ export function createWorkspaceService(deps) {
38
38
  }
39
39
  return join(managed.userAreas(record.user), "jobs");
40
40
  },
41
+ workspaceRoot(owner) {
42
+ if (deps === undefined)
43
+ return undefined;
44
+ const { directory, managed, isolation } = deps;
45
+ const records = directory.list();
46
+ const record = selectRecord(records, owner);
47
+ const active = isolation.active();
48
+ let osAccount;
49
+ if (record?.osUser !== undefined) {
50
+ if (deps.osUsers?.available !== true)
51
+ return undefined;
52
+ try {
53
+ osAccount = (deps.lookupAccount ?? lookupOsAccount)(record.osUser);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ if (osAccount === undefined)
59
+ return undefined;
60
+ }
61
+ // Rules 1+2: without active isolation a workspace root exists
62
+ // only in the single-record posture — forConversation resolves
63
+ // the same default there (and is unavailable with more users).
64
+ if (osAccount === undefined && !active) {
65
+ return records.length <= 1 ? deps.defaultWorkspacePath : undefined;
66
+ }
67
+ if (record === undefined)
68
+ return undefined;
69
+ const candidate = record.workspace ??
70
+ osAccount?.home ??
71
+ join(managed.userAreas(record.user), "workspace");
72
+ return validateUserPath(candidate, record, managed).ok ? candidate : undefined;
73
+ },
41
74
  async forConversation(conversation, prefs, read, ctx) {
42
75
  const workspace = prefs.defaultWorkspace;
43
76
  if (workspace.type !== "local") {