omp-conductor 0.19.1 → 0.19.3

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/REFERENCE.md CHANGED
@@ -2225,7 +2225,7 @@ Field notes:
2225
2225
  | `tracker.repo` | `owner/repo`. `tracker.kind` may be omitted; `"github"` is the only accepted value. |
2226
2226
  | `queueLabel` | The one label meaning "a human has signed this off as agent-ready". Matched exactly, case-sensitively. |
2227
2227
  | `release.versionFile` | Optional, per repo: a repo-relative JSON file with a top-level string `version`, such as `omp/package.json`. Declares that tags must match the version already landed on the live default branch. A delegated `git-tag` for such a repo requires delegated `version-bump-pr` too; otherwise config loading fails with the missing preparation path instead of granting an impossible release. Absolute paths and `..` are refused. |
2228
- | `groomBelow` | Optional; default `4`. Routable candidates below this count make the orchestrator's tick prompt say the queue is running low and to groom it (Duty 2). An integer ≥ 1; anything else degrades to the default. |
2228
+ | `groomBelow` | Optional; default `4`. Routable candidates below this count make the orchestrator's tick prompt say the queue is running low and to groom it (Duty 2). An integer ≥ 1, or the literal `"always"` (#988) — groom on demand every tick while any ungroomed, unrefused, unparked candidate exists, whatever the queue volume. Anything else fails the config load. |
2229
2229
  | `stateLabels` | Optional; defaults to `agent:in-progress`, `agent:blocked`, `agent:failed`. |
2230
2230
  | `routing.labelPrefix` | Optional; defaults to `repo:`. |
2231
2231
  | `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "check": "tsc --noEmit && bun run src/check-trailing-newlines.ts",
32
+ "check": "tsc --noEmit && bun run src/check-trailing-newlines.ts && bun run src/check-browser-js.ts",
33
33
  "test": "bun test",
34
34
  "schema": "bun run src/generate-schema.ts"
35
35
  },
@@ -171,7 +171,19 @@
171
171
  "type": "string",
172
172
  "minLength": 1
173
173
  },
174
- "groomBelow": {},
174
+ "groomBelow": {
175
+ "anyOf": [
176
+ {
177
+ "type": "integer",
178
+ "minimum": 1,
179
+ "maximum": 9007199254740991
180
+ },
181
+ {
182
+ "type": "string",
183
+ "const": "always"
184
+ }
185
+ ]
186
+ },
175
187
  "stateLabels": {
176
188
  "type": "object",
177
189
  "properties": {
@@ -156,6 +156,30 @@ function challengeHash(code: string): string {
156
156
  return createHash("sha256").update(code).digest("hex");
157
157
  }
158
158
 
159
+ /**
160
+ * The state key of a whole-fleet ceremony (#991): one challenge that arms every
161
+ * configured project on a single matching reply.
162
+ *
163
+ * A NUL byte, so it can never collide with a project name — `encodeProjectKey`
164
+ * escapes it to hex, and no configured project can produce the same file. The
165
+ * empty string was unavailable: that is already the key of a legacy unstamped
166
+ * config, and a fleet record must not shadow one.
167
+ */
168
+ export const FLEET_ARM_KEY = "\u0000fleet";
169
+
170
+ /**
171
+ * Whether a token looks like a challenge code at all.
172
+ *
173
+ * This is what makes a reply answerable without knowing any code: the codes are
174
+ * `FLEET-` plus hex (see `makeChallengeCode`), so a token of that shape is an
175
+ * arming attempt whether or not it matches, while ordinary chat is left alone.
176
+ * Only the shape is inspected — the token itself is never hashed against
177
+ * anything but a pending record, never logged, and never echoed.
178
+ */
179
+ export function looksLikeChallengeCode(token: string): boolean {
180
+ return /^FLEET-[0-9A-Fa-f]{4,32}$/.test(token);
181
+ }
182
+
159
183
  /**
160
184
  * Acknowledgement files outliving their transaction — a crash between the
161
185
  * adapter's write and the host's settle — are inert by construction: no
@@ -225,26 +249,78 @@ export function recordArmChallenge(
225
249
  * host settle all fail closed without touching anyone else's handshake, and
226
250
  * two projects acknowledging concurrently cannot clobber each other.
227
251
  */
228
- export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
229
- const pending = readPendingFor(projectKey(project));
230
- if (pending === undefined) return false;
231
- if (now >= pending.expiresAt) return false;
252
+ /**
253
+ * What an inbound reply was, so the adapter can answer it (#991).
254
+ *
255
+ * Before this, a non-matching or expired code was simply ignored: the waiter
256
+ * kept waiting and the operator had no idea they had been heard. The fastest
257
+ * way to be sure you held the current code was to scroll the chat, which is
258
+ * exactly the work conductor-owned verification was supposed to end.
259
+ *
260
+ * - `matched` — an active proof; the acknowledgement is written.
261
+ * - `expired` — a code that hashes to a pending record whose window has closed.
262
+ * - `unknown` — a challenge-shaped token that matches nothing readable here.
263
+ * - `none` — no challenge-shaped token at all: ordinary chat, answer nothing.
264
+ */
265
+ export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
266
+
267
+ /**
268
+ * Classify one inbound reply against this session's own records, writing the
269
+ * acknowledgement on a match.
270
+ *
271
+ * Two records are consulted and no others: this project's, and the fleet-wide
272
+ * one (#991). That bound is the non-disclosure property — a reply that matches
273
+ * nothing here can never reveal that a *different* project has a live
274
+ * challenge, because no other project's record is ever read.
275
+ *
276
+ * Fail-closed ordering is unchanged: the project's own active challenge wins,
277
+ * then the fleet ceremony, and an expired record is never a proof.
278
+ */
279
+ export function classifyArmReply(
280
+ project: string | undefined,
281
+ replyText: string,
282
+ now: number,
283
+ ): ArmReplyVerdict {
232
284
  // Challenge codes contain no whitespace, so tokenising on whitespace never
233
285
  // splits one; empty replies simply yield no token.
234
- const targetHash = pending.hash;
235
- let matched = false;
236
- for (const token of replyText.trim().split(/\s+/)) {
237
- if (token.length > 0 && challengeHash(token) === targetHash) {
238
- matched = true;
239
- break;
286
+ const tokens = replyText.trim().split(/\s+/).filter((token) => token.length > 0);
287
+ if (tokens.length === 0) return "none";
288
+ const candidates = [readPendingFor(projectKey(project)), readPendingFor(FLEET_ARM_KEY)].filter(
289
+ (pending): pending is PendingChallenge => pending !== undefined,
290
+ );
291
+
292
+ let expired = false;
293
+ for (const token of tokens) {
294
+ const hash = challengeHash(token);
295
+ for (const pending of candidates) {
296
+ if (pending.hash !== hash) continue;
297
+ if (now >= pending.expiresAt) {
298
+ // Keep looking: a fresh record for the same code is a proof, and only
299
+ // an exhausted search may conclude "expired".
300
+ expired = true;
301
+ continue;
302
+ }
303
+ // Keyed by the challenge id, so replays overwrite the one record the
304
+ // single live waiter consumes; a stale id's file can never be created
305
+ // here.
306
+ const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
307
+ writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
308
+ return "matched";
240
309
  }
241
310
  }
242
- if (!matched) return false;
243
- // Keyed by the challenge id, so replays overwrite the one record the single
244
- // live waiter consumes; a stale id's file can never be created here.
245
- const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
246
- writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
247
- return true;
311
+ if (expired) return "expired";
312
+ // Shape only, and only to decide whether the operator gets an answer. A token
313
+ // that is not challenge-shaped is ordinary chat.
314
+ return tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none";
315
+ }
316
+
317
+ /**
318
+ * The inbound adapter's acknowledgement (conductor #614), kept as the boolean
319
+ * the availability gate already reads. {@link classifyArmReply} is the same
320
+ * pass with the non-matching cases named.
321
+ */
322
+ export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
323
+ return classifyArmReply(project, replyText, now) === "matched";
248
324
  }
249
325
 
250
326
  /** The acknowledgement record for one exact challenge id, or undefined. */
package/src/board.ts CHANGED
@@ -14,6 +14,7 @@ import type { FleetLayers, TelegramHealth } from "./status-render.ts";
14
14
  import { probeCodeGraph, DEFAULT_DEPS, type CodeGraphHealth } from "./graph-health.ts";
15
15
  import { healthCheck, livingDaemon } from "./lifecycle.ts";
16
16
  import type { WorkerPausePhase } from "./worker.ts";
17
+ import type { WorkerPauseView } from "./fleet.ts";
17
18
  import { dbPath, openStore } from "./store.ts";
18
19
  import { formatTranscriptLine } from "./transcript.ts";
19
20
  import { makeTracker } from "./tracker/github.ts";
@@ -31,6 +32,8 @@ import type {
31
32
  } from "./types.ts";
32
33
 
33
34
  const REFRESH_MS = 1_000;
35
+ /** How long an armed pause confirmation stays valid (#997). */
36
+ const PAUSE_CONFIRM_MS = 8_000;
34
37
  const HEALTH_REFRESH_MS = 10_000;
35
38
  /** Base cadence for the tracker label read, and the ceiling the idle backoff
36
39
  * in {@link nextLabelDelayMs} doubles toward. Separate from the health gate
@@ -141,7 +144,7 @@ export interface BoardHealth {
141
144
 
142
145
  interface BoardHealthProbe {
143
146
  health: BoardHealth;
144
- pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
147
+ pausedPhases: ReadonlyMap<number, WorkerPauseView>;
145
148
  }
146
149
 
147
150
  /**
@@ -191,7 +194,7 @@ export interface BoardSnapshot {
191
194
  project: ProjectConfig;
192
195
  status: StatusSnapshot;
193
196
  health: BoardHealth;
194
- pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
197
+ pausedPhases: ReadonlyMap<number, WorkerPauseView>;
195
198
  labels: BoardLabels;
196
199
  /** Live re-verification of pushed rows (#173): run id → what the tracker says
197
200
  * the PR looks like now. Rendered as a `now:` suffix on the card. */
@@ -570,7 +573,7 @@ function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void {
570
573
  function runCardLines(run: RunRecord, snapshot: BoardSnapshot, lane: BoardLane): string[] {
571
574
  const endedAt = run.endedAt ?? snapshot.now;
572
575
  const duration = humanDuration(endedAt - run.startedAt);
573
- const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue) : undefined;
576
+ const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue)?.phase : undefined;
574
577
  const pausePrefix = phase === "paused" ? "⏸ PAUSED " : phase === "pausing" ? "… pausing " : "";
575
578
  const lines = [
576
579
  // The class, when the sweep has attached one and nothing has recovered it
@@ -833,7 +836,7 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
833
836
  // #173: a blocked/failed run whose label is gone is the reason this card is
834
837
  // parked; the header says so rather than presenting the state as current.
835
838
  const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
836
- const pausePhase = snapshot.pausedPhases.get(run.issue);
839
+ const pausePhase = snapshot.pausedPhases.get(run.issue)?.phase;
837
840
  const metadata = [
838
841
  styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
839
842
  ...(pausePhase === undefined
@@ -869,7 +872,7 @@ function renderHelp(width: number, height: number): string[] {
869
872
  "↑/↓ or k/j select card",
870
873
  "Enter inspect/follow transcript",
871
874
  "u unblock selected blocked, failed or orphaned issue",
872
- "space pause/resume selected worker",
875
+ "space pause/resume selected worker (asks for a confirming press)",
873
876
  "i open selected issue",
874
877
  "p open selected pull request",
875
878
  "r refresh health now",
@@ -964,7 +967,7 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProb
964
967
  pausedPhases:
965
968
  daemon === "ok"
966
969
  ? workerPhasesFromHealthz(health?.body, project.name)
967
- : new Map<number, WorkerPausePhase>(),
970
+ : new Map<number, WorkerPauseView>(),
968
971
  };
969
972
  }
970
973
 
@@ -1065,6 +1068,31 @@ async function unblock(project: ProjectConfig, issue: number): Promise<string> {
1065
1068
  return summarizeUnblockOutput(issue, stdout);
1066
1069
  }
1067
1070
 
1071
+ /**
1072
+ * What one spacebar press on a RUNNING card does (#997): the first press only
1073
+ * arms the toggle and says so; the confirming second press on the same card,
1074
+ * within {@link PAUSE_CONFIRM_MS}, performs it. Pure, so the gate itself is
1075
+ * provable: an unconfirmed press must change nothing but the armed state —
1076
+ * one stray spacebar parked a live worker for 14 minutes on 2026-08-23.
1077
+ */
1078
+ export function pauseKeyDecision(
1079
+ pending: { issue: number; at: number } | undefined,
1080
+ issue: number,
1081
+ phase: WorkerPausePhase | undefined,
1082
+ now: number,
1083
+ ):
1084
+ | { kind: "confirm"; pending: undefined }
1085
+ | { kind: "arm"; pending: { issue: number; at: number }; notice: string } {
1086
+ if (pending?.issue === issue && now - pending.at <= PAUSE_CONFIRM_MS) {
1087
+ return { kind: "confirm", pending: undefined };
1088
+ }
1089
+ return {
1090
+ kind: "arm",
1091
+ pending: { issue, at: now },
1092
+ notice: `press space again to confirm ${phase === "paused" ? "resume" : "pause"} of #${issue}`,
1093
+ };
1094
+ }
1095
+
1068
1096
  async function toggleWorkerPause(
1069
1097
  project: ProjectConfig,
1070
1098
  issue: number,
@@ -1083,7 +1111,7 @@ async function toggleWorkerPause(
1083
1111
  {
1084
1112
  method: "PUT",
1085
1113
  headers: { "content-type": "application/json" },
1086
- body: JSON.stringify({ project: project.name }),
1114
+ body: JSON.stringify({ project: project.name, source: "board" }),
1087
1115
  },
1088
1116
  );
1089
1117
  const payload = (await response.json()) as { error?: unknown; phase?: unknown };
@@ -1267,6 +1295,8 @@ export async function runBoard(projectName?: string): Promise<void> {
1267
1295
  ]);
1268
1296
  let health = healthProbe.health;
1269
1297
  let pausedPhases = healthProbe.pausedPhases;
1298
+ // An armed-but-unconfirmed pause toggle (#997): issue and arm time.
1299
+ let pendingPause: { issue: number; at: number } | undefined;
1270
1300
  let healthAt = Date.now();
1271
1301
  let healthRefresh: Promise<void> | undefined;
1272
1302
  // The tracker read is gated separately from health and the plan allowance:
@@ -1400,6 +1430,9 @@ export async function runBoard(projectName?: string): Promise<void> {
1400
1430
  });
1401
1431
  const name = key.name ?? key.sequence;
1402
1432
  if (name === "refresh" || name === "resize") continue;
1433
+ // #997: an armed pause confirmation survives only until the next
1434
+ // keypress — anything but the confirming space disarms it.
1435
+ if (name !== "space") pendingPause = undefined;
1403
1436
  // Past this point the key came from a person, not the refresh timer:
1404
1437
  // someone watching the board gets the base cadence back immediately.
1405
1438
  labelDelay = LABEL_REFRESH_MS;
@@ -1479,11 +1512,19 @@ export async function runBoard(projectName?: string): Promise<void> {
1479
1512
  if (name === "space") {
1480
1513
  const lane = COLUMN_DEFS[cursor.column]?.key;
1481
1514
  if (card === undefined || card.kind !== "run" || lane !== "running") {
1515
+ pendingPause = undefined;
1482
1516
  notice = "pause/resume is available for RUNNING cards";
1483
1517
  } else {
1484
1518
  const issue = cardIssue(card);
1485
- notice = await toggleWorkerPause(project, issue, pausedPhases.get(issue));
1486
- healthAt = 0;
1519
+ const phase = pausedPhases.get(issue)?.phase;
1520
+ const decision = pauseKeyDecision(pendingPause, issue, phase, Date.now());
1521
+ pendingPause = decision.pending;
1522
+ if (decision.kind === "confirm") {
1523
+ notice = await toggleWorkerPause(project, issue, phase);
1524
+ healthAt = 0;
1525
+ } else {
1526
+ notice = decision.notice;
1527
+ }
1487
1528
  }
1488
1529
  continue;
1489
1530
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gate: every browser script this package serves must parse.
3
+ *
4
+ * Run via `bun run check` from the package root, beside the trailing-newline
5
+ * gate. `tsc` never sees `src/dashboard/app.js` — it is plain browser JS,
6
+ * shipped as an asset and loaded by a `<script>` tag — and no test executes
7
+ * it, so nothing in the pipeline had an opinion about whether it was even
8
+ * syntactically valid.
9
+ *
10
+ * It was not. 0.19.0 shipped an `app.js` carrying four `__omp_shell("…")`
11
+ * fragments where `!answer.ok` and `!confirmDestructive(` should have been:
12
+ * the authoring session wrote the file through a Python eval path, whose
13
+ * IPython-style `!cmd` shell escape rewrote every line that began with `!`.
14
+ * A single SyntaxError takes the whole script with it, so the dashboard
15
+ * rendered its static `<h1>` and nothing else — no token prompt, no fleet —
16
+ * and looked, from the outside, like an auth or data problem (#981).
17
+ *
18
+ * A parse is the whole check. It is not a linter and has no opinion about
19
+ * style: it asks the one question a served script must answer yes to.
20
+ */
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ /** Tracked browser scripts, repo-root-relative. */
27
+ export const BROWSER_SCRIPTS: readonly string[] = ["omp/src/dashboard/app.js"];
28
+
29
+ /**
30
+ * The parse error for one script, or null when it parses. Uses Bun's own
31
+ * transpiler: a full parse of the source, without executing a line of it (the
32
+ * script drives a DOM this process does not have).
33
+ */
34
+ export function parseFailure(source: string, path: string): string | null {
35
+ try {
36
+ new Bun.Transpiler({ loader: "js", target: "browser" }).transformSync(source);
37
+ return null;
38
+ } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ return `${path}: ${message.split("\n")[0]}`;
41
+ }
42
+ }
43
+
44
+ /** One violation line per unparseable script, so a run names every offender. */
45
+ export function findViolations(
46
+ paths: readonly string[],
47
+ read: (path: string) => string,
48
+ ): string[] {
49
+ const violations: string[] = [];
50
+ for (const path of paths) {
51
+ const failure = parseFailure(read(path), path);
52
+ if (failure !== null) violations.push(failure);
53
+ }
54
+ return violations;
55
+ }
56
+
57
+ function repoRoot(): string {
58
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
59
+ }
60
+
61
+ if (import.meta.main) {
62
+ const root = repoRoot();
63
+ const violations = findViolations(BROWSER_SCRIPTS, (path) =>
64
+ readFileSync(join(root, path), "utf8"),
65
+ );
66
+ if (violations.length > 0) {
67
+ for (const violation of violations) console.error(violation);
68
+ console.error(`browser script gate: ${violations.length} script(s) do not parse`);
69
+ process.exit(1);
70
+ }
71
+ console.log("browser script gate: ok");
72
+ }
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { loadConfig } from "./config.ts";
12
12
  import { COMMAND_MANIFEST, renderUsage, type CommandManifestEntry } from "./command-manifest.ts";
13
13
  import { armCommand } from "./commands/arm.ts";
14
14
  import { boardCommand } from "./commands/board.ts";
15
+ import { companionCommand } from "./commands/companion.ts";
15
16
  import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
16
17
  import { daemonCommand } from "./commands/daemon.ts";
17
18
  import { dashboardCommand } from "./commands/dashboard.ts";
@@ -278,6 +279,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
278
279
  doctor: () => doctorCommand(ctx),
279
280
  ledger: () => ledgerCommand(ctx),
280
281
  board: () => boardCommand(ctx),
282
+ companion: () => companionCommand(ctx),
281
283
  dashboard: () => dashboardCommand(ctx),
282
284
  hold: () => holdCommand(ctx),
283
285
  drain: () => drainCommand(ctx),
@@ -156,6 +156,16 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
156
156
  usage: ["board [--project NAME] [--json]"],
157
157
  flags: [project(), toggle("--json", "print the stable board JSON shape")],
158
158
  },
159
+ {
160
+ name: "companion",
161
+ description: "render the recovery companion pane's decisions and watches view",
162
+ scope: "project",
163
+ usage: ["companion decisions [--project NAME] [--once]"],
164
+ flags: [
165
+ project(),
166
+ toggle("--once", "draw one frame and exit instead of holding the pane"),
167
+ ],
168
+ },
159
169
  {
160
170
  name: "dashboard",
161
171
  description: "serve the read-only browser dashboard",
@@ -7,11 +7,39 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
- import { armTicks } from "../fleet.ts";
10
+ import { armFleet, armTicks } from "../fleet.ts";
11
11
  import { withProgress } from "../ui/progress.ts";
12
12
 
13
13
  export async function armCommand(ctx: CommandContext): Promise<void> {
14
- for (const project of ctx.targetProjects()) {
14
+ const targets = ctx.targetProjects();
15
+ // One ceremony for the whole fleet (#991). Two projects used to mean two
16
+ // sequential handshakes with two codes in one chat, though nothing about the
17
+ // fleet's state differed between them. `arm --project X` is untouched: it is
18
+ // still exactly one project, one challenge, one marker.
19
+ if (ctx.projectFlag === undefined && targets.length > 1) {
20
+ const r = await withProgress(
21
+ "arm: verifying one arming proof for the fleet…",
22
+ "Arming proof verified",
23
+ () =>
24
+ armFleet(
25
+ targets.map((project) => project.name),
26
+ { progress: (line) => process.stdout.write(`${line}\n`) },
27
+ ),
28
+ { plainMessage: true },
29
+ );
30
+ process.stdout.write(
31
+ `ARMED — one inbound round-trip proved with owner ${r.owner}; ticks are now live for ${String(r.armed.length)} project(s).\n` +
32
+ r.armed
33
+ .map(
34
+ (project) =>
35
+ ` ${project.project}: marker ${project.path}${project.alreadyArmed ? " (replaced previous marker)" : ""}`,
36
+ )
37
+ .join("\n") +
38
+ "\n",
39
+ );
40
+ return;
41
+ }
42
+ for (const project of targets) {
15
43
  // Proof-neutral wording: `claim-only` performs no Telegram send, so the
16
44
  // progress line cannot promise a challenge that never goes out (#613). The
17
45
  // result line names the proof that actually armed it.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `omp-conductor companion decisions` — the recovery companion pane's own
3
+ * renderer (#994).
4
+ *
5
+ * Why a command and not `watch '<one-shot>'`, which is what recovery used to
6
+ * spin up: `watch` re-forks its argument every interval, and the
7
+ * foreground-process transition that produces made herdr repaint the pane —
8
+ * a flash every 60 seconds, reported live during a fleet restart. Measured at
9
+ * the time: exactly one full erase at startup in both `watch` and `watch -c`,
10
+ * ~610 bytes for three renders, and zero ANSI bytes in the command's own
11
+ * output. So neither `watch`'s drawing nor `-c` was the cause; the re-fork was.
12
+ * The board pane, a single long-lived process holding its terminal, never
13
+ * flashed — and this is that shape.
14
+ *
15
+ * Two properties are load-bearing:
16
+ *
17
+ * - **One process, held.** The pane's foreground stays this process between
18
+ * renders, which is exactly what recovery's liveness check reads
19
+ * (`foreground_verdict`: a bare shell means the pane is at a prompt and gets
20
+ * an `agent start`). A `while`-loop in `sh` would have stopped the flashing
21
+ * and broken that, because its foreground process is named `sh`.
22
+ * - **Redraw in place, only on change.** Cursor-home plus erase-to-end-of-line
23
+ * per line, and the leftover tail of a shorter frame erased explicitly. No
24
+ * full-screen clear, so there is nothing to see between two identical frames
25
+ * — the print-and-sleep loop this lineage started with appended instead, and
26
+ * a pane left open overnight was a mile of identical lists.
27
+ */
28
+
29
+ import { companionFrame } from "../companion-view.ts";
30
+ import { findProject, loadConfig } from "../config.ts";
31
+ import { dbPath, openStore } from "../store.ts";
32
+ import type { CommandContext } from "./context.ts";
33
+
34
+ /** Seconds between reads. The list moves when a human or a condition moves it,
35
+ * neither of which is fast; a shorter interval would spend the fleet's
36
+ * process budget to display the same frame. */
37
+ export const COMPANION_REFRESH_SECONDS = 60;
38
+
39
+ /** Cursor to home, and erase from the cursor to the end of the line. Nothing
40
+ * clears the whole screen: a full erase is what a reader perceives as a
41
+ * flash, and it is unnecessary when every line is overwritten in place. */
42
+ const HOME = "\u001B[H";
43
+ const ERASE_LINE = "\u001B[K";
44
+ const ERASE_BELOW = "\u001B[J";
45
+
46
+ /** One frame, drawn over whatever the last one left. `previous` is the line
47
+ * count of the last frame so a shorter one erases its own leftovers instead of
48
+ * leaving stale rows that read as current. */
49
+ export function drawFrame(lines: readonly string[], previous: number): string {
50
+ const body = lines.map((line) => `${line}${ERASE_LINE}`).join("\n");
51
+ // A frame that shrank leaves rows below it; erase from the end of the new
52
+ // content down rather than clearing first, which would flash.
53
+ return lines.length < previous ? `${HOME}${body}\n${ERASE_BELOW}` : `${HOME}${body}\n`;
54
+ }
55
+
56
+ export async function companionCommand(ctx: CommandContext): Promise<void> {
57
+ const sub = ctx.argv[1];
58
+ if (sub !== "decisions") {
59
+ process.stderr.write(
60
+ `omp-conductor: unknown companion view "${sub ?? ""}" — expected decisions\n`,
61
+ );
62
+ process.exit(2);
63
+ }
64
+ const project = findProject(loadConfig(), ctx.projectFlag);
65
+ const once = ctx.argv.includes("--once");
66
+
67
+ let previous = 0;
68
+ let stopped = false;
69
+ const stop = (): void => {
70
+ stopped = true;
71
+ };
72
+ process.on("SIGINT", stop);
73
+ process.on("SIGTERM", stop);
74
+
75
+ while (!stopped) {
76
+ // Opened per render, closed immediately: a companion pane lives for days,
77
+ // and a handle held that long across daemon restarts and db snapshots is a
78
+ // handle to a file that may no longer be the store.
79
+ const store = openStore(dbPath());
80
+ let lines: string[];
81
+ try {
82
+ lines = companionFrame(store.openDecisions(project.name));
83
+ } finally {
84
+ store.close();
85
+ }
86
+ process.stdout.write(drawFrame(lines, previous));
87
+ previous = lines.length;
88
+ if (once) return;
89
+ await new Promise<void>((resolve) => {
90
+ const timer = setTimeout(resolve, COMPANION_REFRESH_SECONDS * 1_000);
91
+ // Never hold the process open past a signal: the pane is closed by
92
+ // closing the pane, and a lingering timer would outlive it.
93
+ timer.unref?.();
94
+ const poll = setInterval(() => {
95
+ if (!stopped) return;
96
+ clearInterval(poll);
97
+ clearTimeout(timer);
98
+ resolve();
99
+ }, 250);
100
+ poll.unref?.();
101
+ });
102
+ }
103
+ }
@@ -81,6 +81,7 @@ export type CommandScope = "project" | "host" | "fleet" | "none";
81
81
  export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
82
82
  // project — exactly one project; findProject demands --project when several
83
83
  "brief-upgrade": "project",
84
+ companion: "project",
84
85
  decision: "project",
85
86
  drain: "project",
86
87
  event: "project",
@@ -41,6 +41,7 @@ const response = await fetch(
41
41
  headers: { "content-type": "application/json" },
42
42
  body: JSON.stringify({
43
43
  project: project.name,
44
+ source: "cli",
44
45
  ...(reason === undefined ? {} : { reason }),
45
46
  }),
46
47
  },