omp-conductor 0.3.13 → 0.3.15

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/host.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Host facts the wizard and status surfaces need without dragging in systemd
3
+ * or the tracker. Pure so tests pin the thresholds without a real machine.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { DEFAULT_CAPS } from "./types.ts";
9
+
10
+ /** 16 GiB — below this, two in-process omp sessions plus the orchestrator are
11
+ * a measured swap risk on a shared VPS (issue #51: 3–4GB peaks on 7.6GB). */
12
+ export const SMALL_HOST_RAM_BYTES = 16 * 1024 ** 3;
13
+
14
+ /**
15
+ * MemTotal from a `/proc/meminfo` body, in bytes. kB × 1024, matching what
16
+ * Linux reports; undefined when the line is missing or unparsable.
17
+ */
18
+ export function ramBytesFromMeminfo(text: string): number | undefined {
19
+ const m = /^MemTotal:\s+(\d+)\s+kB\s*$/m.exec(text);
20
+ if (m === null) return undefined;
21
+ const kib = Number(m[1]);
22
+ if (!Number.isFinite(kib) || kib <= 0) return undefined;
23
+ return kib * 1024;
24
+ }
25
+
26
+ /** Darwin `sysctl -n hw.memsize` stdout → bytes. */
27
+ export function ramBytesFromSysctl(raw: string): number | undefined {
28
+ const n = Number(raw.trim());
29
+ if (!Number.isFinite(n) || n <= 0) return undefined;
30
+ return n;
31
+ }
32
+
33
+ /**
34
+ * Total installed RAM when the host will say, otherwise undefined. Never
35
+ * throws: a container without /proc or a locked-down sysctl is "unknown", and
36
+ * callers fall back to the shipped worker default.
37
+ */
38
+ export function hostRamBytes(): number | undefined {
39
+ if (process.platform === "linux") {
40
+ try {
41
+ return ramBytesFromMeminfo(readFileSync("/proc/meminfo", "utf8"));
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ if (process.platform === "darwin") {
47
+ const ran = spawnSync("sysctl", ["-n", "hw.memsize"], { encoding: "utf8" });
48
+ if (ran.status !== 0 || ran.stdout === null) return undefined;
49
+ return ramBytesFromSysctl(ran.stdout);
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ /**
55
+ * Setup default for `maxConcurrentWorkers`. Small hosts get 1; everyone else
56
+ * keeps {@link DEFAULT_CAPS.maxConcurrentWorkers}. Unknown RAM keeps the
57
+ * shipped default — guessing low would silently halve throughput on a beefy
58
+ * box whose /proc we cannot read.
59
+ */
60
+ export function recommendedMaxWorkers(ramBytes: number | undefined): number {
61
+ if (ramBytes === undefined) return DEFAULT_CAPS.maxConcurrentWorkers;
62
+ return ramBytes < SMALL_HOST_RAM_BYTES ? 1 : DEFAULT_CAPS.maxConcurrentWorkers;
63
+ }
64
+
65
+ /** Compact binary units for status lines (`3.2 GB`, `430 MB`). */
66
+ export function formatRss(bytes: number): string {
67
+ if (!Number.isFinite(bytes) || bytes < 0) return "?";
68
+ const gb = bytes / 1024 ** 3;
69
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
70
+ const mb = bytes / 1024 ** 2;
71
+ if (mb >= 1) return `${mb.toFixed(0)} MB`;
72
+ return `${Math.round(bytes / 1024)} KB`;
73
+ }
74
+
75
+ /**
76
+ * `rssBytes` from a `/healthz` JSON body. Old daemons omit the field; garbage
77
+ * answers undefined rather than NaN so status can hide the line.
78
+ */
79
+ export function rssBytesFromHealthz(body: string | undefined): number | undefined {
80
+ if (body === undefined || body.length === 0) return undefined;
81
+ try {
82
+ const parsed: unknown = JSON.parse(body);
83
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
84
+ const n = (parsed as { rssBytes?: unknown }).rssBytes;
85
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return undefined;
86
+ return n;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
package/src/lifecycle.ts CHANGED
@@ -313,39 +313,34 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
313
313
  // `livingDaemon` already cleared a stale file; this covers the unparseable
314
314
  // one it refused to read. Still ask systemd: a unit can be running with
315
315
  // no pidfile (boot race, wiped runtime dir) and stop must still land.
316
- const unitPid = systemdMainPid();
317
- if (unitPid !== undefined && (await stopViaSystemd(undefined))) {
318
- const deadline = Date.now() + (o.timeoutMs ?? STOP_TIMEOUT_MS);
319
- while (isAlive(unitPid) && Date.now() < deadline) await sleep(100);
320
- if (isAlive(unitPid)) {
321
- throw new Error(
322
- `daemon pid ${unitPid} still alive after systemctl stop ${SYSTEMD_UNIT} — ` +
323
- `check \`systemctl status ${SYSTEMD_UNIT}\``,
324
- );
325
- }
316
+ const decision = decideSystemdStop(undefined);
317
+ if (decision.kind === "unknown") {
318
+ throw new Error(ownershipUnknown("stop", decision.reason));
319
+ }
320
+ if (decision.kind === "stop") {
321
+ await runSystemdStop(decision.mainPid, o.timeoutMs);
326
322
  clearRecord();
327
- return { kind: "stopped", pid: unitPid, via: "systemctl" };
323
+ return { kind: "stopped", pid: decision.mainPid, via: "systemctl" };
328
324
  }
329
325
  clearRecord();
330
326
  return { kind: "not-running" };
331
327
  }
332
328
 
333
- if (await stopViaSystemd(rec.pid)) {
334
- // Wait out the unit: systemctl stop is synchronous for Type=simple, but
335
- // a slow drain still holds the old pid briefly and the next start would
336
- // refuse against it.
337
- const deadline = Date.now() + (o.timeoutMs ?? STOP_TIMEOUT_MS);
338
- while (isAlive(rec.pid) && Date.now() < deadline) await sleep(100);
339
- if (isAlive(rec.pid)) {
340
- throw new Error(
341
- `daemon pid ${rec.pid} still alive after systemctl stop ${SYSTEMD_UNIT} — ` +
342
- `check \`systemctl status ${SYSTEMD_UNIT}\``,
343
- );
344
- }
329
+ const decision = decideSystemdStop(rec.pid);
330
+ if (decision.kind === "unknown") {
331
+ // Pidfile stays: we could not prove the unit does *not* own this pid, and
332
+ // signalling it under that uncertainty is the bounce under Restart=on-failure.
333
+ throw new Error(ownershipUnknown("stop", decision.reason));
334
+ }
335
+ if (decision.kind === "stop") {
336
+ await runSystemdStop(rec.pid, o.timeoutMs);
345
337
  clearRecord();
346
338
  return { kind: "stopped", pid: rec.pid, via: "systemctl" };
347
339
  }
348
340
 
341
+ // decision.kind === "not-ours": confirmed no unit, inactive unit, no systemd
342
+ // binary, or a unit whose MainPID is somebody else. Only a *confirmed*
343
+ // negative is safe to signal.
349
344
  const gone = await terminate(rec.pid, o.timeoutMs ?? STOP_TIMEOUT_MS);
350
345
  if (!gone) {
351
346
  // The record stays: something is still holding that pid, and forgetting
@@ -360,11 +355,11 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
360
355
  * Restarts the daemon.
361
356
  *
362
357
  * Same ownership rule as {@link stopDaemon}: when the unit owns the live pid,
363
- * `systemctl restart` keeps systemd in charge of the replacement process so
364
- * the new MainPID is still the unit's. Falling back to stop+start for a
365
- * hand-started daemon would leave the unit dead and the new process
366
- * unsupervised — fine for a laptop, wrong for the host that installed a unit
367
- * specifically so a crash comes back.
358
+ * `systemctl restart` is the *only* path a failed manager call is terminal,
359
+ * never a fallthrough to raw signals. An *unanswered* ownership query is also
360
+ * terminal: "dbus blipped" is not "no unit". Falling back to stop+start is
361
+ * reserved for a confirmed hand-started daemon (no systemd, inactive unit, or
362
+ * a unit whose MainPID is someone else).
368
363
  *
369
364
  * Returns the record of the process that is now answering `/healthz`.
370
365
  */
@@ -372,34 +367,38 @@ export async function restartDaemon(
372
367
  o: { port?: number; project?: string; timeoutMs?: number } = {},
373
368
  ): Promise<{ previous: DaemonRecord | undefined; record: DaemonRecord; via: "systemctl" | "cli" }> {
374
369
  const previous = livingDaemon();
375
- const unitPid = systemdMainPid();
370
+ const ownership = probeUnit();
371
+ if (ownership.kind === "unknown") {
372
+ throw new Error(ownershipUnknown("restart", ownership.reason));
373
+ }
374
+
376
375
  const unitOwns =
377
- unitPid !== undefined && (previous === undefined || previous.pid === unitPid);
376
+ ownership.kind === "active" && (previous === undefined || previous.pid === ownership.pid);
378
377
 
379
378
  if (unitOwns) {
379
+ // Ownership is proven. A refused/timed-out restart must not fall through
380
+ // to stopDaemon's signal path — that is the exact bounce this module exists
381
+ // to prevent (SIGTERM → exit 143 → Restart=on-failure → new MainPID).
380
382
  const ran = systemctl(["restart", SYSTEMD_UNIT]);
381
- if (ran.ok) {
382
- // systemctl restart returns once the new MainPID is up; the pidfile is
383
- // written by the daemon itself on boot, so wait for that rather than
384
- // inventing a record from the unit alone.
385
- const deadline = Date.now() + READY_TIMEOUT_MS;
386
- for (;;) {
387
- const rec = livingDaemon();
388
- if (rec !== undefined) {
389
- const health = await healthCheck(rec.port);
390
- if (health.ok) return { previous, record: rec, via: "systemctl" };
391
- }
392
- if (Date.now() >= deadline) break;
393
- await sleep(READY_POLL_MS);
383
+ if (!ran.ok) {
384
+ throw new Error(systemctlFailure("restart", ran));
385
+ }
386
+ // systemctl restart returns once the new MainPID is up; the pidfile is
387
+ // written by the daemon itself on boot, so wait for that rather than
388
+ // inventing a record from the unit alone.
389
+ const deadline = Date.now() + READY_TIMEOUT_MS;
390
+ for (;;) {
391
+ const rec = livingDaemon();
392
+ if (rec !== undefined) {
393
+ const health = await healthCheck(rec.port);
394
+ if (health.ok) return { previous, record: rec, via: "systemctl" };
394
395
  }
395
- throw new Error(
396
- `systemctl restart ${SYSTEMD_UNIT} returned, but the daemon never answered /healthz`,
397
- );
396
+ if (Date.now() >= deadline) break;
397
+ await sleep(READY_POLL_MS);
398
398
  }
399
- // Unit exists and owns the pid but systemctl refused (permissions, dbus
400
- // down). Fall through to the signal path rather than stranding the
401
- // operator with "restart failed" and a still-running daemon they cannot
402
- // reach through the unit.
399
+ throw new Error(
400
+ `systemctl restart ${SYSTEMD_UNIT} returned, but the daemon never answered /healthz`,
401
+ );
403
402
  }
404
403
 
405
404
  await stopDaemon({ timeoutMs: o.timeoutMs });
@@ -408,32 +407,70 @@ export async function restartDaemon(
408
407
  }
409
408
 
410
409
  /**
411
- * The MainPID of {@link SYSTEMD_UNIT}, or `undefined` when systemd is absent,
412
- * the unit is unknown, or it is not running. Never throws: a missing binary
413
- * or a dbus blip is "no unit", and stop falls back to SIGTERM.
410
+ * What we know about {@link SYSTEMD_UNIT}.
411
+ *
412
+ * - `active` unit has a live MainPID (> 1). Includes transitional manager
413
+ * states (`activating`, `deactivating`, `reloading`, `reactivating`) — the
414
+ * pid is still systemd-owned, so a raw SIGTERM would bounce under
415
+ * `Restart=on-failure`.
416
+ * - `inactive` — confirmed not running (MainPID absent/0), unit absent, *or*
417
+ * no `systemctl` binary on this host. Safe to treat as "not supervised here".
418
+ * - `unknown` — the manager exists (or we cannot tell it does not) but the
419
+ * query failed: dbus blip, permission, timeout. Must not be collapsed into
420
+ * `inactive` — that is how a unit-owned daemon gets a raw SIGTERM.
414
421
  */
415
- export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
422
+ export type UnitOwnership =
423
+ | { kind: "active"; pid: number }
424
+ | { kind: "inactive" }
425
+ | { kind: "unknown"; reason: string };
426
+
427
+ /**
428
+ * Probe {@link SYSTEMD_UNIT} ownership. Never throws — the caller decides
429
+ * whether `unknown` is fatal (stop/restart: yes).
430
+ */
431
+ export function probeUnit(unit = SYSTEMD_UNIT): UnitOwnership {
416
432
  const ran = systemctl(["show", unit, "--property=MainPID", "--property=ActiveState", "--value"]);
417
- if (!ran.ok) return undefined;
433
+ if (!ran.ok) {
434
+ // No binary at all → this host has no systemd manager, so nothing here can
435
+ // be unit-owned. Any other failure (dbus, auth, timeout) is unknown.
436
+ if (ran.missing) return { kind: "inactive" };
437
+ const detail = (ran.stderr.trim() || ran.stdout.trim() || "systemctl show failed").split("\n")[0]!;
438
+ return { kind: "unknown", reason: detail };
439
+ }
418
440
  // `systemctl show --value` prints one property per line, MainPID then
419
441
  // ActiveState, in the order requested. Tolerate either order and blank
420
442
  // lines so a future systemctl rearrange does not silently disable the path.
443
+ // Unknown units still exit 0 with `0` / `inactive`, which is the confirmed
444
+ // negative we want — not an error.
445
+ //
446
+ // Ownership is the MainPID, not ActiveState. A successful probe that names a
447
+ // live pid (> 1) means systemd still owns that process — including during
448
+ // `activating` / `deactivating` / `reloading`. Filtering on ActiveState here
449
+ // used to label those transitional states "inactive" and hand the pid to a
450
+ // raw SIGTERM, which is exactly the Restart=on-failure bounce.
421
451
  const lines = ran.stdout
422
452
  .split("\n")
423
453
  .map((l) => l.trim())
424
454
  .filter((l) => l.length > 0);
425
455
  let pid: number | undefined;
426
- let active: string | undefined;
427
456
  for (const line of lines) {
428
457
  if (/^\d+$/.test(line)) {
429
458
  const n = Number(line);
430
459
  if (Number.isInteger(n) && n > 1) pid = n;
431
- } else {
432
- active = line;
433
460
  }
434
461
  }
435
- if (active !== undefined && active !== "active" && active !== "reactivating") return undefined;
436
- return pid;
462
+ if (pid === undefined) return { kind: "inactive" };
463
+ return { kind: "active", pid };
464
+ }
465
+
466
+ /**
467
+ * The MainPID of an *active* {@link SYSTEMD_UNIT}, or `undefined` when the
468
+ * unit is confirmed inactive/absent or when ownership could not be determined.
469
+ * Prefer {@link probeUnit} when the caller must distinguish those two.
470
+ */
471
+ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
472
+ const ownership = probeUnit(unit);
473
+ return ownership.kind === "active" ? ownership.pid : undefined;
437
474
  }
438
475
 
439
476
  // ---------------------------------------------------------------------------
@@ -441,40 +478,108 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
441
478
  // ---------------------------------------------------------------------------
442
479
 
443
480
  /**
444
- * Ask systemd to stop the unit, but only when it actually owns `pid`.
481
+ * Whether systemd should stop this daemon.
482
+ *
483
+ * - `stop` — unit is active and owns `pid` (or there is no pidfile and the
484
+ * unit is the only candidate). Caller MUST go through systemctl; a failed
485
+ * manager call is terminal.
486
+ * - `not-ours` — confirmed inactive/absent unit, no systemd binary, or a unit
487
+ * whose MainPID is someone else. Caller may SIGTERM its own pidfile process.
488
+ * - `unknown` — ownership query failed. Caller MUST NOT signal.
489
+ */
490
+ type SystemdStopDecision =
491
+ | { kind: "stop"; mainPid: number }
492
+ | { kind: "not-ours" }
493
+ | { kind: "unknown"; reason: string };
494
+
495
+ function decideSystemdStop(pid: number | undefined): SystemdStopDecision {
496
+ const ownership = probeUnit();
497
+ if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
498
+ if (ownership.kind === "inactive") return { kind: "not-ours" };
499
+ if (pid !== undefined && ownership.pid !== pid) return { kind: "not-ours" };
500
+ return { kind: "stop", mainPid: ownership.pid };
501
+ }
502
+
503
+ /**
504
+ * `systemctl stop` for a pid we have already proven the unit owns.
445
505
  *
446
- * `pid === undefined` means "no pidfile" still stop the unit if it is
447
- * active, because that is the only process that could be the daemon. A unit
448
- * whose MainPID is some other process is left alone: stopping it would take
449
- * down a neighbour, and the signal path handles *our* pid.
506
+ * Throws on manager refusal/timeout never returns "false" for the caller to
507
+ * reinterpret as "try SIGTERM instead". That reinterpretation is how stop
508
+ * bounced under `Restart=on-failure`.
450
509
  */
451
- async function stopViaSystemd(pid: number | undefined): Promise<boolean> {
452
- const main = systemdMainPid();
453
- if (main === undefined) return false;
454
- if (pid !== undefined && main !== pid) return false;
510
+ async function runSystemdStop(pid: number, timeoutMs?: number): Promise<void> {
455
511
  const ran = systemctl(["stop", SYSTEMD_UNIT]);
456
- return ran.ok;
512
+ if (!ran.ok) {
513
+ // Record stays: the unit still owns the process, and clearing it would let
514
+ // the next `start` believe the port is free while systemd still holds it.
515
+ throw new Error(systemctlFailure("stop", ran));
516
+ }
517
+ // systemctl stop is synchronous for Type=simple, but a slow drain still
518
+ // holds the old pid briefly and the next start would refuse against it.
519
+ const deadline = Date.now() + (timeoutMs ?? STOP_TIMEOUT_MS);
520
+ while (isAlive(pid) && Date.now() < deadline) await sleep(100);
521
+ if (isAlive(pid)) {
522
+ throw new Error(
523
+ `daemon pid ${pid} still alive after systemctl stop ${SYSTEMD_UNIT} — ` +
524
+ `check \`systemctl status ${SYSTEMD_UNIT}\``,
525
+ );
526
+ }
527
+ }
528
+
529
+ function systemctlFailure(verb: "stop" | "restart", ran: SystemctlResult): string {
530
+ const detail = (ran.stderr.trim() || ran.stdout.trim() || "no output").split("\n")[0] ?? "no output";
531
+ return (
532
+ `systemctl ${verb} ${SYSTEMD_UNIT} failed: ${detail} — ` +
533
+ `refusing to signal a unit-owned daemon (that is how Restart=on-failure turns stop into a bounce); ` +
534
+ `fix the unit or run \`systemctl ${verb} ${SYSTEMD_UNIT}\` yourself`
535
+ );
536
+ }
537
+
538
+ function ownershipUnknown(verb: "stop" | "restart", reason: string): string {
539
+ return (
540
+ `cannot determine whether ${SYSTEMD_UNIT} owns the daemon (${reason}) — ` +
541
+ `refusing to ${verb} via signal while ownership is unknown ` +
542
+ `(a raw SIGTERM under Restart=on-failure is a bounce); ` +
543
+ `retry when systemctl answers, or run \`systemctl ${verb} ${SYSTEMD_UNIT}\` yourself`
544
+ );
457
545
  }
458
546
 
459
547
  /**
460
- * Run one `systemctl` invocation. Captures output and never throws: absence
461
- * of the binary, a missing unit, or a permission error are all "not ok", and
462
- * the caller decides whether to fall back.
548
+ * Run one `systemctl` invocation. Captures output and never throws: the caller
549
+ * classifies the result. `missing: true` means the binary is not on PATH that
550
+ * is a confirmed "no systemd on this host", not a transient query failure.
463
551
  *
464
552
  * The default shells out. Tests replace it with {@link setSystemctlForTest}
465
553
  * so the ownership decision is exercised without a real systemd.
466
554
  */
467
- export type SystemctlFn = (args: string[]) => { ok: boolean; stdout: string; stderr: string };
555
+ export type SystemctlResult = {
556
+ ok: boolean;
557
+ stdout: string;
558
+ stderr: string;
559
+ /** Binary not found (ENOENT). Distinct from a failed invocation of a present binary. */
560
+ missing?: boolean;
561
+ };
468
562
 
469
- function defaultSystemctl(args: string[]): { ok: boolean; stdout: string; stderr: string } {
563
+ export type SystemctlFn = (args: string[]) => SystemctlResult;
564
+
565
+ function defaultSystemctl(args: string[]): SystemctlResult {
470
566
  try {
471
567
  const res = spawnSync("systemctl", args, {
472
568
  encoding: "utf8",
473
- // A hung dbus is not worth blocking stop on; the signal path is right there.
569
+ // Bound the wait: a hung dbus becomes `unknown` ownership, not a hang.
570
+ // Callers must NOT treat that timeout as "no unit".
474
571
  timeout: 15_000,
475
572
  env: process.env,
476
573
  });
477
- if (res.error) return { ok: false, stdout: "", stderr: res.error.message };
574
+ if (res.error) {
575
+ const err = res.error as NodeJS.ErrnoException;
576
+ return {
577
+ ok: false,
578
+ stdout: "",
579
+ stderr: err.message,
580
+ ...(err.code === "ENOENT" ? { missing: true } : {}),
581
+ };
582
+ }
478
583
  return {
479
584
  ok: res.status === 0,
480
585
  stdout: res.stdout ?? "",
package/src/omp.ts CHANGED
@@ -13,6 +13,8 @@
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
+
16
18
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
17
19
 
18
20
  /**
@@ -114,6 +116,12 @@ export async function createSession(opts: {
114
116
  sessionDir?: string;
115
117
  model?: string;
116
118
  resume?: boolean;
119
+ /**
120
+ * Install the worktree `tool_call` gate so structured file tools cannot
121
+ * leave `cwd`. Workers pass true; the orchestrator leaves this off — it has
122
+ * to read the state directory and briefs.
123
+ */
124
+ confineToCwd?: boolean;
117
125
  }): Promise<AgentSessionLike> {
118
126
  let loaded: unknown;
119
127
  try {
@@ -167,6 +175,10 @@ export async function createSession(opts: {
167
175
  // agentDir (~/.omp/agent/mcp.json) — without this, workers grep-only and
168
176
  // burn the turns cap on discovery (#29).
169
177
  enableMCP: true,
178
+ // Mechanical worktree gate (#24): structured write/edit/read/grep/glob
179
+ // whose path resolves outside cwd are blocked before execution. Inline
180
+ // extension — the harness has no separate fs-policy field.
181
+ ...(opts.confineToCwd ? { extensions: [worktreeConfinement(opts.cwd)] } : {}),
170
182
  });
171
183
  const raw = asRawSession(created);
172
184
  // Surfaced rather than swallowed: this is how a quiet downgrade to a weaker
@@ -334,7 +334,7 @@ export function resolveTickScope(): {
334
334
  * live `POLICY.md`.
335
335
  *
336
336
  * Runs on **every** successful send — including ticks that use a custom
337
- * `message` — so protocol updates land after `npm install` without waiting for
337
+ * `message` — so protocol updates land after a package upgrade without waiting for
338
338
  * the default prompt path. Failures (no config, no `POLICY.md`, unreadable
339
339
  * overlay) are silent: the tick still goes out.
340
340
  */