omp-conductor 0.18.0 → 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 (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. package/src/worktree.ts +29 -12
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;
@@ -963,6 +963,127 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
963
963
  return ownership.kind === "active" ? ownership.pid : undefined;
964
964
  }
965
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
+
966
1087
  // ---------------------------------------------------------------------------
967
1088
  // internals
968
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
  /**
@@ -216,15 +297,28 @@ export async function createLocalSession(opts: {
216
297
  */
217
298
  requireYieldTool?: boolean;
218
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);
219
314
  let loaded: unknown;
220
315
  try {
221
- // Dynamic import is load-bearing, not laziness: the harness is an optional
222
- // peer dependency that is absent when this package is type-checked or
223
- // published, so a static import would fail the build it must survive.
224
- 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);
225
319
  } catch (cause) {
226
320
  throw new Error(
227
- `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).`,
228
322
  { cause },
229
323
  );
230
324
  }
@@ -329,6 +423,19 @@ export async function createLocalSession(opts: {
329
423
  const modelFallbackMessage =
330
424
  typeof fallback === "string" && fallback !== "" ? fallback : undefined;
331
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
+
332
439
  // One real subscription fanned out per event type, so N `on()` calls cost one
333
440
  // listener on the harness stream and unknown event types cost nothing.
334
441
  const handlers = new Map<string, ((e: unknown) => void)[]>();
@@ -339,6 +446,7 @@ export async function createLocalSession(opts: {
339
446
  for (const cb of handlers.get("*") ?? []) cb(event);
340
447
  });
341
448
 
449
+ const loadedHarnessVersion = harnessVersion(harnessPath);
342
450
  const session: AgentSessionLike = {
343
451
  prompt: (text, promptOpts) => raw.prompt(text, promptOpts),
344
452
  on(event, cb) {
@@ -354,6 +462,12 @@ export async function createLocalSession(opts: {
354
462
  raw.abort({ goalReason: "interrupted", reason: "operator pause" }),
355
463
  ).then(() => undefined);
356
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 }),
357
471
  // The path the session actually opened, never one we asked for: the
358
472
  // arm/monitor tooling reads this file as proof of activity, so a path
359
473
  // nothing ever writes to is worse than no path at all.
@@ -361,6 +475,7 @@ export async function createLocalSession(opts: {
361
475
  return raw.sessionFile;
362
476
  },
363
477
  ...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
478
+ graphToolsReady,
364
479
  };
365
480
 
366
481
  disposers.set(session, async () => {
@@ -439,6 +554,28 @@ function asRawSession(created: unknown): RawSession {
439
554
  /** Absolute path of the child entrypoint, resolved against this package. */
440
555
  export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
441
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
+
442
579
  /**
443
580
  * How long the parent waits for a child to connect and report `ready`.
444
581
  *
@@ -478,6 +615,18 @@ export interface CreateSessionOptions {
478
615
  model?: string;
479
616
  resume?: boolean;
480
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;
481
630
  releaseGrants?: ResolvedGrants;
482
631
  onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
483
632
  /**
@@ -533,6 +682,12 @@ export interface CreateSessionOptions {
533
682
  * without a live harness or a model bill.
534
683
  */
535
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;
536
691
  /**
537
692
  * Deny every tool but reading and searching (#307).
538
693
  *
@@ -626,13 +781,22 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
626
781
  );
627
782
  }
628
783
  // The socket is the run's own channel, and the daemon's own uid is the only
629
- // 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.
630
788
  chmodSync(socketPath, 0o600);
789
+ if (opts.identity !== undefined) {
790
+ chownSync(socketPath, opts.identity.uid, opts.identity.gid);
791
+ }
631
792
 
632
793
  const spec: SessionHostSpec = {
633
794
  socket: socketPath,
634
795
  cwd: opts.cwd,
635
796
  role: opts.role,
797
+ ...(opts.identity === undefined
798
+ ? {}
799
+ : { identity: { uid: opts.identity.uid, gid: opts.identity.gid } }),
636
800
  ...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
637
801
  ...(opts.model === undefined ? {} : { model: opts.model }),
638
802
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
@@ -646,14 +810,35 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
646
810
  };
647
811
 
648
812
  const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
649
- 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
+ }
650
839
  const child = Bun.spawn(argv, {
651
840
  cwd: opts.cwd,
652
- // Stamp the session's own role on the child, so a process the agent runs —
653
- // `omp-conductor report` from its sandbox — can tell a worker session from
654
- // the operator's shell. Direct CLI runs outside a spawned session inherit
655
- // nothing and stay the orchestrator surface.
656
- env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
841
+ env,
657
842
  stdin: "ignore",
658
843
  // The harness writes progress to stdout; both streams are the daemon's log,
659
844
  // never the protocol. The protocol has its own socket precisely so a chatty
@@ -783,6 +968,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
783
968
  const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
784
969
  let sessionFile: string | undefined;
785
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;
786
975
  let promptSeq = 0;
787
976
 
788
977
  const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
@@ -886,8 +1075,14 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
886
1075
  case "ready":
887
1076
  sessionFile = message.sessionFile;
888
1077
  modelFallbackMessage = message.modelFallbackMessage;
1078
+ extensionVersion = message.extensionVersion;
1079
+ loadedHarnessPath = message.harnessPath;
1080
+ loadedHarnessVersion = message.harnessVersion;
889
1081
  onReady();
890
1082
  break;
1083
+ case "graph-tools":
1084
+ graphTools = message.graphTools;
1085
+ break;
891
1086
  case "start-error":
892
1087
  fail(message.message);
893
1088
  break;
@@ -981,6 +1176,18 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
981
1176
  get modelFallbackMessage() {
982
1177
  return modelFallbackMessage;
983
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
+ },
984
1191
  };
985
1192
 
986
1193
  disposers.set(session, async () => {