omp-conductor 0.3.13 → 0.3.16

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.
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: conductor-update
3
+ description: Safely update an installed omp-conductor fleet as one maintenance operation across both separately installed halves: the npm omp plugin and the GitHub herdr-conductor plugin. Use when the user asks to update, upgrade, refresh, reinstall, or deploy a newly published conductor version, or when installed conductor code is behind npm/main. Quiesces work, preserves exact pane identity, performs a whole-version swap, restarts through the supported lifecycle, re-arms through Telegram proof, and verifies the live fleet.
4
+ ---
5
+
6
+ # Update a conductor fleet
7
+
8
+ Treat an update as one operation across two independently installed plugins:
9
+
10
+ - `omp-conductor` comes from npm and owns dispatch, CLI, status, and heartbeat.
11
+ - `herdr-conductor` comes from `TerrifiedBug/conductor/herdr` and owns exact-pane recovery.
12
+
13
+ Updating only one half can leave a version that starts but cannot recover correctly.
14
+ This skill updates an installed fleet. It does **not** publish npm, merge a PR, tag a
15
+ release, or edit files in an install directory.
16
+
17
+ ## Safety boundary
18
+
19
+ Run this procedure from an operator shell or maintenance omp session that is not
20
+ hosted by the target `herdr-fleet.service`. If the current working directory
21
+ contains `.conductor-tick.json`, or stopping that unit would kill the session
22
+ executing this skill, stop and move the update to an external maintenance
23
+ session. A self-terminating updater cannot produce trustworthy verification.
24
+
25
+ Use the configured target host. Do not assume a hostname, state directory, Herdr
26
+ session name, project name, or systemd availability. Read them from the existing
27
+ installation and `omp-conductor status`. Never print bot tokens, npm tokens, or
28
+ authentication files.
29
+
30
+ ## 1. Establish the desired version
31
+
32
+ Read, do not guess:
33
+
34
+ ```bash
35
+ omp-conductor --version
36
+ version=$(npm view omp-conductor version)
37
+ gitHead=$(npm view "omp-conductor@$version" gitHead)
38
+ npm view "omp-conductor@$version" version gitHead --json
39
+ omp plugin list --json
40
+ omp-conductor status [--project NAME]
41
+ ```
42
+
43
+ Use the registry latest as `$version` unless the user explicitly names another
44
+ published version, then query that exact version as shown. Record its `gitHead`
45
+ and require a full commit SHA: the npm spec and Herdr ref below are both pinned,
46
+ so a concurrent release cannot mix two versions. If npm has no newer version, do
47
+ not churn the fleet: report that it is current. Confirm that `herdr` and
48
+ `systemctl` are present before taking anything down when status says Herdr
49
+ manages the pane.
50
+
51
+ ## 2. Quiesce without losing work
52
+
53
+ First stop new claims and heartbeat prompts:
54
+
55
+ ```bash
56
+ omp-conductor hold [--project NAME]
57
+ ```
58
+
59
+ Read status until `active runs (none)`. Do not kill workers to make the update
60
+ faster. If a run does not drain, report the run and stop; ordinary update authority
61
+ does not include discarding work.
62
+
63
+ Then stop the exact conductor pane and dispatch daemon through the supported,
64
+ fail-closed path:
65
+
66
+ ```bash
67
+ omp-conductor halt --pane [--project NAME]
68
+ ```
69
+
70
+ This writes the recovery pin before stopping the exact configured Herdr agent. If
71
+ identity is invalid, missing, or ambiguous, it refuses rather than killing a guess.
72
+ Do not bypass that refusal with `pkill`.
73
+
74
+ When `herdr-fleet.service` is installed, stop it before replacing either plugin:
75
+
76
+ ```bash
77
+ systemctl stop herdr-fleet.service
78
+ ```
79
+
80
+ At this point the daemon is stopped, the pane is stopped, and recovery remains
81
+ pinned. If any of those statements is false, do not modify the install.
82
+
83
+ ## 3. Replace both installed halves
84
+
85
+ Refresh the exact npm version using omp's package installer:
86
+
87
+ ```bash
88
+ omp plugin install "omp-conductor@$version"
89
+ ```
90
+
91
+ Then refresh the Herdr managed checkout from the exact npm release commit. Herdr
92
+ requires the GitHub source before its options:
93
+
94
+ ```bash
95
+ herdr plugin install TerrifiedBug/conductor/herdr --ref "$gitHead" --yes
96
+ ```
97
+
98
+ Do not use `git pull`, `scp`, a linked checkout, or edits under either plugin's
99
+ install root. This is a whole-version swap. If either install fails, leave the
100
+ fleet stopped and recovery pinned; report the failed command and do not continue
101
+ with a mixed live version.
102
+
103
+ ## 4. Start through the new lifecycle
104
+
105
+ Run the newly installed CLI:
106
+
107
+ ```bash
108
+ omp-conductor --version
109
+ omp-conductor start [--project NAME]
110
+ ```
111
+
112
+ `start` clears the recovery pin, starts the optional `herdr-fleet.service`, and
113
+ starts the dispatch daemon after a real `/healthz` check. Herdr then recovers the
114
+ exact orchestrator pane and requests an immediate tick; the verification below,
115
+ not the `start` command alone, proves that recovery completed.
116
+
117
+ Wait until `omp-conductor status` reports the exact pane `live`; `start` returning
118
+ only proves the service and dispatch daemon. `hold` deliberately disarmed ticks,
119
+ so restore unattended operation through the existing Telegram proof:
120
+
121
+ ```bash
122
+ omp-conductor arm [--project NAME]
123
+ ```
124
+
125
+ Wait for the operator's inbound Telegram reply. Never create the arm marker by
126
+ hand and never treat an outbound challenge as proof.
127
+
128
+ ## 5. Verify the live result
129
+
130
+ Require all of the following before reporting success:
131
+
132
+ ```bash
133
+ omp-conductor --version
134
+ npm view "omp-conductor@$version" version gitHead --json
135
+ omp plugin list --json
136
+ herdr plugin list
137
+ omp-conductor status [--project NAME]
138
+ systemctl is-active herdr-fleet.service
139
+ ```
140
+
141
+ - installed version and the npm package entry both equal `$version`;
142
+ - the Herdr plugin source revision equals the npm release `gitHead`;
143
+ - `dispatch` is `running`;
144
+ - `ticks` is `armed` and a next tick is shown;
145
+ - `pane` is `live` for the exact configured Herdr agent;
146
+ - `recovery` is `clear`;
147
+ - `herdr` is `active` when managed;
148
+ - Telegram is `ok` (or its exact supported degraded state is reported);
149
+ - daemon `/healthz` is `ok`;
150
+ - no unexpected active runs appeared during maintenance.
151
+
152
+ Run a second status check after the immediate recovery tick is consumed. A single
153
+ healthy snapshot is not proof that recovery and heartbeat scheduling survived the
154
+ swap.
155
+
156
+ Report the old and new versions, both plugin refreshes, the final layered status,
157
+ and any supported degraded state. Do not report success for a partial update.
@@ -573,7 +573,7 @@ export function formatBriefStatus(path: string, status: BriefStatus): string {
573
573
  ` floor package template → recomposed into ${status.orchestratorPath} each tick`,
574
574
  ` policy ${status.policyPath} (Learning loop / operator edits)`,
575
575
  "",
576
- "Protocol updates: npm install omp-conductor@… and restart — no brief-upgrade --apply.",
576
+ "Protocol updates: upgrade omp-conductor in this host's existing install root (same package manager), then restart the daemon — no brief-upgrade --apply.",
577
577
  "Legacy migrate: omp-conductor brief-upgrade --migrate",
578
578
  ].join("\n");
579
579
  }
@@ -3,8 +3,9 @@
3
3
  This file is the **package floor**: duties, tiers, hard boundaries, and the
4
4
  Learning loop. It ships inside `omp-conductor` and is re-rendered into your
5
5
  workspace on every tick (composed with `POLICY.md` as `ORCHESTRATOR.md` for the
6
- session). Protocol updates arrive with `npm install` you do not brief-upgrade
7
- the floor.
6
+ session). Protocol updates arrive when you upgrade the installed `omp-conductor`
7
+ package (same package manager / install root) and restart — you do not
8
+ brief-upgrade the floor.
8
9
 
9
10
  Fleet-specific policy — Releases, Project context, Reporting, Amendments — lives
10
11
  in **`POLICY.md`** beside this composed brief. The Learning loop edits
@@ -15,7 +15,7 @@ inside your own worktree.
15
15
  - **Code repo:** {{REPO}}
16
16
  - **Your worktree (cwd):** `{{WORKTREE}}`
17
17
  - **Your branch:** `{{BRANCH}}` — already created for you off the repo's default
18
- branch. Never switch branches and never touch a path outside the worktree.
18
+ branch. Never switch branches and never touch a path outside the worktree (write/edit/read/grep/glob are also blocked mechanically outside this checkout; `bash` is still a must-not — do not use it to escape).
19
19
 
20
20
  Read the issue first — it carries the acceptance criteria and any discussion the
21
21
  dispatcher did not copy down:
package/src/cli.ts CHANGED
@@ -21,12 +21,22 @@ import {
21
21
  writeMergedBrief,
22
22
  } from "./brief-upgrade.ts";
23
23
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
24
- import { dbPath, formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
24
+ import { dbPath, runDaemon, setPaused } from "./daemon.ts";
25
+ import {
26
+ armTicks,
27
+ clearPaneHalt,
28
+ disarmTicks,
29
+ halt,
30
+ haltWithPane,
31
+ hold,
32
+ releaseHold,
33
+ renderStatus,
34
+ startHerdrFleet,
35
+ } from "./fleet.ts";
25
36
  import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
26
37
  import {
27
38
  clearRecord,
28
39
  DEFAULT_PORT,
29
- healthCheck,
30
40
  livingDaemon,
31
41
  restartDaemon,
32
42
  startDaemon,
@@ -46,13 +56,29 @@ import { makeTracker } from "./tracker/github.ts";
46
56
  import type { ProjectConfig } from "./types.ts";
47
57
  import { formatUnblock, unblockIssue } from "./unblock.ts";
48
58
 
59
+ function packageVersion(): string {
60
+ const parsed = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as {
61
+ version?: unknown;
62
+ };
63
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
64
+ throw new Error("installed package.json has no version");
65
+ }
66
+ return parsed.version;
67
+ }
68
+
49
69
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
50
70
 
51
71
  usage:
52
72
  omp-conductor start [--port N] [--project NAME]
73
+ omp-conductor --version
53
74
  omp-conductor stop
54
75
  omp-conductor restart [--port N] [--project NAME]
55
76
  omp-conductor status [--project NAME]
77
+ omp-conductor hold [--project NAME]
78
+ omp-conductor halt [--pane] [--project NAME]
79
+ omp-conductor arm [--project NAME]
80
+ omp-conductor disarm [--project NAME]
81
+ omp-conductor release-pane [--project NAME]
56
82
  omp-conductor tail <issue> [--project NAME]
57
83
  omp-conductor unblock <issue> [--project NAME]
58
84
  omp-conductor daemon [--once] [--port N] [--project NAME]
@@ -62,9 +88,9 @@ usage:
62
88
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
63
89
  omp-conductor help
64
90
 
65
- start run the dispatch loop in the background and wait until it answers
66
- GET /healthz on :8787 (override with --port). Refuses if one is
67
- already running.
91
+ start start the installed herdr-fleet.service when present, then run the
92
+ dispatch loop in the background and wait until it answers GET
93
+ /healthz on :8787 (override with --port). Refuses if one is running.
68
94
  stop stop the running daemon. Uses systemctl when the omp-conductor
69
95
  unit owns the process (so Restart=on-failure cannot bring it back);
70
96
  otherwise SIGTERM then SIGKILL.
@@ -73,8 +99,23 @@ usage:
73
99
  dirty live worktrees before orphaning those rows — see README
74
100
  "Deploying a new package onto a busy fleet". Goes through systemctl
75
101
  when the unit owns the live pid.
76
- status show pause state, caps, active runs, today's usage, and whether a
77
- daemon is alive.
102
+ status layered fleet report: dispatch (running|paused|stopped), ticks and
103
+ next due time, pane, herdr, Telegram bot/API health, daemon, caps
104
+ and active runs.
105
+ hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
106
+ This is "stop the conductor overnight" without killing processes.
107
+ halt hold, then stop the dispatch daemon (systemctl-aware). Pane stays up
108
+ unless --pane is passed.
109
+ halt --pane
110
+ halt, then pin herdr-conductor recovery off for the conductor agent
111
+ only — does NOT stop herdr-fleet.service or any other herdr session.
112
+ Clear the pin with release-pane when you want recovery again.
113
+ arm proof-gated: send a Telegram challenge and write the arm marker only
114
+ after your reply appears as a user turn in the orchestrator transcript.
115
+ Never auto-armed by resume/hold.
116
+ disarm remove the arm marker so ticks skip. Processes untouched.
117
+ release-pane
118
+ clear the halt --pane recovery pin so herdr-conductor may resume again.
78
119
  tail follow the newest run for <issue>: the worker's assistant text and
79
120
  the tools it calls, printed as they land. Workers are sessions inside
80
121
  the daemon rather than terminals, so this is the only way to watch
@@ -87,8 +128,8 @@ usage:
87
128
  cost a worker.
88
129
  daemon run the dispatch loop in the foreground; --once runs a single tick
89
130
  and exits. This is what \`start\` launches.
90
- pause stop claiming new work. The running daemon notices on its next tick.
91
- resume allow claiming again.
131
+ pause stop claiming new work only (ticks keep firing if armed). Prefer hold.
132
+ resume clear pause only — does NOT re-arm. Prefer hold's inverse: resume + arm.
92
133
  graph-setup
93
134
  print how to set up the code-graph indexes workers query instead of
94
135
  grepping: the clone commands for any missing index-only clone, the
@@ -105,12 +146,22 @@ usage:
105
146
  --apply still merges a bannered single-file brief. --file checks a
106
147
  brief that is not where the wizard would have put it.
107
148
  help print this text (also --help, -h).
149
+ --version
150
+ print the installed omp-conductor package version (also -V, version).
108
151
 
109
152
  Pause is a flag file under the state directory, so it applies to every project
110
- and survives a daemon restart. A running daemon is tracked by a pidfile under
111
- $OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor), written
112
- whether it was started in the background or in the foreground, and probed for
113
- liveness on every read a stale one never blocks a start.`;
153
+ and survives a daemon restart. Hold also removes the arm marker the heartbeat
154
+ reads, so both brains go quiet without killing processes. A running daemon is
155
+ tracked by a pidfile under $OMP_CONDUCTOR_RUNTIME_DIR (default
156
+ ~/.omp/run/daemons/omp-conductor), written whether it was started in the
157
+ background or in the foreground, and probed for liveness on every read — a
158
+ stale one never blocks a start.
159
+
160
+ Stop the conductor:
161
+ hold no claims, no tick sends (inspectable)
162
+ halt hold + stop dispatch daemon
163
+ halt --pane halt + pin conductor-pane recovery off
164
+ resume && arm clear pause, then prove inbound Telegram before ticks resume`;
114
165
 
115
166
  /** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
116
167
  function flag(argv: string[], name: string): string | undefined {
@@ -146,26 +197,6 @@ function humanDuration(ms: number): string {
146
197
  return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
147
198
  }
148
199
 
149
- /**
150
- * The daemon half of `status`. Kept separate from `formatStatus` because the
151
- * pidfile and the endpoint are the CLI's business, not the dispatcher's, and
152
- * because a pid without a `/healthz` answer is a distinct — and interesting —
153
- * state: the process is up but the loop is not serving.
154
- */
155
- async function daemonSection(): Promise<string> {
156
- const rec = livingDaemon();
157
- if (rec === undefined) return "daemon not running";
158
- const health = await healthCheck(rec.port);
159
- return [
160
- "daemon",
161
- ` pid ${rec.pid}`,
162
- ` uptime ${humanDuration(Date.now() - rec.startedAt)}`,
163
- ` port ${rec.port}`,
164
- ...(rec.project === undefined ? [] : [` project ${rec.project}`]),
165
- ` healthz ${health.ok ? `ok ${health.body ?? ""}`.trimEnd() : "unreachable — the process is up but not serving"}`,
166
- ` log ${rec.logFile}`,
167
- ].join("\n");
168
- }
169
200
 
170
201
  /**
171
202
  * The orchestrator half, and the one thing `status` has ever known about the
@@ -362,6 +393,11 @@ const cmd = argv[0];
362
393
 
363
394
  try {
364
395
  switch (cmd) {
396
+ case "--version":
397
+ case "-V":
398
+ case "version":
399
+ process.stdout.write(`${packageVersion()}\n`);
400
+ break;
365
401
  case "daemon": {
366
402
  // Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
367
403
  // pidfile, which left a daemon started in the foreground (which is how
@@ -413,10 +449,14 @@ try {
413
449
  }
414
450
 
415
451
  case "start": {
416
- const rec = await startDaemon({ port: portFlag(argv), project: flag(argv, "project") });
452
+ const project = flag(argv, "project");
453
+ const herdr = startHerdrFleet(project);
454
+ const rec = await startDaemon({ port: portFlag(argv), project });
417
455
  process.stdout.write(
418
456
  `started — pid ${rec.pid}, /healthz on :${rec.port}` +
419
- `${rec.project === undefined ? "" : `, project ${rec.project}`}\nlog ${rec.logFile}\n`,
457
+ `${rec.project === undefined ? "" : `, project ${rec.project}`}\n` +
458
+ `herdr ${herdr.kind === "active" ? `active (${herdr.unit})${herdr.recoveryReleased ? "; recovery pin cleared" : ""}` : `unmanaged (${herdr.reason})`}\n` +
459
+ `log ${rec.logFile}\n`,
420
460
  );
421
461
  break;
422
462
  }
@@ -456,9 +496,84 @@ try {
456
496
  }
457
497
 
458
498
  case "status": {
459
- const snapshot = formatStatus(statusSnapshot(flag(argv, "project")));
499
+ const project = flag(argv, "project");
500
+ const text = await renderStatus(project);
460
501
  const stalled = stallLine();
461
- process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n${stalled === undefined ? "" : `\n${stalled}\n`}`);
502
+ process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
503
+ break;
504
+ }
505
+
506
+ case "hold": {
507
+ const r = hold(flag(argv, "project"));
508
+ process.stdout.write(
509
+ `held — claiming paused` +
510
+ `${r.wasPaused ? " (already paused)" : ""}` +
511
+ `; ticks disarmed at ${r.disarmed.path}` +
512
+ `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}\n` +
513
+ `daemon and pane left running; halt to stop the daemon\n`,
514
+ );
515
+ break;
516
+ }
517
+
518
+ case "halt": {
519
+ const project = flag(argv, "project");
520
+ const withPane = argv.includes("--pane");
521
+ if (withPane) {
522
+ const r = await haltWithPane(project);
523
+ const stopLine =
524
+ r.stop.kind === "not-running"
525
+ ? "daemon was not running"
526
+ : `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
527
+ process.stdout.write(
528
+ `halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
529
+ `${stopLine}\n` +
530
+ `pane recovery pinned at ${r.pane.pinPath}\n` +
531
+ `pane stop: ${r.pane.stopped} — ${r.pane.detail}\n` +
532
+ ` (conductor agent "${r.pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
533
+ ` release-pane clears the pin when you want recovery again)\n`,
534
+ );
535
+ } else {
536
+ const r = await halt(project);
537
+ const stopLine =
538
+ r.stop.kind === "not-running"
539
+ ? "daemon was not running"
540
+ : `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
541
+ process.stdout.write(
542
+ `halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
543
+ `${stopLine}\n` +
544
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
545
+ );
546
+ }
547
+ break;
548
+ }
549
+
550
+ case "arm": {
551
+ process.stdout.write("arm: sending inbound Telegram challenge…\n");
552
+ const r = await armTicks(flag(argv, "project"));
553
+ process.stdout.write(
554
+ `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
555
+ `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
556
+ );
557
+ break;
558
+ }
559
+
560
+ case "disarm": {
561
+ const r = disarmTicks(flag(argv, "project"));
562
+ process.stdout.write(
563
+ `disarmed — ticks will be skipped` +
564
+ `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
565
+ `marker ${r.path}\n`,
566
+ );
567
+ break;
568
+ }
569
+
570
+ case "release-pane": {
571
+ const r = clearPaneHalt(flag(argv, "project"));
572
+ process.stdout.write(
573
+ r.wasHalted
574
+ ? `pane recovery pin cleared — ${r.path}\nherdr-conductor may resume the fleet agent again\n`
575
+ : `no pane recovery pin at ${r.path}\n`,
576
+ );
462
577
  break;
463
578
  }
464
579
 
@@ -484,12 +599,18 @@ try {
484
599
 
485
600
  case "pause":
486
601
  setPaused(true);
487
- process.stdout.write("paused — no new work will be claimed\n");
602
+ process.stdout.write(
603
+ "paused — no new work will be claimed\n" +
604
+ "note: ticks keep firing if armed; use hold to silence both\n",
605
+ );
488
606
  break;
489
607
 
490
608
  case "resume":
491
- setPaused(false);
492
- process.stdout.write("resumed — work will be claimed on the next tick\n");
609
+ releaseHold();
610
+ process.stdout.write(
611
+ "resumed — claiming allowed on the next tick\n" +
612
+ "note: did NOT re-arm; run arm after an inbound Telegram proof to resume ticks\n",
613
+ );
493
614
  break;
494
615
 
495
616
  case "graph-setup": {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Mechanical worktree confinement for worker sessions.
3
+ *
4
+ * The harness has no first-class fs-policy field, but `createAgentSession`
5
+ * accepts inline `extensions` that subscribe to `tool_call` and can return
6
+ * `{ block: true }` before a tool runs (see the harness `protected-paths`
7
+ * example). Workers get that gate for structured file tools; the orchestrator
8
+ * does not — it has to read the state directory and briefs.
9
+ *
10
+ * `bash` is deliberately not confined here: its input is an opaque shell
11
+ * string, and parsing it is a false-sense of security. Closing that gap is a
12
+ * least-privilege uid (deployment), documented beside this module's README
13
+ * section — not a regex over `rm -rf`.
14
+ */
15
+
16
+ import { existsSync, realpathSync } from "node:fs";
17
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
+
19
+ /** Tools whose structured `path` (or path-like) field we can gate. */
20
+ const GATED = new Set(["write", "edit", "read", "grep", "glob"]);
21
+
22
+ /**
23
+ * Resolve `candidate` as a worker would, then ask whether it stays under
24
+ * `root`. Symlink-aware: existing path components are realpath'd so a link
25
+ * planted inside the worktree cannot escape by string-prefix tricks.
26
+ *
27
+ * A path that does not exist yet (a new write) realpaths the deepest existing
28
+ * ancestor and appends the rest — the same TOCTOU posture as the harness's
29
+ * own workspace confinement helper.
30
+ */
31
+ export function isInsideWorktree(root: string, candidate: string): boolean {
32
+ if (candidate.length === 0 || candidate.includes("\0")) return false;
33
+
34
+ let rootReal: string;
35
+ try {
36
+ rootReal = realpathSync(resolve(root));
37
+ } catch {
38
+ rootReal = resolve(root);
39
+ }
40
+
41
+ const abs = resolve(rootReal, candidate);
42
+
43
+ const missing: string[] = [];
44
+ let probe = abs;
45
+ while (!existsSync(probe)) {
46
+ const parent = dirname(probe);
47
+ if (parent === probe) break;
48
+ missing.unshift(basename(probe));
49
+ probe = parent;
50
+ }
51
+
52
+ let base: string;
53
+ try {
54
+ base = realpathSync(probe);
55
+ } catch {
56
+ base = probe;
57
+ }
58
+ const resolved = missing.length === 0 ? base : join(base, ...missing);
59
+
60
+ const rel = relative(rootReal, resolved);
61
+ // Inside ⇒ "" or a relative path that does not climb out. Absolute `rel` is
62
+ // a Windows drive mismatch; anything starting with `..` has left the root.
63
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
64
+ }
65
+
66
+ /** Pull the path-like field a gated tool carries, if any. */
67
+ export function pathFromToolInput(toolName: string, input: Record<string, unknown>): string | undefined {
68
+ if (!GATED.has(toolName)) return undefined;
69
+ const path = input.path;
70
+ if (typeof path === "string" && path.length > 0) return path;
71
+ // glob / grep sometimes scope via target_directory / path_filter; only a
72
+ // concrete directory root is confinable without inventing a glob parser.
73
+ const target = input.target_directory ?? input.cwd;
74
+ if (typeof target === "string" && target.length > 0) return target;
75
+ return undefined;
76
+ }
77
+
78
+ export type ConfineDecision = { block: true; reason: string };
79
+
80
+ /**
81
+ * Decide whether one tool_call may run. Undefined means "no opinion" (allow).
82
+ * Pure so tests pin the gate without standing up a harness session.
83
+ */
84
+ export function confineToolCall(
85
+ root: string,
86
+ toolName: string,
87
+ input: Record<string, unknown>,
88
+ ): ConfineDecision | undefined {
89
+ const path = pathFromToolInput(toolName, input);
90
+ if (path === undefined) return undefined;
91
+ if (isInsideWorktree(root, path)) return undefined;
92
+ return {
93
+ block: true,
94
+ reason:
95
+ `Blocked: ${toolName} path "${path}" is outside the worker worktree (${root}). ` +
96
+ `Structured file tools may only touch the assigned checkout; use paths under it.`,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Minimal extension surface this package needs. Kept duck-typed so the peer
102
+ * harness does not have to be on disk for `tsc` — same reason `omp.ts` exists.
103
+ */
104
+ export interface ConfinementPi {
105
+ on(
106
+ event: "tool_call",
107
+ handler: (
108
+ event: { toolName: string; input: Record<string, unknown> },
109
+ ctx: unknown,
110
+ ) => ConfineDecision | undefined | Promise<ConfineDecision | undefined>,
111
+ ): void;
112
+ }
113
+
114
+ /**
115
+ * Inline extension factory for `createAgentSession({ extensions: [...] })`.
116
+ * Installs the worktree gate on every structured file tool_call.
117
+ */
118
+ export function worktreeConfinement(root: string): (pi: ConfinementPi) => void {
119
+ const rootAbs = resolve(root);
120
+ return (pi) => {
121
+ pi.on("tool_call", (event) => confineToolCall(rootAbs, event.toolName, event.input));
122
+ };
123
+ }