omp-conductor 0.15.12 → 0.16.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 (55) hide show
  1. package/REFERENCE.md +81 -6
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +6 -0
  4. package/src/admission.ts +745 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +93 -54
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +66 -34
  17. package/src/commands/unfreeze.ts +56 -0
  18. package/src/commands/watch.ts +77 -0
  19. package/src/config-schema.ts +9 -0
  20. package/src/config.ts +24 -0
  21. package/src/daemon.ts +485 -577
  22. package/src/dashboard/server.ts +2 -1
  23. package/src/decisions.ts +32 -7
  24. package/src/depends-on.ts +73 -0
  25. package/src/doctor.ts +418 -8
  26. package/src/escalate.ts +122 -15
  27. package/src/failure-class.ts +47 -0
  28. package/src/fleet.ts +55 -377
  29. package/src/gitops.ts +86 -1
  30. package/src/lifecycle.ts +113 -2
  31. package/src/log.ts +40 -0
  32. package/src/model-fallback.ts +3 -2
  33. package/src/omp-settings.ts +114 -0
  34. package/src/omp.ts +63 -0
  35. package/src/orchestrator-down.ts +231 -0
  36. package/src/orchestrator-tick.ts +14 -1
  37. package/src/orchestrator.ts +14 -0
  38. package/src/release-policy.ts +163 -18
  39. package/src/reports.ts +124 -12
  40. package/src/session-host.ts +6 -0
  41. package/src/setup-host.ts +386 -17
  42. package/src/setup-install.ts +40 -2
  43. package/src/setup-wizard.ts +314 -113
  44. package/src/setup.ts +58 -1
  45. package/src/status-render.ts +445 -0
  46. package/src/stop-provenance.ts +119 -0
  47. package/src/store.ts +533 -11
  48. package/src/types.ts +298 -4
  49. package/src/unblock.ts +1 -1
  50. package/src/upgrade-verify.ts +1 -1
  51. package/src/upgrade.ts +27 -8
  52. package/src/verbs/protocol.ts +16 -3
  53. package/src/verbs/server.ts +52 -1
  54. package/src/wizard-ui.ts +261 -46
  55. package/src/worker.ts +183 -10
package/src/lifecycle.ts CHANGED
@@ -25,6 +25,10 @@ import { spawn, spawnSync } from "node:child_process";
25
25
  import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { join } from "node:path";
28
+ // Type-only: erased at runtime, so the "free of every other module" property
29
+ // below survives — this module still opens no store, loads no config and runs
30
+ // no `gh`, and `stop`/`status` keep working when the config is broken.
31
+ import type { DaemonStopDraft } from "./types.ts";
28
32
 
29
33
  /**
30
34
  * The systemd unit name operators are expected to install for a supervised
@@ -480,6 +484,58 @@ export async function startDaemon(
480
484
  );
481
485
  }
482
486
 
487
+ /**
488
+ * The delivery facts a mediated stop/restart records at the exact chokepoint:
489
+ * the request's own provenance, how the stop is about to be delivered, and the
490
+ * daemon pid it targets (#378). The recorder is injected by the CLI callers
491
+ * (which own the store); this module stays free of it.
492
+ */
493
+ export interface StopDelivery {
494
+ /** The request facts built by the CLI (commands/stop.ts, commands/restart.ts). */
495
+ provenance: DaemonStopDraft;
496
+ /** Whether the stop is about to go through `systemctl` or a raw signal. */
497
+ via: "systemctl" | "signal";
498
+ /** The daemon pid being stopped, when one was known. */
499
+ pid?: number;
500
+ }
501
+
502
+ /** The injected recorder invoked immediately before a stop/restart is signalled. */
503
+ export type StopDeliveryFn = (stop: StopDelivery) => void;
504
+
505
+ /**
506
+ * The honest provenance for a daemon that received a stop with no mediated
507
+ * request anywhere: unattributed by construction — Linux exposes no sender
508
+ * identity for a signal, so the record must say "unattributed" rather than
509
+ * guess one — plus what the receiving process actually knows: its own pid,
510
+ * the moment (stamped by the store), the runtime directory, and the projects
511
+ * it serves with their live-run counts.
512
+ *
513
+ * Storage belongs at the caller: the daemon's SIGINT/SIGTERM path writes the
514
+ * returned draft through the store before/while draining. This builds the
515
+ * facts, store-free, so a raw `kill`/out-of-band `systemctl stop` — no
516
+ * conductor CLI in the delivery path at all — still leaves a durable row the
517
+ * next `omp-conductor status` can show.
518
+ */
519
+ export function externalStopProvenance(o: {
520
+ /** The daemon pid that received the signal, when known. */
521
+ daemonPid?: number;
522
+ /** The daemon's runtime directory (host paths only — never secrets). */
523
+ runtimeDir: string;
524
+ /** Every served project with its live-run count at signal time. */
525
+ affected: { project: string; live: number }[];
526
+ reason?: string;
527
+ }): DaemonStopDraft {
528
+ return {
529
+ controlPath: "external signal",
530
+ scope: "global",
531
+ ...(o.daemonPid === undefined ? {} : { daemonPid: o.daemonPid }),
532
+ runtimeDir: o.runtimeDir,
533
+ affected: o.affected,
534
+ reason: o.reason ?? "external signal — no mediated stop request was recorded",
535
+ unattributed: true,
536
+ };
537
+ }
538
+
483
539
  /**
484
540
  * How the last stop actually landed. Callers print this so an operator can
485
541
  * tell a supervised stop from a bare SIGTERM without reading the journal.
@@ -510,7 +566,35 @@ export interface RestartResult {
510
566
  * a stopped unit). The grace period is a deadline, not a clean drain: a tick
511
567
  * with a worker in flight can run for that worker's whole wall clock.
512
568
  */
513
- export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopResult> {
569
+ export async function stopDaemon(
570
+ o: {
571
+ timeoutMs?: number;
572
+ /**
573
+ * The request facts for this stop, when a mediated caller carries them.
574
+ * With `record`, the durable row is written immediately before the signal
575
+ * is sent — the exact ordering "provenance precedes signalling" refers to
576
+ * — and only when a stop actually lands: a request that finds no daemon
577
+ * stops nothing and records nothing. Drain-style callers whose restart may
578
+ * never execute additionally record the request at entry themselves.
579
+ */
580
+ provenance?: DaemonStopDraft;
581
+ /** Invoked just before the daemon is signalled, with the delivery method. */
582
+ record?: StopDeliveryFn;
583
+ } = {},
584
+ ): Promise<StopResult> {
585
+ const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
586
+ if (o.provenance === undefined || o.record === undefined) return;
587
+ o.record({
588
+ provenance: {
589
+ ...o.provenance,
590
+ controlPath: `${o.provenance.controlPath} via ${via}`,
591
+ ...(pid === undefined ? {} : { daemonPid: pid }),
592
+ },
593
+ via,
594
+ pid,
595
+ });
596
+ };
597
+
514
598
  const rec = livingDaemon();
515
599
  if (rec === undefined) {
516
600
  // `livingDaemon` already cleared a stale file; this covers the unparseable
@@ -521,6 +605,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
521
605
  throw new Error(ownershipUnknown("stop", decision.reason));
522
606
  }
523
607
  if (decision.kind === "stop") {
608
+ recordStop("systemctl", decision.mainPid);
524
609
  await runSystemdStop(decision.mainPid, o.timeoutMs);
525
610
  clearRecord();
526
611
  return { kind: "stopped", pid: decision.mainPid, via: "systemctl" };
@@ -536,6 +621,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
536
621
  throw new Error(ownershipUnknown("stop", decision.reason));
537
622
  }
538
623
  if (decision.kind === "stop") {
624
+ recordStop("systemctl", rec.pid);
539
625
  await runSystemdStop(rec.pid, o.timeoutMs);
540
626
  clearRecord();
541
627
  return { kind: "stopped", pid: rec.pid, via: "systemctl" };
@@ -544,6 +630,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
544
630
  // decision.kind === "not-ours": confirmed no unit, inactive unit, no systemd
545
631
  // binary, or a unit whose MainPID is somebody else. Only a *confirmed*
546
632
  // negative is safe to signal.
633
+ recordStop("signal", rec.pid);
547
634
  const gone = await terminate(rec.pid, o.timeoutMs ?? STOP_TIMEOUT_MS);
548
635
  if (!gone) {
549
636
  // The record stays: something is still holding that pid, and forgetting
@@ -577,8 +664,29 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
577
664
  * Returns the record of the process that is now answering `/healthz`.
578
665
  */
579
666
  export async function restartDaemon(
580
- o: { port?: number; project?: string; timeoutMs?: number } = {},
667
+ o: {
668
+ port?: number;
669
+ project?: string;
670
+ timeoutMs?: number;
671
+ /** Request facts and recorder, same semantics as {@link stopDaemon}: the
672
+ * durable row is written immediately before the restarting signal. */
673
+ provenance?: DaemonStopDraft;
674
+ record?: StopDeliveryFn;
675
+ } = {},
581
676
  ): Promise<RestartResult> {
677
+ const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
678
+ if (o.provenance === undefined || o.record === undefined) return;
679
+ o.record({
680
+ provenance: {
681
+ ...o.provenance,
682
+ controlPath: `${o.provenance.controlPath} via ${via}`,
683
+ ...(pid === undefined ? {} : { daemonPid: pid }),
684
+ },
685
+ via,
686
+ pid,
687
+ });
688
+ };
689
+
582
690
  const previous = livingDaemon();
583
691
  const ownership = probeUnit();
584
692
  if (ownership.kind === "unknown") {
@@ -591,6 +699,7 @@ export async function restartDaemon(
591
699
  // startDaemon() — that leaves the unit failed while handing the operator
592
700
  // a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
593
701
  // Any manager refusal is terminal; so is unproven ownership afterwards.
702
+ recordStop("systemctl", previous?.pid);
594
703
  const record = await restoreFailedUnit(o.timeoutMs, o.project);
595
704
  return { previous, record, via: "systemctl" };
596
705
  }
@@ -602,6 +711,7 @@ export async function restartDaemon(
602
711
  // Ownership is proven. A refused/timed-out restart must not fall through
603
712
  // to stopDaemon's signal path — that is the exact bounce this module exists
604
713
  // to prevent (SIGTERM → exit 143 → Restart=on-failure → new MainPID).
714
+ recordStop("systemctl", ownership.pid);
605
715
  const ran = systemctl(["restart", SYSTEMD_UNIT]);
606
716
  if (!ran.ok) {
607
717
  throw new Error(systemctlFailure("restart", ran));
@@ -615,6 +725,7 @@ export async function restartDaemon(
615
725
 
616
726
  // Confirmed unmanaged: no unit, an inactive unit, or a unit whose MainPID
617
727
  // is somebody else. The detached CLI daemon is the only path left.
728
+ recordStop("signal", previous?.pid);
618
729
  await stopDaemon({ timeoutMs: o.timeoutMs });
619
730
  const record = await startDaemon({ port: o.port ?? previous?.port, project: o.project ?? previous?.project });
620
731
  return { previous, record, via: "cli" };
package/src/log.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared daemon logging and best-effort escalation delivery.
3
+ *
4
+ * Split out of `daemon.ts` so the extracted admission module (and any future
5
+ * extracted concern) can log and escalate without importing the composition
6
+ * root back — which would be the circular-import failure mode the daemon split
7
+ * (#571) exists to rule out. `daemon.ts` composes; the leaf modules talk only
8
+ * to other leaves.
9
+ */
10
+
11
+ import { escalationIssueRef } from "./escalate.ts";
12
+ import type { Escalation } from "./types.ts";
13
+
14
+ /** One timestamped line to stderr — the daemon's canonical log sink. */
15
+ export function log(msg: string): void {
16
+ process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
17
+ }
18
+
19
+ /** A stable one-line rendering of an unknown thrown value. */
20
+ export function errText(e: unknown): string {
21
+ return e instanceof Error ? (e.stack ?? e.message) : String(e);
22
+ }
23
+
24
+ /**
25
+ * Best-effort escalation delivery: never let a failed page take down the
26
+ * pass, and say in the log that it failed. Structural `escalate` only — the
27
+ * caller's full dep object satisfies it without importing anything back.
28
+ */
29
+ export async function safeEscalate(
30
+ d: { escalate(e: Escalation): Promise<void> },
31
+ e: Escalation,
32
+ ): Promise<boolean> {
33
+ try {
34
+ await d.escalate(e);
35
+ return true;
36
+ } catch (err) {
37
+ log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
38
+ return false;
39
+ }
40
+ }
@@ -26,6 +26,7 @@ import type { FailureClass, RunRecord } from "./types.ts";
26
26
  export const FAILOVER_CLASSES: readonly FailureClass[] = [
27
27
  "provider-transient",
28
28
  "provider-credit",
29
+ "provider-capacity",
29
30
  ];
30
31
 
31
32
  /** The default for a project's `modelFallbackThreshold` when it is absent or
@@ -44,7 +45,7 @@ export interface ProviderFailureFacts {
44
45
  /** How many terminal runs at the head of the chain failed as a provider. */
45
46
  streak: number;
46
47
  /** Those runs' classes, newest first — the set is either one class or a mix
47
- * of `provider-transient` and `provider-credit`. */
48
+ * of `provider-transient`, `provider-credit` and `provider-capacity`. */
48
49
  classes: FailureClass[];
49
50
  /**
50
51
  * The model the most recent failure dispatched on, when its row recorded
@@ -97,7 +98,7 @@ export interface ModelChoice {
97
98
  * returned unchanged and `fallback` stays false — today's dispatch byte for
98
99
  * byte. Once the streak reaches the threshold the chain advances one slot per
99
100
  * extra failure, clamped to the last model: the chain is exhausted there, and
100
- * the existing escalation path (the provider-transient strike cap) settles it,
101
+ * the existing escalation path (the provider-class strike cap) settles it,
101
102
  * naming every model tried.
102
103
  */
103
104
  export function resolveDispatchModel(args: {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The fleet-owned omp settings overlay (#537).
3
+ *
4
+ * Workers inherit omp configuration from the daemon account's global
5
+ * `~/.omp/agent/config.yml` and nothing else. This module is the channel for
6
+ * saying "this project's workers run with *these* omp settings" without
7
+ * editing that fleet-wide file: a project's `ompSettings` map (an opaque
8
+ * settings tree the harness owns) is materialised to a YAML overlay *outside
9
+ * the worktree* — under the run's own session directory, alongside the
10
+ * transcript and the control socket — and threaded to the session through
11
+ * omp's own `Settings.init({ configFiles: [<path>] })` seam.
12
+ *
13
+ * Two properties make this channel distinct from the obvious alternative of
14
+ * writing `<worktree>/.omp/config.yml`:
15
+ *
16
+ * - The overlay is fleet-owned, never a file in the git checkout, so the
17
+ * diff a worker eventually ships is untouched and nothing needs an ignore
18
+ * entry. The worktree ignore block's standing rule — an ignored new file
19
+ * is invisible to salvage — is exactly why an in-tree staging was refused.
20
+ * - It is rewritten from config on every dispatch, so a config edit takes
21
+ * effect on the next attempt (and a resumed attempt reuses the kept
22
+ * session directory with the *current* config, not the one as of first
23
+ * provision).
24
+ *
25
+ * Conductor validates YAML shape only — omp owns the schema. An unknown key or
26
+ * a wrong-typed value is omp's to reject when it resolves the overlay, never
27
+ * conductor's to interpret.
28
+ */
29
+
30
+ import { unlinkSync, writeFileSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { stringify } from "yaml";
33
+ import { stateDir } from "./config.ts";
34
+ import type { ProjectConfig } from "./types.ts";
35
+
36
+ /** The overlay filename inside a run's session directory. */
37
+ export const OMP_SETTINGS_FILE = "omp-settings.yml";
38
+
39
+ /**
40
+ * The effective overlay map for a project: the opaque `ompSettings` map plus
41
+ * the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)
42
+ * derived from `modelFallbacks` — #539's staging half, moved onto this general
43
+ * channel so there is one way to stage settings into a worker session, not one
44
+ * seam per feature.
45
+ *
46
+ * `ompSettings.retry` is the operator's explicit word: when it is already a
47
+ * mapping, the derived retry block is skipped entirely (the overlay then says
48
+ * what it says, and the dispatch-failover chain #286 reads is unchanged — only
49
+ * omp's *retry-time* model swap respects the explicit stanza). Otherwise the
50
+ * derived block is merged in, preserving #539's behaviour exactly.
51
+ *
52
+ * Returns `undefined` when nothing is staged — a project with neither an
53
+ * `ompSettings` map nor a model-fallback chain gets no overlay at all, so its
54
+ * dispatch is byte-for-byte today's.
55
+ */
56
+ export function ompSettingsOverlay(
57
+ p: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
58
+ ): Record<string, unknown> | undefined {
59
+ const map: Record<string, unknown> = { ...(p.ompSettings ?? {}) };
60
+ const chain = p.modelFallbacks;
61
+ const explicitRetry =
62
+ typeof map.retry === "object" && map.retry !== null && !Array.isArray(map.retry);
63
+ if (chain !== undefined && chain.length > 0 && !explicitRetry) {
64
+ map.retry = {
65
+ modelFallback: true,
66
+ fallbackChains: { default: [...chain] },
67
+ };
68
+ }
69
+ return Object.keys(map).length === 0 ? undefined : map;
70
+ }
71
+
72
+ /**
73
+ * The directory every run's session dir lives under — where the overlay is
74
+ * materialised at dispatch. `doctor` probes its writability to report whether
75
+ * the next dispatch's overlay can actually land.
76
+ */
77
+ export function sessionRootDir(): string {
78
+ return join(stateDir(), "sessions");
79
+ }
80
+
81
+ /**
82
+ * Materialises a project's effective overlay to `<sessionDir>/omp-settings.yml`
83
+ * and returns its absolute path, or `undefined` when nothing is staged.
84
+ *
85
+ * When nothing is staged, any overlay a previous attempt wrote is removed, so
86
+ * a project that dropped its map leaves no stale file in a kept session dir —
87
+ * the resume path reuses that directory, and an orphaned overlay with nobody
88
+ * reading it would be the exact silent fake this channel exists to avoid.
89
+ *
90
+ * Re-runs every dispatch, so a kept worktree picks up a config change on the
91
+ * next attempt rather than at the next worktree creation. A non-staged
92
+ * project's dispatch degrades to the harness discovering settings exactly as
93
+ * it always has.
94
+ */
95
+ export function materializeOmpSettings(
96
+ project: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
97
+ sessionDir: string,
98
+ ): string | undefined {
99
+ const path = join(sessionDir, OMP_SETTINGS_FILE);
100
+ const overlay = ompSettingsOverlay(project);
101
+ if (overlay === undefined) {
102
+ try {
103
+ unlinkSync(path);
104
+ } catch {
105
+ // Nothing written — a fresh session dir, or the absent case on first
106
+ // dispatch. Either way there is no stale overlay to remove.
107
+ }
108
+ return undefined;
109
+ }
110
+ // 0600: the overlay can carry provider names and model selectors, and the
111
+ // session dir already holds the run's private channels.
112
+ writeFileSync(path, stringify(overlay), { mode: 0o600 });
113
+ return path;
114
+ }
package/src/omp.ts CHANGED
@@ -44,6 +44,14 @@ export interface AgentSessionLike {
44
44
  * `"*"` for every event. The payload is the harness's own event union, which
45
45
  * this package cannot name without the peer dependency, so it arrives as
46
46
  * `unknown` and each caller narrows the two or three fields it reads.
47
+ *
48
+ * One event is not the harness's at all: `"session_exit"` fires when the
49
+ * underlying session *process* has terminated, carrying
50
+ * `{ type: "session_exit", code?: number | null }`. It is the single real
51
+ * terminal signal — a crashed or killed session is never confused with one
52
+ * that merely stopped streaming (`"agent_end"`). A supervisor may receive it
53
+ * after its own `dispose()`, and must decide what an exit means from its own
54
+ * state rather than assuming every exit is a crash.
47
55
  */
48
56
  on(event: string, cb: (e: unknown) => void): void;
49
57
  abort(): void;
@@ -160,6 +168,17 @@ export async function createLocalSession(opts: {
160
168
  releaseGrants?: ResolvedGrants;
161
169
  /** Durable audit callback invoked only when that gate rejects a call. */
162
170
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
171
+ /**
172
+ * The fleet-owned omp settings overlay staged into this session's omp
173
+ * settings (#537): an absolute path to a YAML overlay the daemon materialised
174
+ * from the project's `ompSettings` map (plus the retry keys derived from
175
+ * `modelFallbacks`), passed to `Settings.init({ configFiles: [<path>] })` so
176
+ * it layers on top of the ordinary global/project discovery — never an
177
+ * isolated instance, which would drop providers and approvals out from under
178
+ * the worker. Absent, no settings are passed and the harness discovers
179
+ * exactly as it does today.
180
+ */
181
+ ompSettingsFile?: string;
163
182
  /**
164
183
  * The conductor verb socket this session's mutation tools call (#126).
165
184
  *
@@ -235,11 +254,31 @@ export async function createLocalSession(opts: {
235
254
  conductorVerbs(opts.verbSocketPath),
236
255
  ];
237
256
 
257
+ // The fleet-owned omp settings overlay (#537): when the daemon staged one,
258
+ // layer it into a Settings instance over the ordinary global/project
259
+ // discovery. `Settings.init({ cwd, configFiles })` keeps the daemon account's
260
+ // global config (providers, approvals, modelRoles) and any project
261
+ // `.omp/config.yml` intact — the overlay is deep-merged *after* them — and
262
+ // never `Settings.isolated()`, which would build from the overlay alone and
263
+ // drop everything out from under the worker. Absent a staged overlay, no
264
+ // settings are passed and the harness discovers exactly as it does today.
265
+ let overlaySettings: unknown = undefined;
266
+ if (opts.ompSettingsFile !== undefined) {
267
+ const Settings = Reflect.get(namespace, "Settings");
268
+ if (typeof Settings === "function" && typeof Settings.init === "function") {
269
+ overlaySettings = await Settings.init({
270
+ cwd: opts.cwd,
271
+ configFiles: [opts.ompSettingsFile],
272
+ });
273
+ }
274
+ }
275
+
238
276
  const created = await mod.createAgentSession({
239
277
  cwd: opts.cwd,
240
278
  // A raw pattern rather than a resolved Model: the harness resolves it
241
279
  // after extensions load, so we never have to import its model registry.
242
280
  ...(opts.model === undefined ? {} : { modelPattern: opts.model }),
281
+ ...(overlaySettings === undefined ? {} : { settings: overlaySettings }),
243
282
  sessionManager,
244
283
  // A private registry per session, never the process-global default: that
245
284
  // one admits only one "Main" identity per generation, so a second session
@@ -416,6 +455,13 @@ export interface CreateSessionOptions {
416
455
  role: SessionRole;
417
456
  releaseGrants?: ResolvedGrants;
418
457
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
458
+ /**
459
+ * Absolute path to the fleet-owned omp settings overlay (#537), forwarded to
460
+ * the child through the host spec so the far side's `createLocalSession`
461
+ * loads it via `Settings.init({ configFiles: [<path>] })`. Absent, nothing
462
+ * is staged and the session discovers settings as it does today.
463
+ */
464
+ ompSettingsFile?: string;
419
465
 
420
466
  /**
421
467
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -554,6 +600,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
554
600
  ...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
555
601
  ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
556
602
  ...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
603
+ ...(opts.ompSettingsFile === undefined ? {} : { ompSettingsFile: opts.ompSettingsFile }),
557
604
  };
558
605
 
559
606
  const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
@@ -714,8 +761,24 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
714
761
  pending.clear();
715
762
  };
716
763
 
764
+ // The one terminal event this proxy owns outright: the session process is
765
+ // gone, whether by crash, kill, or a clean dispose of its own. Delivered to
766
+ // subscribers *before* any failure is surfaced, so a supervisor learning of
767
+ // the death can also read its exit code off the same event. `disposing` is
768
+ // the proxy's own intent (its disposer asked the child to stop), not the
769
+ // subscriber's, so the event is emitted in both branches and each observer
770
+ // decides what an exit means from its own state.
771
+ const emitSessionExit = (code: number | null): void => {
772
+ const type = "session_exit";
773
+ const event = { type, code };
774
+ for (const cb of handlers.get(type) ?? []) cb(event);
775
+ for (const cb of handlers.get("*") ?? []) cb(event);
776
+ };
777
+
717
778
  void child.exited.then(async (code) => {
718
779
  onExit();
780
+ const exitCode = typeof code === "number" ? code : null;
781
+ emitSessionExit(exitCode);
719
782
  // A child that cannot start writes `start-error` over the socket, not the
720
783
  // pipes — and its exit can be dispatched before the accept of a connection
721
784
  // that already completed in the kernel. Let the socket settle before the