omp-conductor 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
package/src/lifecycle.ts CHANGED
@@ -53,7 +53,7 @@ const READY_TIMEOUT_MS = 15_000;
53
53
  const READY_POLL_MS = 250;
54
54
 
55
55
  /** `/healthz` is a local, in-memory answer; a slow one means something is wrong. */
56
- const HEALTH_TIMEOUT_MS = 1_500;
56
+ export const HEALTH_TIMEOUT_MS = 1_500;
57
57
 
58
58
  /** Default grace period between `SIGTERM` and `SIGKILL`. */
59
59
  const STOP_TIMEOUT_MS = 10_000;
@@ -340,21 +340,52 @@ export function acquireOnceLease(
340
340
  throw new Error("could not acquire the daemon lease — a stale holder kept reappearing");
341
341
  }
342
342
 
343
+ /**
344
+ * Why a health probe came back negative. A timeout means the process is up
345
+ * but not answering (wedged or overloaded); a refusal means nothing is
346
+ * listening on the port at all. Everything else — a 500, a torn response —
347
+ * is `other`, a health answer rather than a probe verdict.
348
+ */
349
+ export type HealthCheckFailure = "timeout" | "refused" | "other";
350
+
351
+ export type HealthCheckResult =
352
+ | { ok: true; body?: string }
353
+ | { ok: false; failure: HealthCheckFailure; body?: string };
354
+
343
355
  /**
344
356
  * Probes the daemon's own health endpoint. Never throws: a refused connection,
345
357
  * a DNS-less host, a hung socket and a 500 are all just "not healthy", and the
346
- * callers of this are the ones responsible for saying so nicely.
358
+ * callers of this are the ones responsible for saying so nicely. The `failure`
359
+ * kind lets a caller tell "up but slow" (timeout) from "nothing listening"
360
+ * (refused) instead of collapsing both into `{ ok: false }` (#716).
347
361
  */
348
- export async function healthCheck(port: number): Promise<{ ok: boolean; body?: string }> {
362
+ export async function healthCheck(port: number): Promise<HealthCheckResult> {
349
363
  try {
350
364
  const res = await fetch(`http://127.0.0.1:${port}/healthz`, {
351
365
  signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
352
366
  });
353
367
  const body = (await res.text()).trim();
354
- return { ok: res.ok, ...(body.length > 0 ? { body } : {}) };
355
- } catch {
356
- return { ok: false };
368
+ if (!res.ok) {
369
+ return { ok: false, failure: "other", ...(body.length > 0 ? { body } : {}) };
370
+ }
371
+ return { ok: true, ...(body.length > 0 ? { body } : {}) };
372
+ } catch (err) {
373
+ return { ok: false, failure: healthFailureKind(err) };
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Classify a fetch rejection: the timeout signal raises `TimeoutError`; a
379
+ * refused connection is `ConnectionRefused` under Bun and `ECONNREFUSED`
380
+ * under undici. Anything else is an unclassified probe failure.
381
+ */
382
+ function healthFailureKind(err: unknown): HealthCheckFailure {
383
+ if (err instanceof Error && err.name === "TimeoutError") return "timeout";
384
+ const code = (err as { code?: unknown } | null)?.code;
385
+ if (code === "ConnectionRefused" || code === "ECONNREFUSED" || code === "ECONNRESET") {
386
+ return "refused";
357
387
  }
388
+ return "other";
358
389
  }
359
390
 
360
391
  /** Whether a `/healthz` body names `project` as served: the daemon's health
@@ -932,6 +963,127 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
932
963
  return ownership.kind === "active" ? ownership.pid : undefined;
933
964
  }
934
965
 
966
+ /**
967
+ * The run-control endpoint the `worker` verbs may address.
968
+ *
969
+ * `record` is the pidfile's own answer: the record port is authoritative
970
+ * because the daemon itself wrote it, and the record's `project` (when set)
971
+ * rides along so callers keep refusing a record pinned to another project
972
+ * exactly as before. `unit` is the systemd answer for a missing or stale
973
+ * record: the unit is active, its MainPID is alive, and `/healthz` on the
974
+ * probe port proved a conductor daemon serving `expectedProject` — the port
975
+ * was located, not assumed. `unserved` is that same live unit when no port
976
+ * proved itself: the daemon is running but its control address cannot be
977
+ * located, so callers must refuse rather than guess. `unknown` is the
978
+ * manager-failure answer: the unit's ownership could not be read (bus,
979
+ * permission, timeout), so neither running nor stopped is provable and the
980
+ * callers must refuse — collapsing that into `none` is how a control verb
981
+ * lies "daemon is not running" while the unit is up. `none` is the confirmed
982
+ * negative: no record, and no unit with a live MainPID.
983
+ */
984
+ export type DaemonControlTarget =
985
+ | { kind: "record"; pid: number; port: number; project?: string }
986
+ | { kind: "unit"; pid: number; port: number }
987
+ | { kind: "unserved"; pid: number; port: number }
988
+ | { kind: "unknown"; reason: string }
989
+ | { kind: "none" };
990
+
991
+ /**
992
+ * The port a unit-run daemon is probed on when the pidfile is absent: the
993
+ * generated unit always pins `--port` 8787 (`DEFAULT_PORT`), so that is the
994
+ * default candidate. The port is verified by `/healthz` before it is used —
995
+ * a listener is never trusted for being on the default port alone (#811).
996
+ *
997
+ * A test process redirects the probe with `OMP_CONDUCTOR_TEST_DAEMON_PORT`,
998
+ * the same seam discipline as `OMP_CONDUCTOR_TEST_SYSTEMCTL_STATE`: a CLI
999
+ * test cannot bind the real 8787 (the host's actual daemon may answer, the
1000
+ * #399 failure mode), so its fake unit daemon serves on an ephemeral port
1001
+ * that the state names.
1002
+ */
1003
+ function controlProbePort(): number {
1004
+ if (process.env["NODE_ENV"] === "test") {
1005
+ const override = process.env["OMP_CONDUCTOR_TEST_DAEMON_PORT"];
1006
+ if (override !== undefined && override.trim() !== "") {
1007
+ const port = Number.parseInt(override.trim(), 10);
1008
+ if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
1009
+ }
1010
+ }
1011
+ return DEFAULT_PORT;
1012
+ }
1013
+
1014
+ /**
1015
+ * Resolve the running daemon's pid + port for the run-control verbs, or prove
1016
+ * the daemon is not running. The pidfile is authoritative while the process
1017
+ * it names is alive; when it is missing or stale, the systemd unit is the
1018
+ * liveness witness — the same consultation `status` performs (#716): an
1019
+ * active unit whose MainPID is alive proves the daemon runs even though no
1020
+ * record pins its port. The port is never assumed: `/healthz` on the probe
1021
+ * port must prove a conductor daemon answering for `expectedProject` before
1022
+ * that address is offered as something to operate (#811).
1023
+ *
1024
+ * Never throws — the caller decides whether `unserved` (a live unit with no
1025
+ * proved endpoint) or `none` (nothing running) is the answer to give.
1026
+ */
1027
+ export async function daemonControlTarget(
1028
+ expectedProject?: string,
1029
+ ): Promise<DaemonControlTarget> {
1030
+ const record = livingDaemon();
1031
+ if (record !== undefined) {
1032
+ return {
1033
+ kind: "record",
1034
+ pid: record.pid,
1035
+ port: record.port,
1036
+ ...(record.project === undefined ? {} : { project: record.project }),
1037
+ };
1038
+ }
1039
+ const ownership = probeUnit();
1040
+ // A manager query the manager could not answer is not "no unit": collapsing
1041
+ // it into `none` is exactly how a control verb reports a live unit-owned
1042
+ // daemon stopped. The caller must refuse on the honest indeterminacy.
1043
+ if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
1044
+ if (ownership.kind !== "active" || !isAlive(ownership.pid)) return { kind: "none" };
1045
+ const port = controlProbePort();
1046
+ const health = await healthCheck(port);
1047
+ if (!health.ok || !healthServesProject(health.body, expectedProject)) {
1048
+ return { kind: "unserved", pid: ownership.pid, port };
1049
+ }
1050
+ return { kind: "unit", pid: ownership.pid, port };
1051
+ }
1052
+
1053
+ /**
1054
+ * The reachable { pid, port } a run-control verb may operate, or the refusal
1055
+ * that names why it cannot. The refusal language is one shared place so
1056
+ * `worker` and `extend` cannot drift apart on the same endpoint questions:
1057
+ * `none` is the plain "not running"; a record pinned to another project is
1058
+ * the same refusal the verbs always gave; an unprovable live unit and an
1059
+ * unknown manager state both fail closed instead of contacting anything or
1060
+ * claiming the daemon stopped.
1061
+ */
1062
+ export async function requireDaemonControl(
1063
+ expectedProject: string,
1064
+ ): Promise<{ pid: number; port: number }> {
1065
+ const target = await daemonControlTarget(expectedProject);
1066
+ if (target.kind === "none") throw new Error("daemon is not running");
1067
+ if (target.kind === "unknown") {
1068
+ throw new Error(
1069
+ `cannot determine whether ${SYSTEMD_UNIT} is running (${target.reason}) — ` +
1070
+ `refusing to send run control while liveness is unknown; ` +
1071
+ `retry when systemctl answers, or check \`systemctl status ${SYSTEMD_UNIT}\``,
1072
+ );
1073
+ }
1074
+ if (target.kind === "record" && target.project !== undefined && target.project !== expectedProject) {
1075
+ throw new Error(
1076
+ `daemon serves project "${target.project}", not requested project "${expectedProject}"`,
1077
+ );
1078
+ }
1079
+ if (target.kind === "unserved") {
1080
+ throw new Error(
1081
+ `daemon is running (${SYSTEMD_UNIT} pid ${target.pid}) but not serving /healthz for project "${expectedProject}" on port ${target.port} — no run-control endpoint could be located, so nothing was changed`,
1082
+ );
1083
+ }
1084
+ return { pid: target.pid, port: target.port };
1085
+ }
1086
+
935
1087
  // ---------------------------------------------------------------------------
936
1088
  // internals
937
1089
  // ---------------------------------------------------------------------------
package/src/omp.ts CHANGED
@@ -8,17 +8,23 @@
8
8
  * `createAgentSession`, `subscribe` or `abort`, exactly one file breaks.
9
9
  */
10
10
 
11
- /**
12
- * Harness package name. Held in a variable and cast to `string` at the call
13
- * site on purpose: a non-literal specifier stops `tsc` from trying to resolve
14
- * the module, which is the whole reason this shim exists.
15
- */
16
- import { chmodSync, chownSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { chmodSync, chownSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
17
12
  import { createServer, type Server, type Socket } from "node:net";
18
13
  import { tmpdir } from "node:os";
19
14
  import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
20
16
 
17
+ import {
18
+ OMP_HARNESS_PACKAGE,
19
+ packageNodeModulesRoot,
20
+ workerHarnessPath,
21
+ workerLaunchArgv,
22
+ workerSessionEnv,
23
+ type WorkerIdentity,
24
+ } from "./host.ts";
25
+ import { harnessVersion, resolveHarnessEntry } from "./harness-loader.ts";
21
26
  import { readOnlySession, worktreeConfinement } from "./confinement.ts";
27
+ import { observeGraphTools } from "./graph.ts";
22
28
  import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
23
29
  import type {
24
30
  HostToParent,
@@ -26,11 +32,53 @@ import type {
26
32
  SessionHostSpec,
27
33
  } from "./session-host.ts";
28
34
  import { decodeFrames, encodeFrame } from "./session-host.ts";
29
- import type { ResolvedGrants, SessionRole } from "./types.ts";
35
+ import type { GraphToolsObservation, ResolvedGrants, SessionRole } from "./types.ts";
30
36
  import { SESSION_ROLE_ENV } from "./types.ts";
31
37
  import { conductorVerbs } from "./verbs/client.ts";
32
38
 
33
- const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
39
+ /**
40
+ * Harness package name. Held in a variable and cast to `string` at the call
41
+ * site on purpose: a non-literal specifier stops `tsc` from trying to resolve
42
+ * the module, which is the whole reason this shim exists.
43
+ */
44
+ const OMP_PACKAGE = OMP_HARNESS_PACKAGE;
45
+
46
+ /**
47
+ * Why the explicitly anchored harness entry is not the one installed beside
48
+ * omp-conductor, or `undefined` when it is — or when there is nothing to
49
+ * compare, because this package is running from a source checkout with no
50
+ * install root of its own.
51
+ *
52
+ * The failure it names is silent otherwise (#828): Bun's ambient bare-package
53
+ * resolution can choose its install cache even when the worker binding is
54
+ * mounted and inode-current. The production resolver is therefore
55
+ * {@link resolveHarnessEntry}, rooted at this module's directory; this check
56
+ * remains the fail-closed attestation that the resolved absolute path stayed
57
+ * inside the same install.
58
+ */
59
+ export function harnessOutsideInstall(
60
+ moduleDir: string = import.meta.dir,
61
+ resolveSpecifier: (specifier: string) => string = () => resolveHarnessEntry(moduleDir),
62
+ ): string | undefined {
63
+ const root = packageNodeModulesRoot(moduleDir);
64
+ if (root === undefined) return undefined;
65
+ let resolved: string;
66
+ try {
67
+ const result = resolveSpecifier(OMP_PACKAGE);
68
+ resolved = result.startsWith("file:") ? fileURLToPath(result) : result;
69
+ } catch {
70
+ // Nothing resolved at all. The import below reports that far better than a
71
+ // guess here could, and reporting it twice in two spellings is worse.
72
+ return undefined;
73
+ }
74
+ if (resolved.startsWith(`${root}/`)) return undefined;
75
+ return (
76
+ `omp-conductor could not load its peer dependency ${OMP_PACKAGE} from its own installation: it resolves to ` +
77
+ `${resolved}, outside ${root}. Refusing Bun's ambient auto-install cache prevents a different harness build ` +
78
+ "and native addon from standing in for the operator's install. Run `omp-conductor setup host` to verify the " +
79
+ "worker binding, or install the harness alongside omp-conductor."
80
+ );
81
+ }
34
82
 
35
83
  /**
36
84
  * The only session surface the dispatcher is allowed to know about: send one
@@ -73,6 +121,39 @@ export interface AgentSessionLike {
73
121
  * downgrade, instead of it vanishing and a run just reading dumber.
74
122
  */
75
123
  modelFallbackMessage?: string;
124
+ /**
125
+ * The code-graph session observation (#726): whether the graph MCP tools
126
+ * were in this session's registry, as a one-shot async fact. The harness
127
+ * tool registry settles late in a session's life (MCP wiring finalises
128
+ * after creation), so the in-process session under {@link createLocalSession}
129
+ * exposes this promise; the session-host child relays the resolved value
130
+ * over the wire, and the proxy session {@link createSession} exposes the
131
+ * settled observation as {@link graphTools}. Resolving `undefined` means no
132
+ * observation could be recorded (surface absent or never readable) — never
133
+ * "graph tools absent", which is the `present: false` truth value.
134
+ */
135
+ graphToolsReady?: Promise<GraphToolsObservation | undefined>;
136
+ /**
137
+ * The code-graph session observation (#726): whether the graph MCP tools
138
+ * were in this session's registry. Set on the proxy session once the child's
139
+ * relayed observation lands. Absent means no observation was recorded — never
140
+ * "graph tools absent", which is the `present: false` truth value.
141
+ */
142
+ graphTools?: GraphToolsObservation;
143
+ /**
144
+ * The version of the omp-conductor module this session process actually
145
+ * LOADED, attested by the live process at session start (#832): the module
146
+ * tree this session's tools execute out of, read from the package this
147
+ * process resolved — never from the package files on disk after the fact.
148
+ * Absent only when the version cannot be read (a hand-built fake, a
149
+ * packaging oddity) — absence is not "the newest", and an upgrade that
150
+ * cannot see the loaded version must not claim the session reloaded.
151
+ */
152
+ extensionVersion?: string;
153
+ /** Absolute entry path of the harness module this session process loaded. */
154
+ harnessPath?: string;
155
+ /** Version read from the package owning {@link harnessPath}. */
156
+ harnessVersion?: string;
76
157
  }
77
158
 
78
159
  /**
@@ -197,16 +278,47 @@ export async function createLocalSession(opts: {
197
278
  * session has no route to a mutation to scope in the first place.
198
279
  */
199
280
  readOnly?: boolean;
281
+ /**
282
+ * The structured-settlement contract (#540): a JSON Schema the session's
283
+ * `yield` tool validates its `data` payload against. The worker yields its
284
+ * settlement as structured output instead of writing prose for the caller to
285
+ * regex. A subagent-oriented harness option, forwarded as-is.
286
+ */
287
+ outputSchema?: unknown;
288
+ /**
289
+ * Enforcement policy for {@link outputSchema}. The worker contract is
290
+ * `"permissive"`: a schema violation must still settle with whatever the
291
+ * worker produced, never discard the report.
292
+ */
293
+ outputSchemaMode?: "permissive" | "strict";
294
+ /**
295
+ * Force the hidden `yield` tool into this session's toolset (#540). Without
296
+ * it the model has no sanctioned way to submit structured output.
297
+ */
298
+ requireYieldTool?: boolean;
200
299
  }): Promise<AgentSessionLike> {
300
+ let harnessPath: string;
301
+ try {
302
+ // An explicit Bun-supported root is the resolver contract. A bare import
303
+ // from this same bound module was observed selecting the worker's ambient
304
+ // auto-install cache even while the peer beside it was readable (#828).
305
+ harnessPath = resolveHarnessEntry(import.meta.dir);
306
+ } catch (cause) {
307
+ throw new Error(
308
+ `omp-conductor could not resolve its peer dependency ${OMP_PACKAGE} from ${import.meta.dir}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
309
+ { cause },
310
+ );
311
+ }
312
+ const stray = harnessOutsideInstall(import.meta.dir, () => harnessPath);
313
+ if (stray !== undefined) throw new Error(stray);
201
314
  let loaded: unknown;
202
315
  try {
203
- // Dynamic import is load-bearing, not laziness: the harness is an optional
204
- // peer dependency that is absent when this package is type-checked or
205
- // published, so a static import would fail the build it must survive.
206
- loaded = await import(OMP_PACKAGE as string);
316
+ // Dynamic import is load-bearing: the runtime-selected absolute path pins
317
+ // the operator's peer without making it a build-time dependency.
318
+ loaded = await import(harnessPath);
207
319
  } catch (cause) {
208
320
  throw new Error(
209
- `omp-conductor could not load its peer dependency ${OMP_PACKAGE}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
321
+ `omp-conductor could not load its peer dependency ${OMP_PACKAGE} from ${harnessPath}. Install it alongside omp-conductor (it is deliberately not bundled, so the dispatcher runs the same harness build as the operator).`,
210
322
  { cause },
211
323
  );
212
324
  }
@@ -290,6 +402,13 @@ export async function createLocalSession(opts: {
290
402
  // agentDir (~/.omp/agent/mcp.json) — without this, workers grep-only and
291
403
  // burn the turns cap on discovery (#29).
292
404
  enableMCP: true,
405
+ // The structured-settlement contract (#540): subagent-oriented options
406
+ // threaded to the harness so the worker can yield its report as a schema-
407
+ // validated object instead of prose. Absent for every other session kind —
408
+ // probes and orchestrator sessions never yield.
409
+ ...(opts.outputSchema === undefined ? {} : { outputSchema: opts.outputSchema }),
410
+ ...(opts.outputSchemaMode === undefined ? {} : { outputSchemaMode: opts.outputSchemaMode }),
411
+ ...(opts.requireYieldTool === undefined ? {} : { requireYieldTool: opts.requireYieldTool }),
293
412
  // Mechanical tool gates: confinement prevents structured worktree I/O from
294
413
  // escaping cwd; release policy blocks release/deploy calls when configured.
295
414
  ...(extensions.length === 0 ? {} : { extensions }),
@@ -304,6 +423,19 @@ export async function createLocalSession(opts: {
304
423
  const modelFallbackMessage =
305
424
  typeof fallback === "string" && fallback !== "" ? fallback : undefined;
306
425
 
426
+ // The code-graph session observation (#726): whether the graph MCP tools are
427
+ // in this session's registry. The harness tool registry is structural and
428
+ // settles late — MCP wiring finalises through a deferred discovery pass after
429
+ // `createAgentSession` resolves, so the read can throw in the creation
430
+ // window, and the graph tools surface under the *enabled* names, never the
431
+ // active ones. `observeGraphTools` polls that surface until it stops
432
+ // throwing, bounded; an absent surface (older harness build) or an
433
+ // unreadable one records no observation — a registry answer is never
434
+ // guessed. The observation resolves asynchronously so the session handshake
435
+ // never waits for it.
436
+ const sessionTools = raw as { getEnabledToolNames?: () => string[] };
437
+ const graphToolsReady = observeGraphTools(sessionTools.getEnabledToolNames?.bind(sessionTools));
438
+
307
439
  // One real subscription fanned out per event type, so N `on()` calls cost one
308
440
  // listener on the harness stream and unknown event types cost nothing.
309
441
  const handlers = new Map<string, ((e: unknown) => void)[]>();
@@ -314,6 +446,7 @@ export async function createLocalSession(opts: {
314
446
  for (const cb of handlers.get("*") ?? []) cb(event);
315
447
  });
316
448
 
449
+ const loadedHarnessVersion = harnessVersion(harnessPath);
317
450
  const session: AgentSessionLike = {
318
451
  prompt: (text, promptOpts) => raw.prompt(text, promptOpts),
319
452
  on(event, cb) {
@@ -329,6 +462,12 @@ export async function createLocalSession(opts: {
329
462
  raw.abort({ goalReason: "interrupted", reason: "operator pause" }),
330
463
  ).then(() => undefined);
331
464
  },
465
+ // The loaded-module attestation (#832): this session process executes the
466
+ // conductor module it resolved at start; the version is a property of this
467
+ // process's loaded tree, deliberately not of what is on disk now.
468
+ ...(conductorModuleVersion() === undefined ? {} : { extensionVersion: conductorModuleVersion() }),
469
+ harnessPath,
470
+ ...(loadedHarnessVersion === undefined ? {} : { harnessVersion: loadedHarnessVersion }),
332
471
  // The path the session actually opened, never one we asked for: the
333
472
  // arm/monitor tooling reads this file as proof of activity, so a path
334
473
  // nothing ever writes to is worse than no path at all.
@@ -336,6 +475,7 @@ export async function createLocalSession(opts: {
336
475
  return raw.sessionFile;
337
476
  },
338
477
  ...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
478
+ graphToolsReady,
339
479
  };
340
480
 
341
481
  disposers.set(session, async () => {
@@ -414,6 +554,28 @@ function asRawSession(created: unknown): RawSession {
414
554
  /** Absolute path of the child entrypoint, resolved against this package. */
415
555
  export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
416
556
 
557
+ /**
558
+ * The version of the omp-conductor package this *process* loaded — the module
559
+ * tree this process's session machinery actually executes, read from the
560
+ * package.json next to `import.meta.dir` (#832). Read once per process and
561
+ * cached: the upgrade's session-reload proof compares this against the target
562
+ * release to tell a genuinely reloaded OMP process from a package install the
563
+ * live session never picked up. `undefined` when the file cannot be read —
564
+ * absence is a missing attestation, never "the newest".
565
+ */
566
+ let loadedConductorVersion: string | undefined;
567
+ export function conductorModuleVersion(): string | undefined {
568
+ if (loadedConductorVersion !== undefined) return loadedConductorVersion;
569
+ try {
570
+ const raw = readFileSync(join(import.meta.dir, "..", "package.json"), "utf8");
571
+ const parsed = JSON.parse(raw) as { version?: unknown };
572
+ loadedConductorVersion = typeof parsed.version === "string" ? parsed.version : undefined;
573
+ } catch {
574
+ loadedConductorVersion = undefined;
575
+ }
576
+ return loadedConductorVersion;
577
+ }
578
+
417
579
  /**
418
580
  * How long the parent waits for a child to connect and report `ready`.
419
581
  *
@@ -453,6 +615,18 @@ export interface CreateSessionOptions {
453
615
  model?: string;
454
616
  resume?: boolean;
455
617
  role: SessionRole;
618
+ /**
619
+ * The worker identity this session must run under (#798): when set, the
620
+ * child is launched through {@link workerLaunchArgv}'s identity transition
621
+ * (setpriv) before any of its code runs, its environment is re-pointed at
622
+ * the worker's own home/agent path, the control socket is granted to that
623
+ * uid, and the child refuses to build a session unless its kernel uid/gid
624
+ * match. The daemon resolves this identity once and threads it through every
625
+ * worker launch; a worker session is never launched without it. Absent, the
626
+ * session runs as this process's own identity — the orchestrator and every
627
+ * test surface.
628
+ */
629
+ identity?: WorkerIdentity;
456
630
  releaseGrants?: ResolvedGrants;
457
631
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
458
632
  /**
@@ -508,6 +682,12 @@ export interface CreateSessionOptions {
508
682
  * without a live harness or a model bill.
509
683
  */
510
684
  hostModule?: string;
685
+ /**
686
+ * Test-only worker binding root. Production always uses
687
+ * `WORKER_HARNESS_NODE_MODULES`; the Linux privilege regression supplies an
688
+ * inode-identical temporary binding so it never mutates the live host mount.
689
+ */
690
+ harnessNodeModules?: string;
511
691
  /**
512
692
  * Deny every tool but reading and searching (#307).
513
693
  *
@@ -515,6 +695,20 @@ export interface CreateSessionOptions {
515
695
  * gate — actually lives.
516
696
  */
517
697
  readOnly?: boolean;
698
+ /**
699
+ * The structured-settlement contract (#540): a JSON Schema the session's
700
+ * `yield` tool validates its `data` payload against. Forwarded to the child
701
+ * through the host spec, exactly like {@link ompSettingsFile}.
702
+ */
703
+ outputSchema?: unknown;
704
+ /**
705
+ * Enforcement policy for {@link outputSchema} — the worker contract is
706
+ * `"permissive"`, so a schema violation settles with whatever the worker
707
+ * produced instead of losing the report.
708
+ */
709
+ outputSchemaMode?: "permissive" | "strict";
710
+ /** Force the hidden `yield` tool into this session's toolset (#540). */
711
+ requireYieldTool?: boolean;
518
712
  }
519
713
 
520
714
  /**
@@ -587,13 +781,22 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
587
781
  );
588
782
  }
589
783
  // The socket is the run's own channel, and the daemon's own uid is the only
590
- // one that speaks on it.
784
+ // one that speaks on it — unless this session runs under the worker
785
+ // identity, in which case the child (and only the child) is the worker uid,
786
+ // and the socket must be granted to it or the very first connect fails
787
+ // before the identity could matter.
591
788
  chmodSync(socketPath, 0o600);
789
+ if (opts.identity !== undefined) {
790
+ chownSync(socketPath, opts.identity.uid, opts.identity.gid);
791
+ }
592
792
 
593
793
  const spec: SessionHostSpec = {
594
794
  socket: socketPath,
595
795
  cwd: opts.cwd,
596
796
  role: opts.role,
797
+ ...(opts.identity === undefined
798
+ ? {}
799
+ : { identity: { uid: opts.identity.uid, gid: opts.identity.gid } }),
597
800
  ...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
598
801
  ...(opts.model === undefined ? {} : { model: opts.model }),
599
802
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
@@ -601,17 +804,41 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
601
804
  ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
602
805
  ...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
603
806
  ...(opts.ompSettingsFile === undefined ? {} : { ompSettingsFile: opts.ompSettingsFile }),
807
+ ...(opts.outputSchema === undefined ? {} : { outputSchema: opts.outputSchema }),
808
+ ...(opts.outputSchemaMode === undefined ? {} : { outputSchemaMode: opts.outputSchemaMode }),
809
+ ...(opts.requireYieldTool === undefined ? {} : { requireYieldTool: opts.requireYieldTool }),
604
810
  };
605
811
 
606
812
  const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
607
- const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
813
+ const hostModule = opts.hostModule ?? SESSION_HOST;
814
+ // Under the worker identity the child launches from the read-only binding of
815
+ // the operator's install (#828). The child then resolves the harness from an
816
+ // explicit root and runs with `--no-install`; the entry remap and resolver
817
+ // anchor therefore name the same tree, while Bun has no ambient-cache
818
+ // fallback for either the peer or its transitive/native imports.
819
+ const packageRoot = packageNodeModulesRoot(hostModule);
820
+ const boundEntry =
821
+ opts.harnessNodeModules === undefined
822
+ ? workerHarnessPath(hostModule, packageRoot)
823
+ : workerHarnessPath(hostModule, packageRoot, opts.harnessNodeModules);
824
+ const entryModule = opts.identity === undefined ? hostModule : boundEntry ?? hostModule;
825
+ const payloadArgv = [process.execPath, "--no-install", entryModule, JSON.stringify(spec)];
826
+ const argv = workerLaunchArgv(payloadArgv, opts.identity);
827
+ // The session's own role is stamped on the child, so a process the agent
828
+ // runs — `omp-conductor report` from its sandbox — can tell a worker session
829
+ // from the operator's shell. Direct CLI runs outside a spawned session
830
+ // inherit nothing and stay the orchestrator surface. Under the worker
831
+ // identity, the child's HOME and agent/state roots are re-pointed at the
832
+ // worker account's own, so the harness it boots writes worker state, never
833
+ // the daemon's.
834
+ let env: Record<string, string | undefined> | undefined;
835
+ if (opts.role !== undefined) {
836
+ env = { ...process.env, [SESSION_ROLE_ENV]: opts.role };
837
+ if (opts.identity !== undefined) env = workerSessionEnv(env, opts.identity);
838
+ }
608
839
  const child = Bun.spawn(argv, {
609
840
  cwd: opts.cwd,
610
- // Stamp the session's own role on the child, so a process the agent runs —
611
- // `omp-conductor report` from its sandbox — can tell a worker session from
612
- // the operator's shell. Direct CLI runs outside a spawned session inherit
613
- // nothing and stay the orchestrator surface.
614
- env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
841
+ env,
615
842
  stdin: "ignore",
616
843
  // The harness writes progress to stdout; both streams are the daemon's log,
617
844
  // never the protocol. The protocol has its own socket precisely so a chatty
@@ -741,6 +968,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
741
968
  const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
742
969
  let sessionFile: string | undefined;
743
970
  let modelFallbackMessage: string | undefined;
971
+ let graphTools: GraphToolsObservation | undefined;
972
+ let extensionVersion: string | undefined;
973
+ let loadedHarnessPath: string | undefined;
974
+ let loadedHarnessVersion: string | undefined;
744
975
  let promptSeq = 0;
745
976
 
746
977
  const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
@@ -844,8 +1075,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
844
1075
  case "ready":
845
1076
  sessionFile = message.sessionFile;
846
1077
  modelFallbackMessage = message.modelFallbackMessage;
1078
+ extensionVersion = message.extensionVersion;
1079
+ loadedHarnessPath = message.harnessPath;
1080
+ loadedHarnessVersion = message.harnessVersion;
847
1081
  onReady();
848
1082
  break;
1083
+ case "graph-tools":
1084
+ graphTools = message.graphTools;
1085
+ break;
849
1086
  case "start-error":
850
1087
  fail(message.message);
851
1088
  break;
@@ -939,6 +1176,18 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
939
1176
  get modelFallbackMessage() {
940
1177
  return modelFallbackMessage;
941
1178
  },
1179
+ get graphTools() {
1180
+ return graphTools;
1181
+ },
1182
+ get extensionVersion() {
1183
+ return extensionVersion;
1184
+ },
1185
+ get harnessPath() {
1186
+ return loadedHarnessPath;
1187
+ },
1188
+ get harnessVersion() {
1189
+ return loadedHarnessVersion;
1190
+ },
942
1191
  };
943
1192
 
944
1193
  disposers.set(session, async () => {