omp-conductor 0.3.25 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/omp.ts CHANGED
@@ -13,9 +13,29 @@
13
13
  * site on purpose: a non-literal specifier stops `tsc` from trying to resolve
14
14
  * the module, which is the whole reason this shim exists.
15
15
  */
16
- import { worktreeConfinement } from "./confinement.ts";
17
- import { releasePolicyTripwire, type ReleaseShape } from "./release-policy.ts";
18
- import type { ReleasePolicy } from "./types.ts";
16
+ import { chmodSync, chownSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
17
+ import { createServer, type Server, type Socket } from "node:net";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+
21
+ import {
22
+ orchestratorConfinement,
23
+ orchestratorJailFromConfig,
24
+ worktreeConfinement,
25
+ type OrchestratorJail,
26
+ type OrchestratorRefusal,
27
+ } from "./confinement.ts";
28
+ import { recordConfinementRefusal } from "./confinement.ts";
29
+ import type { SessionBoundary } from "./credentials.ts";
30
+ import { releasePolicyTripwire } from "./release-policy.ts";
31
+ import type {
32
+ HostToParent,
33
+ ParentToHost,
34
+ SessionHostSpec,
35
+ } from "./session-host.ts";
36
+ import { decodeFrames, encodeFrame } from "./session-host.ts";
37
+ import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
38
+ import { conductorVerbs } from "./verbs/client.ts";
19
39
 
20
40
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
21
41
 
@@ -96,8 +116,15 @@ interface RawSession {
96
116
  const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
97
117
 
98
118
  /**
99
- * Start one omp coding session rooted at `cwd`, with a private agent registry
100
- * and a file-backed transcript of its own.
119
+ * Start one omp coding session **in this process**, rooted at `cwd`, with a
120
+ * private agent registry and a file-backed transcript of its own.
121
+ *
122
+ * This is the far side of the boundary, not the dispatcher's entry point.
123
+ * Production callers want {@link createSession}, which runs this same function
124
+ * inside a child process under the run's own OS principal (#125); the only
125
+ * caller of this one is `session-host.ts`, plus the tests that pin what it
126
+ * builds. Calling it directly from the daemon puts the session back in the
127
+ * credential-holding process, which is the thing #125 exists to stop.
101
128
  *
102
129
  * `sessionDir` chooses the directory the harness writes that transcript into;
103
130
  * omitted, the harness picks its default location for `cwd`. Either way the
@@ -113,21 +140,47 @@ const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
113
140
  * deployment mistake, and a stack trace about a failed dynamic import sends the
114
141
  * reader looking in the wrong place.
115
142
  */
116
- export async function createSession(opts: {
143
+ export async function createLocalSession(opts: {
117
144
  cwd: string;
118
145
  sessionDir?: string;
119
146
  model?: string;
120
147
  resume?: boolean;
121
148
  /**
122
- * Install the worktree `tool_call` gate so structured file tools cannot
123
- * leave `cwd`. Workers pass true; the orchestrator leaves this off it has
124
- * to read the state directory and briefs.
149
+ * What kind of session this is. Explicit rather than derived from the caller:
150
+ * it selects the worktree confinement gate *and* is the role the release gate
151
+ * compares grants against, and those two must never be able to disagree about
152
+ * whether a session is a worker (#122).
125
153
  */
126
- confineToCwd?: boolean;
127
- /** Install the release/deploy tool-call gate for this session. */
128
- releasePolicy?: ReleasePolicy;
154
+ role: SessionRole;
155
+ /** Install the release/deploy tool-call gate with these per-shape grants. */
156
+ releaseGrants?: ResolvedGrants;
129
157
  /** Durable audit callback invoked only when that gate rejects a call. */
130
158
  onReleaseBlocked?: (shape: ReleaseShape) => void;
159
+ /**
160
+ * The allowlist an orchestrator session is held to (#127). Omitted, it is
161
+ * derived from the config on disk — which is the only honest default, since
162
+ * a jail that silently resolved to "no jail" would leave the roots this gate
163
+ * exists to close wide open on the day a caller forgot the option.
164
+ */
165
+ orchestratorJail?: OrchestratorJail;
166
+ /**
167
+ * Where an orchestrator confinement refusal is recorded. Defaults to the
168
+ * durable audit in the daemon's state directory — which is exactly why it is
169
+ * injectable: under `uid-pool` this function runs as a principal that cannot
170
+ * write that directory, so {@link runSessionHost} substitutes a forwarder and
171
+ * the *parent* performs the write. A refusal the operator never learns about
172
+ * is indistinguishable from a session that behaved (#127).
173
+ */
174
+ onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
175
+ /**
176
+ * The conductor verb socket this session's mutation tools call (#126).
177
+ *
178
+ * The tools are registered either way. Omitted, every one of them fails
179
+ * closed with an explanation, which is the correct behaviour for a session
180
+ * whose daemon never gave it a channel — a tool that quietly vanished would
181
+ * leave the model to improvise `git push` instead.
182
+ */
183
+ verbSocketPath?: string;
131
184
  }): Promise<AgentSessionLike> {
132
185
  let loaded: unknown;
133
186
  try {
@@ -165,10 +218,27 @@ export async function createSession(opts: {
165
218
  // transcript is the only record of what the worker actually did.
166
219
  const sessionManager = await openSessionManager(mod, opts);
167
220
  const extensions = [
168
- ...(opts.confineToCwd ? [worktreeConfinement(opts.cwd)] : []),
169
- ...(opts.releasePolicy === undefined
221
+ // Two shapes, because the two sessions need different things: a worker is
222
+ // jailed to its checkout, while the orchestrator has to read the state
223
+ // directory and its briefs and is jailed to an allowlist instead (#127).
224
+ ...(opts.role === "worker"
225
+ ? [worktreeConfinement(opts.cwd)]
226
+ : [
227
+ orchestratorConfinement(
228
+ opts.orchestratorJail ?? orchestratorJailFromConfig(),
229
+ opts.cwd,
230
+ opts.onConfinementRefusal,
231
+ ),
232
+ ]),
233
+ ...(opts.releaseGrants === undefined
170
234
  ? []
171
- : [releasePolicyTripwire(opts.releasePolicy, opts.onReleaseBlocked)]),
235
+ : [releasePolicyTripwire(opts.releaseGrants, opts.role, opts.onReleaseBlocked)]),
236
+ // The sanctioned way back to GitHub (#126). A thin client: it forwards
237
+ // arguments over the run's own socket and renders what the daemon decided.
238
+ // Registered unconditionally, including on a session with no socket, so a
239
+ // missing channel is a named refusal rather than an absent tool the model
240
+ // routes around with bash.
241
+ conductorVerbs(opts.verbSocketPath),
172
242
  ];
173
243
 
174
244
  const created = await mod.createAgentSession({
@@ -299,3 +369,389 @@ function asRawSession(created: unknown): RawSession {
299
369
  }
300
370
  return candidate;
301
371
  }
372
+
373
+
374
+ // --------------------------------------------------------- the session proxy
375
+
376
+ /**
377
+ * Absolute path of the child entrypoint, resolved against this package.
378
+ *
379
+ * Exported so the boundary suite can assert a slot principal may actually read
380
+ * it. The launcher execs this path under the slot uid, and on a real deployment
381
+ * it sits under the daemon's home — so a home hardened to `0700` makes every
382
+ * worker die before it connects, in a way no shell-based probe notices (#125).
383
+ */
384
+ export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
385
+
386
+ /**
387
+ * How long the parent waits for a child to connect and report `ready`.
388
+ *
389
+ * Generous on purpose: the child loads the harness, discovers MCP servers and
390
+ * opens a transcript before it can answer, and on a cold cache that is seconds
391
+ * rather than milliseconds. The timeout is not a performance guard, it is the
392
+ * difference between "the launcher was rejected" surfacing as a named error and
393
+ * surfacing as a dispatch that never returns.
394
+ */
395
+ const START_TIMEOUT_MS = 180_000;
396
+
397
+ /** Bounded tail of the child's stderr, so a crash is legible without unbounded buffering. */
398
+ const STDERR_TAIL = 8_000;
399
+
400
+ /** How long a disposed child gets to exit before it is signalled. */
401
+ const DISPOSE_GRACE_MS = 5_000;
402
+
403
+ export interface CreateSessionOptions {
404
+ cwd: string;
405
+ sessionDir?: string;
406
+ model?: string;
407
+ resume?: boolean;
408
+ role: SessionRole;
409
+ releaseGrants?: ResolvedGrants;
410
+ onReleaseBlocked?: (shape: ReleaseShape) => void;
411
+ orchestratorJail?: OrchestratorJail;
412
+ onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
413
+ /**
414
+ * The OS principal and environment this session runs behind (#125). Omitted,
415
+ * the child runs as the daemon's own user with the daemon's environment —
416
+ * which is the A1 shape: out of process, no security claim. The daemon always
417
+ * passes one, and `status` reports which mechanism it names.
418
+ */
419
+ boundary?: SessionBoundary;
420
+ /**
421
+ * Where the control socket is bound. The daemon puts it inside the run's own
422
+ * boundary root so the slot principal can reach it; omitted, a private temp
423
+ * directory is used, which is what the tests and a `none` fleet want.
424
+ */
425
+ socketPath?: string;
426
+ /**
427
+ * The conductor verb socket this session's mutation tools call (#126).
428
+ *
429
+ * A different socket from `socketPath` above, and deliberately so: that one
430
+ * is the parent's own control channel to the child, this one is the child's
431
+ * only route back to a mutation, and collapsing them would make the daemon's
432
+ * session plumbing an attack surface for verbs.
433
+ */
434
+ verbSocketPath?: string;
435
+ /** Child stderr, line by line. Defaults to the process's own stderr. */
436
+ onChildLog?: (line: string) => void;
437
+ startupTimeoutMs?: number;
438
+ /**
439
+ * Test seam, and only that — the same shape as
440
+ * `OrchestratorOpts.createSessionImpl`. Production always wants
441
+ * `session-host.ts`; the parity suite points this at a child that calls
442
+ * {@link runSessionHost} with a hand-written session, so the proxy, the
443
+ * socket, the framing and the process lifecycle are all exercised for real
444
+ * without a live harness or a model bill.
445
+ */
446
+ hostModule?: string;
447
+ }
448
+
449
+ /**
450
+ * Start one omp coding session in a **child process** and return a proxy for it.
451
+ *
452
+ * The proxy is the whole of `omp-conductor`'s view of a session, and it is
453
+ * deliberately thin: {@link AgentSessionLike} has five members, so there are
454
+ * five things to forward and nothing else belongs here. `prompt` and `abort`
455
+ * go out over a unix socket, harness events come back and are re-emitted to the
456
+ * same `on()` subscribers the in-process version served, and `sessionFile` is
457
+ * whatever path the child reports the session actually opened — never one this
458
+ * side invented.
459
+ *
460
+ * Two behaviours that were free in-process and are bought explicitly here:
461
+ *
462
+ * - **A child outliving its parent.** The session used to die with the daemon
463
+ * because it *was* the daemon. Now the child watches its socket and exits
464
+ * when the parent goes away, so a daemon crash still leaves no orphaned
465
+ * session holding a checkout and a model budget.
466
+ * - **A wedged harness.** An in-process `abort()` that the harness ignored hung
467
+ * the daemon. Disposal here escalates: dispose message, then `SIGTERM`, then
468
+ * `SIGKILL`. The cap-kill path therefore always terminates.
469
+ *
470
+ * @throws if the child cannot be launched or the session cannot be built,
471
+ * carrying the child's own message — a missing peer dependency still reads as
472
+ * the deployment mistake it is, not as an unexplained exit code.
473
+ */
474
+ export async function createSession(opts: CreateSessionOptions): Promise<AgentSessionLike> {
475
+ const boundary = opts.boundary;
476
+ const owned = opts.socketPath === undefined;
477
+ // 0711, not 0700: a slot principal has to *traverse* to its own socket, and
478
+ // must not be able to list what else is in here or unlink the socket and
479
+ // bind its own. Same reasoning as the run workspace parent.
480
+ const socketDir = owned ? mkdtempSync(join(tmpdir(), "omp-session-")) : dirname(opts.socketPath ?? "");
481
+ const socketPath = opts.socketPath ?? join(socketDir, "s");
482
+ mkdirSync(socketDir, { recursive: true, mode: 0o711 });
483
+ chmodSync(socketDir, 0o711);
484
+ rmSync(socketPath, { force: true });
485
+
486
+ const server = createServer();
487
+ const { promise: attached, resolve: onAttach, reject: onAttachFail } =
488
+ Promise.withResolvers<Socket>();
489
+ server.once("connection", (socket: Socket) => {
490
+ socket.setNoDelay(true);
491
+ onAttach(socket);
492
+ });
493
+ server.once("error", (err: Error) => {
494
+ onAttachFail(err);
495
+ });
496
+ await new Promise<void>((resolve, reject) => {
497
+ server.once("error", reject);
498
+ server.listen(socketPath, () => {
499
+ resolve();
500
+ });
501
+ });
502
+ // The socket is the run's own channel: only its principal may speak on it.
503
+ chmodSync(socketPath, 0o600);
504
+ if (boundary?.principal !== undefined) {
505
+ chownSync(socketPath, boundary.principal.uid, boundary.principal.gid);
506
+ }
507
+
508
+ const spec: SessionHostSpec = {
509
+ socket: socketPath,
510
+ cwd: opts.cwd,
511
+ role: opts.role,
512
+ ...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
513
+ ...(opts.model === undefined ? {} : { model: opts.model }),
514
+ ...(opts.resume === undefined ? {} : { resume: opts.resume }),
515
+ ...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
516
+ // Resolved here rather than in the child: the child may not be able to read
517
+ // the config the jail is derived from, and a jail that fell back to
518
+ // defaults would silently widen the gate #127 closed.
519
+ ...(opts.role === "orchestrator"
520
+ ? { orchestratorJail: opts.orchestratorJail ?? orchestratorJailFromConfig() }
521
+ : {}),
522
+ ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
523
+ };
524
+
525
+ const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
526
+ const argv = [
527
+ ...(boundary?.launcher ?? []),
528
+ process.execPath,
529
+ opts.hostModule ?? SESSION_HOST,
530
+ JSON.stringify(spec),
531
+ ];
532
+ const child = Bun.spawn(argv, {
533
+ cwd: opts.cwd,
534
+ stdin: "ignore",
535
+ // The harness writes progress to stdout; both streams are the daemon's log,
536
+ // never the protocol. The protocol has its own socket precisely so a chatty
537
+ // harness build cannot corrupt it.
538
+ stdout: "pipe",
539
+ stderr: "pipe",
540
+ ...(boundary === undefined || Object.keys(boundary.env).length === 0 ? {} : { env: boundary.env }),
541
+ });
542
+
543
+ let stderrTail = "";
544
+ const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
545
+ if (stream === undefined) return;
546
+ const decoder = new TextDecoder();
547
+ let rest = "";
548
+ for await (const chunk of stream) {
549
+ rest += decoder.decode(chunk, { stream: true });
550
+ const lines = rest.split("\n");
551
+ rest = lines.pop() ?? "";
552
+ for (const line of lines) {
553
+ if (line === "") continue;
554
+ stderrTail = `${stderrTail}${line}\n`.slice(-STDERR_TAIL);
555
+ log(`${prefix}${line}`);
556
+ }
557
+ }
558
+ };
559
+ void drain(child.stdout, "session: ");
560
+ void drain(child.stderr, "session: ");
561
+
562
+ const cleanup = (): void => {
563
+ server.close();
564
+ rmSync(socketPath, { force: true });
565
+ if (owned) rmSync(socketDir, { recursive: true, force: true });
566
+ };
567
+
568
+ const handlers = new Map<string, ((e: unknown) => void)[]>();
569
+ const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
570
+ let sessionFile: string | undefined;
571
+ let modelFallbackMessage: string | undefined;
572
+ let promptSeq = 0;
573
+
574
+ const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
575
+ // Parked immediately. `ready` is rejected from the child's exit handler,
576
+ // which can fire before anything awaits it — and an unhandled rejection in a
577
+ // daemon that supervises sessions is a process-level crash, not a failed run.
578
+ // The real awaiters below still see the rejection.
579
+ ready.catch(() => undefined);
580
+ const { promise: exited, resolve: onExit } = Promise.withResolvers<void>();
581
+ /** Set once teardown has begun, so a clean exit is not reported as a crash. */
582
+ let disposing = false;
583
+
584
+ const fail = (message: string): void => {
585
+ const tail = stderrTail.trim();
586
+ const err = new Error(tail === "" ? message : `${message}\nchild output:\n${tail}`);
587
+ onReadyFail(err);
588
+ for (const [, waiter] of pending) waiter.reject(err);
589
+ pending.clear();
590
+ };
591
+
592
+ void child.exited.then((code) => {
593
+ onExit();
594
+ cleanup();
595
+ // A child that exits during teardown exited because we asked it to. Only an
596
+ // exit while the session is supposed to be live is a failure — and it has
597
+ // to reach whoever is awaiting a prompt, or the dispatcher waits forever
598
+ // for a turn from a process that is gone.
599
+ if (disposing) return;
600
+ fail(`omp-conductor session child exited ${String(code)} before the session ended`);
601
+ });
602
+
603
+ let socket: Socket;
604
+ try {
605
+ socket = await Promise.race([
606
+ attached,
607
+ new Promise<never>((_resolve, reject) => {
608
+ setTimeout(() => {
609
+ reject(
610
+ new Error(
611
+ `omp-conductor session child did not connect within ${String(
612
+ (opts.startupTimeoutMs ?? START_TIMEOUT_MS) / 1000,
613
+ )}s (launcher: ${argv.slice(0, Math.max(1, (boundary?.launcher ?? []).length)).join(" ") || "none"})`,
614
+ ),
615
+ );
616
+ }, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
617
+ }),
618
+ child.exited.then((code): never => {
619
+ throw new Error(`omp-conductor session child exited ${String(code)} before connecting`);
620
+ }),
621
+ ]);
622
+ } catch (err) {
623
+ child.kill("SIGKILL");
624
+ cleanup();
625
+ const tail = stderrTail.trim();
626
+ throw new Error(
627
+ `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
628
+ );
629
+ }
630
+
631
+ let buffer = "";
632
+ socket.on("data", (chunk: Buffer) => {
633
+ buffer += chunk.toString("utf8");
634
+ const { frames, rest } = decodeFrames(buffer);
635
+ buffer = rest;
636
+ for (const frame of frames) {
637
+ const message = frame as HostToParent;
638
+ switch (message.t) {
639
+ case "ready":
640
+ sessionFile = message.sessionFile;
641
+ modelFallbackMessage = message.modelFallbackMessage;
642
+ onReady();
643
+ break;
644
+ case "start-error":
645
+ fail(message.message);
646
+ break;
647
+ case "session-file":
648
+ sessionFile = message.path;
649
+ break;
650
+ case "event": {
651
+ const type = (message.event as { type?: unknown } | null | undefined)?.type;
652
+ if (typeof type !== "string") break;
653
+ for (const cb of handlers.get(type) ?? []) cb(message.event);
654
+ for (const cb of handlers.get("*") ?? []) cb(message.event);
655
+ break;
656
+ }
657
+ case "prompt-result": {
658
+ const waiter = pending.get(message.id);
659
+ if (waiter === undefined) break;
660
+ pending.delete(message.id);
661
+ if (message.ok) waiter.resolve();
662
+ else waiter.reject(new Error(message.error ?? "session prompt failed"));
663
+ break;
664
+ }
665
+ case "release-blocked":
666
+ opts.onReleaseBlocked?.(message.shape);
667
+ break;
668
+ case "confinement-refusal":
669
+ // Performed on this side because the child may not be able to write
670
+ // the state directory the audit lives in. Wrapped for the same reason
671
+ // the in-process version is: the audit is evidence, not the gate.
672
+ try {
673
+ (opts.onConfinementRefusal ?? recordConfinementRefusal)(message.refusal);
674
+ } catch {
675
+ // A full disk must not turn a deny into an allow.
676
+ }
677
+ break;
678
+ }
679
+ }
680
+ });
681
+
682
+ const write = (message: ParentToHost): void => {
683
+ if (!socket.writableEnded) socket.write(encodeFrame(message));
684
+ };
685
+
686
+ await Promise.race([
687
+ ready,
688
+ new Promise<never>((_resolve, reject) => {
689
+ setTimeout(() => {
690
+ reject(new Error("omp-conductor session child connected but never reported a session"));
691
+ }, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
692
+ }),
693
+ ]).catch((err: unknown) => {
694
+ child.kill("SIGKILL");
695
+ cleanup();
696
+ throw err;
697
+ });
698
+
699
+ const session: AgentSessionLike = {
700
+ prompt(text, promptOpts) {
701
+ promptSeq += 1;
702
+ const id = promptSeq;
703
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
704
+ pending.set(id, { resolve, reject });
705
+ write({ t: "prompt", id, text, ...(promptOpts === undefined ? {} : { opts: promptOpts }) });
706
+ // Resolves to `undefined`: no caller in this package reads a prompt's
707
+ // return value, and shipping the harness's own object across the wire
708
+ // would make this seam depend on a shape it deliberately does not name.
709
+ return promise.then(() => undefined);
710
+ },
711
+ on(event, cb) {
712
+ const list = handlers.get(event);
713
+ if (list) list.push(cb);
714
+ else handlers.set(event, [cb]);
715
+ },
716
+ abort() {
717
+ write({ t: "abort" });
718
+ },
719
+ get sessionFile() {
720
+ return sessionFile;
721
+ },
722
+ get modelFallbackMessage() {
723
+ return modelFallbackMessage;
724
+ },
725
+ };
726
+
727
+ disposers.set(session, async () => {
728
+ disposing = true;
729
+ write({ t: "dispose" });
730
+ socket.end();
731
+ const settled = await Promise.race([
732
+ exited.then(() => true),
733
+ new Promise<false>((resolve) => {
734
+ setTimeout(() => {
735
+ resolve(false);
736
+ }, DISPOSE_GRACE_MS).unref?.();
737
+ }),
738
+ ]);
739
+ if (!settled) {
740
+ // The escalation that makes a cap-kill final. In-process, a harness that
741
+ // ignored `abort()` hung the daemon; here it costs one signal.
742
+ child.kill("SIGTERM");
743
+ const stopped = await Promise.race([
744
+ exited.then(() => true),
745
+ new Promise<false>((resolve) => {
746
+ setTimeout(() => {
747
+ resolve(false);
748
+ }, DISPOSE_GRACE_MS).unref?.();
749
+ }),
750
+ ]);
751
+ if (!stopped) child.kill("SIGKILL");
752
+ }
753
+ cleanup();
754
+ });
755
+
756
+ return session;
757
+ }
@@ -47,7 +47,7 @@
47
47
  import { spawnSync } from "node:child_process";
48
48
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
49
  import { dirname, isAbsolute, join, resolve } from "node:path";
50
- import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
50
+ import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
51
51
  import {
52
52
  bridgeTokenBound,
53
53
  hasBotToken,
@@ -61,11 +61,19 @@ import {
61
61
  } from "./setup.ts";
62
62
  import {
63
63
  recordReleaseBlock,
64
- releaseDecision,
65
64
  releaseDriftDigestLine,
65
+ releaseRefusal,
66
+ releaseShapeFromTool,
66
67
  type ReleaseDecision,
67
68
  } from "./release-policy.ts";
68
- import { DEFAULT_RELEASE_POLICY, DEFAULT_REPORT_SCOPE, type FrictionSignal, type ReportScope, type Store } from "./types.ts";
69
+ import {
70
+ DEFAULT_REPORT_SCOPE,
71
+ DENIED_RELEASE_GRANTS,
72
+ type FrictionSignal,
73
+ type ReportScope,
74
+ type ResolvedGrants,
75
+ type Store,
76
+ } from "./types.ts";
69
77
  import { dbPath, openStore } from "./store.ts";
70
78
 
71
79
  /** The activation file. Absent means "this is not an orchestrator session". */
@@ -339,12 +347,21 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
339
347
  * so it is never such a turn, and a session that believes otherwise reports
340
348
  * into a void: on 2026-08-06 the fleet this extension runs produced a release
341
349
  * report and two tier-2 escalations as end-of-turn text, and not one of the
342
- * three reached anybody. What makes a report real is a tool call the session
343
- * watched succeed, so the prompt says so on every tick rather than trusting a
344
- * brief that can drift, be edited, or be compacted away.
350
+ * three reached anybody.
351
+ *
352
+ * The clause used to name `telegram_send`, and that held but it was still an
353
+ * instruction where a mechanism was needed: the same miss recurs whenever the
354
+ * session is distracted, compacted or interrupted mid-report, and an unsent
355
+ * report is indistinguishable from a quiet tick. #123 moved delivery to the
356
+ * daemon, so the clause now names the handover instead. `omp-conductor report`
357
+ * persists the text before anything is sent and the daemon retries it until it
358
+ * lands; a handover that returns a report id has therefore *already* survived
359
+ * the failure mode this rule exists for, which a watched `telegram_send` never
360
+ * could. `telegram_send` remains the right call for an interactive reply to a
361
+ * person who is waiting — that is a conversation, not a report.
345
362
  */
346
363
  export const TICK_DELIVERY_RULE =
347
- "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
364
+ "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a report id; the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
348
365
 
349
366
  /** Re-exported so the tick's own contract stays readable from one file: the
350
367
  * constant itself lives beside the check that decides whether it is callable. */
@@ -1289,8 +1306,8 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1289
1306
  };
1290
1307
  let releaseGateArmed = false;
1291
1308
  // An activation file makes this a fleet directory before Herdr can prove
1292
- // which pane owns it. The gate therefore starts closed and only honours an
1293
- // operator-brief policy after ownership is accepted.
1309
+ // which pane owns it. The gate therefore starts closed and only honours a
1310
+ // configured grant after ownership is accepted.
1294
1311
  let releaseAuthorityAccepted = false;
1295
1312
  const armReleaseGate = (): void => {
1296
1313
  if (releaseGateArmed) return;
@@ -1304,35 +1321,38 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1304
1321
  ) => ReleaseDecision | undefined,
1305
1322
  ): void;
1306
1323
  }).on("tool_call", (event) => {
1307
- // Most tool calls are ordinary tracker/file work. Detect shape first so
1308
- // they do not re-read config or emit policy diagnostics.
1309
- const candidate = releaseDecision(DEFAULT_RELEASE_POLICY, event.toolName, event.input);
1310
- if (candidate === undefined) return undefined;
1324
+ // Most tool calls are ordinary tracker/file work. Classify first so they
1325
+ // do not re-read config or emit policy diagnostics.
1326
+ const shape = releaseShapeFromTool(event.toolName, event.input);
1327
+ if (shape === undefined) return undefined;
1311
1328
  let projectName: string | undefined;
1312
- let policy = DEFAULT_RELEASE_POLICY;
1329
+ let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
1313
1330
  let external = true;
1314
1331
  try {
1315
1332
  const project = findProject(loadConfig());
1316
1333
  projectName = project.name;
1317
- policy = resolveReleasePolicy(project);
1334
+ grants = resolveReleaseGrants(project);
1318
1335
  external = project.escalation.orchestrator === "external";
1319
1336
  } catch (err) {
1320
1337
  // A missing/unreadable config cannot open a release gate. Log only when
1321
1338
  // a release-shaped call actually reaches this handler.
1322
1339
  pi.logger.error(
1323
- `[omp-conductor] release policy unreadable; enforcing ${DEFAULT_RELEASE_POLICY}: ${
1340
+ `[omp-conductor] release policy unreadable; denying every release shape: ${
1324
1341
  err instanceof Error ? err.message : String(err)
1325
1342
  }`,
1326
1343
  );
1327
1344
  }
1328
- if (releaseAuthorityAccepted && policy === "operator-brief") return undefined;
1345
+ // Per shape since #122: a fleet may hold `git-tag` and still be refused
1346
+ // `deploy`, so this asks about the shape in hand, not about one policy word.
1347
+ const refusal = releaseRefusal(grants, "orchestrator", shape);
1348
+ if (releaseAuthorityAccepted && refusal === undefined) return undefined;
1329
1349
  // Embedded orchestrators carry the same tripwire inline through
1330
1350
  // `createSession`; suppress this second copy only after this session has
1331
1351
  // proved it owns the external heartbeat.
1332
1352
  if (releaseAuthorityAccepted && !external) return undefined;
1333
1353
  if (projectName !== undefined) {
1334
1354
  try {
1335
- recordReleaseBlock(projectName, "orchestrator", candidate.shape);
1355
+ recordReleaseBlock(projectName, "orchestrator", shape);
1336
1356
  } catch (err) {
1337
1357
  pi.logger.error(
1338
1358
  `[omp-conductor] could not record release-policy block: ${
@@ -1341,7 +1361,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1341
1361
  );
1342
1362
  }
1343
1363
  }
1344
- return candidate.decision;
1364
+ // Before ownership is proved this session holds no grant at all, so a
1365
+ // covered shape still refuses — with the wording it would get from a
1366
+ // deny-all map rather than a claim about a grant it cannot yet use.
1367
+ return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
1345
1368
  });
1346
1369
  };
1347
1370