@bridge4dev/runner 0.37.0 → 0.39.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.
@@ -129,13 +129,220 @@ export function buildUnit(execStart, nodeBinary = process.execPath) {
129
129
  * which is the only way to fix the servers that already have the bad numbers
130
130
  * baked in — and it never overwrites a unit the operator edited by hand.
131
131
  */
132
- export const LIMITS_VERSION = 2;
132
+ export const LIMITS_VERSION = 3;
133
133
  const LIMITS_MARKER = '# devbridge-limits-version:';
134
134
  /** `zz-` so it sorts last: an operator's own drop-in should still win. */
135
135
  const LIMITS_FILE = 'zz-devbridge-limits.conf';
136
136
  export function limitsOverridePath(home = systemdUserHome()) {
137
137
  return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service.d`, LIMITS_FILE);
138
138
  }
139
+ const MIB = 1024 * 1024;
140
+ const GIB = 1024 * MIB;
141
+ /**
142
+ * How long after boot `MemAvailable` starts telling the truth.
143
+ *
144
+ * The runner is a user unit ordered `After=network-online.target`, and the things
145
+ * it shares the machine with — dockerd and its containers, mysql, pm2 — come up
146
+ * around the same time. Measure at second 20 and the neighbours have not claimed
147
+ * their memory yet: on the 12 GB machine below that reads as 11 GB free and
148
+ * produces a ceiling that protects nothing.
149
+ */
150
+ export const BOOT_SETTLE_SEC = 600;
151
+ /**
152
+ * The ceiling is never written BELOW what the cgroup already holds.
153
+ *
154
+ * Until 0.39.0 this policy only ever REMOVED a limit, so applying it to a running
155
+ * service was free. Now it sets one, and systemd applies `memory.max` to a LIVE
156
+ * cgroup on `daemon-reload`: a value under current usage makes the kernel reclaim
157
+ * and then kill inside the cgroup. At daemon start that is harmless — nothing is
158
+ * running yet — but `doctor --fix` on a busy machine would otherwise take out
159
+ * somebody's session as a side effect of running a diagnostic.
160
+ *
161
+ * The cost is that a machine fixed WHILE a runaway is in progress writes a ceiling
162
+ * above that runaway, which protects nothing. It resolves itself: the next daemon
163
+ * start measures an idle cgroup, and the drift check rewrites the inflated number.
164
+ */
165
+ const CEILING_HEADROOM_OVER_CURRENT = 1.25;
166
+ /**
167
+ * The two numbers, and the incident that decides them.
168
+ *
169
+ * Until 0.21.0 the policy was a 2 GB hard ceiling with systemd's default
170
+ * `OOMPolicy=stop`: one agent over the line and the whole service died, taking
171
+ * every other session with it (QA-112, gotcha #100). The fix removed the ceiling
172
+ * entirely and left a soft `MemoryHigh=80%`, on this reasoning, quoted from the
173
+ * code it replaced: «the machine keeps a fifth of its memory for everything that
174
+ * is not this service».
175
+ *
176
+ * That reasoning holds on a DEDICATED dev server and fails on a shared one, which
177
+ * is what vmi2502773 is: 11.9 GB total with ~4 GB permanently held by 18 docker
178
+ * containers, mysql, flowise, chroma and pm2. There, 80 % of TOTAL is 9.5 GB while
179
+ * only ~8 GB is actually free — so the soft brake sat ABOVE the real headroom and
180
+ * could never engage. The machine swapped, `kcompactd0` blocked for over 120
181
+ * seconds and the box hung; 2026-08-05 it was a `claude` at 4.2 GB, 2026-08-12 a
182
+ * single grep at 6.8 GB with 160 MB left. Only a power cycle recovered it.
183
+ *
184
+ * So the percentage has to be of what the machine can SPARE, not of what it has:
185
+ *
186
+ * headroom = MemAvailable + our own usage (ours is added back, or every
187
+ * rewrite would walk the ceiling down by what we already hold)
188
+ * MemoryMax = headroom − reserve
189
+ * MemoryHigh = 80 % of MemoryMax (reclaim and throttle first, kill last)
190
+ *
191
+ * A hard ceiling is safe to have again only because the OTHER half of the 0.21.0
192
+ * fix stayed: `OOMPolicy=continue` is still set below, so the kernel killing one
193
+ * runaway child no longer tears down the service. Restoring the ceiling without
194
+ * that would re-open QA-112.
195
+ *
196
+ * The clamps are the two ways this can go wrong:
197
+ * - floor 2 GB: on a machine whose neighbours already ate everything, a computed
198
+ * ceiling of a few hundred MB would mean a dev server that cannot run one
199
+ * agent. Measured cost of ordinary work is 512–646 MB per `claude` and 1571 MB
200
+ * for a workspace `pnpm -r typecheck`, so anything under 2 GB is not a dev
201
+ * server at all — an unusable one is a worse failure than a busy one, the same
202
+ * call `cpuQuotaPercent` makes below;
203
+ * - cap 85 % of total: on an idle dedicated box `MemAvailable` is nearly the whole
204
+ * machine, and a ceiling of «everything» is the bug this function exists to fix.
205
+ */
206
+ export function memoryPolicy(facts, minCeilingBytes = facts.ownUsageBytes * CEILING_HEADROOM_OVER_CURRENT) {
207
+ const { totalBytes, availableBytes, ownUsageBytes, uptimeSec } = facts;
208
+ const withFloor = (maxBytes, measured, starved = false) => {
209
+ const ceiling = Math.floor(Math.max(maxBytes, minCeilingBytes));
210
+ return { maxBytes: ceiling, highBytes: Math.floor(ceiling * 0.8), measured, starved };
211
+ };
212
+ // Still booting: the neighbours have not claimed their memory yet, so measuring
213
+ // now would hand back almost the whole machine. Take a fraction of TOTAL
214
+ // instead — it needs no measurement at all.
215
+ //
216
+ // 55 % is chosen against the machine that failed rather than against a tidy
217
+ // number: 55 % of 11.9 GB is 6.5 GB, which still leaves 1.4 GB after the 4 GB
218
+ // its neighbours hold, and puts the brake at 5.2 GB — engaged with plenty free.
219
+ // 60 % looked reasonable and left only 760 MB, which is not a margin. This
220
+ // number works blind, so it is sized for the crowded case, and it costs a
221
+ // dedicated box nothing: it only applies for the first ten minutes of uptime,
222
+ // before any session exists. The next daemon start measures and replaces it.
223
+ if (!Number.isFinite(uptimeSec) || uptimeSec < BOOT_SETTLE_SEC) {
224
+ return withFloor(totalBytes * 0.55, false);
225
+ }
226
+ // Enough for sshd, journald, the kernel and some page cache to keep working
227
+ // while the cgroup sits at its ceiling. Proportional on a big machine, absolute
228
+ // on a small one, because 15 % of 4 GB is not enough to stay reachable.
229
+ const reserve = Math.max(1.5 * GIB, totalBytes * 0.15);
230
+ const headroom = availableBytes + ownUsageBytes;
231
+ const cap = totalBytes * 0.85;
232
+ // `min` with the cap, not a bare 2 GiB: below ~2.4 GB of RAM the floor would be
233
+ // ABOVE the cap and `clamp` would silently return the cap anyway — with the
234
+ // reserve ignored and the «2 GB or nothing» promise quietly broken. Saying it
235
+ // here makes the tiny-machine answer deliberate instead of accidental.
236
+ const floor = Math.min(2 * GIB, cap);
237
+ const wanted = headroom - reserve;
238
+ return withFloor(clamp(wanted, floor, cap), true, wanted < floor);
239
+ }
240
+ function clamp(value, low, high) {
241
+ return Math.min(Math.max(value, low), high);
242
+ }
243
+ /** systemd takes a plain byte count, but a unit file is read by people. */
244
+ function asMiB(bytes) {
245
+ return `${Math.max(1, Math.floor(bytes / MIB))}M`;
246
+ }
247
+ /** `MemTotal`/`MemAvailable` in bytes, or null where there is no `/proc`. */
248
+ function readMemInfo() {
249
+ let text;
250
+ try {
251
+ text = fs.readFileSync('/proc/meminfo', 'utf8');
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ const field = (name) => {
257
+ const match = new RegExp(`^${name}:\\s+(\\d+) kB$`, 'm').exec(text);
258
+ return match?.[1] ? Number(match[1]) * 1024 : null;
259
+ };
260
+ const totalBytes = field('MemTotal');
261
+ const availableBytes = field('MemAvailable');
262
+ return totalBytes && availableBytes ? { totalBytes, availableBytes } : null;
263
+ }
264
+ /**
265
+ * What our own cgroup currently holds.
266
+ *
267
+ * Read through `/proc/self/cgroup` rather than assembling the path from the
268
+ * service name: the runner runs as root and as a dedicated user, under
269
+ * `user@0.service` and under `user@1001.service`, and guessing that path wrong
270
+ * silently returns 0 — which would quietly shrink the ceiling by whatever we are
271
+ * already using. Returns 0 on cgroup v1 or in a container without the file, which
272
+ * is the safe direction: a slightly lower ceiling, never a higher one.
273
+ */
274
+ function readOwnCgroupUsage() {
275
+ let cgroup;
276
+ try {
277
+ const line = fs
278
+ .readFileSync('/proc/self/cgroup', 'utf8')
279
+ .split('\n')
280
+ .find((l) => l.startsWith('0::'));
281
+ if (!line)
282
+ return null; // cgroup v1 — no v2 path to read
283
+ cgroup = line.slice('0::'.length).trim();
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ // `/proc/self` is the CALLER. For the daemon that is the service, and this is
289
+ // the hot path. For `doctor --fix` or `install-service` typed over ssh it is a
290
+ // `session-N.scope` holding a few MB — measuring that and calling it «what the
291
+ // runner holds» produced a ceiling 34 % away from the daemon's own answer on
292
+ // this very host, so the two paths would each see the other as drift and
293
+ // rewrite the file forever. Anything that is not the service must say «I do not
294
+ // know» and let the caller supply the number.
295
+ if (!cgroup.endsWith(`/${SERVICE_NAME}.service`))
296
+ return null;
297
+ return readCgroupUnreclaimable(path.join('/sys/fs/cgroup', cgroup));
298
+ }
299
+ /**
300
+ * What the cgroup holds that `MemAvailable` has NOT already counted.
301
+ *
302
+ * `memory.current` is `anon + file + kernel`, and `file` is page cache — which
303
+ * `MemAvailable` already lists as reclaimable. Adding the whole of
304
+ * `memory.current` back to `MemAvailable` therefore counts our page cache twice
305
+ * and inflates the ceiling by exactly that much: measured at +19 % on this host
306
+ * (1.1 GB of cache in a 2.3 GB cgroup), and the cache is largest during builds —
307
+ * precisely when memory is tightest. Subtracting `file` keeps the part we really
308
+ * do hold and cannot give back on demand.
309
+ */
310
+ export function readCgroupUnreclaimable(dir) {
311
+ try {
312
+ const current = Number(fs.readFileSync(path.join(dir, 'memory.current'), 'utf8').trim());
313
+ if (!Number.isFinite(current))
314
+ return null;
315
+ const file = /^file (\d+)$/m.exec(fs.readFileSync(path.join(dir, 'memory.stat'), 'utf8'));
316
+ const cache = file?.[1] ? Number(file[1]) : 0;
317
+ return Math.max(0, current - cache);
318
+ }
319
+ catch {
320
+ return null;
321
+ }
322
+ }
323
+ function readUptimeSec() {
324
+ try {
325
+ return Number(fs.readFileSync('/proc/uptime', 'utf8').split(/\s+/)[0]) || 0;
326
+ }
327
+ catch {
328
+ return 0;
329
+ }
330
+ }
331
+ /**
332
+ * Everything `memoryPolicy` needs, straight off this machine.
333
+ *
334
+ * `ownUsageBytes` is passed in by callers that are not the daemon — `doctor` and
335
+ * `install-service` run in the operator's own cgroup and cannot read the
336
+ * service's usage from `/proc/self`. Returns null when the service's usage is
337
+ * unknowable, because guessing 0 there is the one dangerous direction: it removes
338
+ * the floor that stops a live session from being killed on `daemon-reload`.
339
+ */
340
+ export function readMemoryFacts(ownUsageBytes = readOwnCgroupUsage()) {
341
+ const info = readMemInfo();
342
+ if (!info || ownUsageBytes === null)
343
+ return null;
344
+ return { ...info, ownUsageBytes, uptimeSec: readUptimeSec() };
345
+ }
139
346
  /**
140
347
  * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
141
348
  * box unreachable, but never so little that ordinary work is throttled.
@@ -148,8 +355,16 @@ export function limitsOverridePath(home = systemdUserHome()) {
148
355
  export function cpuQuotaPercent(cpuCount = os.cpus().length) {
149
356
  return cpuCount >= 4 ? (cpuCount - 1) * 100 : null;
150
357
  }
151
- export function buildLimitsOverride(cpuCount = os.cpus().length) {
358
+ export function buildLimitsOverride(cpuCount = os.cpus().length, facts = readMemoryFacts()) {
152
359
  const quota = cpuQuotaPercent(cpuCount);
360
+ // No `minCeilingBytes` parameter on purpose. It existed here for one revision
361
+ // and passed an explicit `0`, which silently DEFEATED the default in
362
+ // `memoryPolicy` — the writer produced a ceiling below live cgroup usage while
363
+ // the drift check and `doctor` both computed the floored one. Two consequences,
364
+ // both found in QA: `doctor --fix` could kill a session on a loaded machine,
365
+ // and the file was rewritten on every start because no fixed point existed.
366
+ // The floor belongs to the policy, so every caller gets it and none can opt out.
367
+ const memory = facts ? memoryPolicy(facts) : null;
153
368
  return ([
154
369
  `${LIMITS_MARKER} ${LIMITS_VERSION}`,
155
370
  '# Managed by devbridge-runner. Put your own overrides in a file that sorts',
@@ -163,14 +378,34 @@ export function buildLimitsOverride(cpuCount = os.cpus().length) {
163
378
  'StartLimitBurst=0',
164
379
  '',
165
380
  '[Service]',
166
- // The whole point of the change: one child's OOM must not take the fleet.
381
+ // The whole point of the 0.21.0 change, and the reason the ceiling below is
382
+ // safe to have at all: one child's OOM must not take the fleet.
167
383
  'OOMPolicy=continue',
168
- // Clears `MemoryMax=2G` from units written before 0.21.0.
169
- 'MemoryMax=',
170
- // Soft pressure instead of a hard ceiling: the kernel reclaims and
171
- // throttles rather than killing, and the machine keeps a fifth of its
172
- // memory for everything that is not this service.
173
- 'MemoryHigh=80%',
384
+ // Both values also override the `MemoryMax=2G` baked into units written
385
+ // before 0.21.0.
386
+ //
387
+ // The unmeasurable branch used to write `MemoryMax=` + `MemoryHigh=80%` —
388
+ // literally the pair this release exists to remove. Because an empty
389
+ // assignment RESETS a directive and a drop-in sorts after the unit, that
390
+ // branch would have stripped the ceiling the API's fallback unit now carries
391
+ // and left the machine weaker than if we had written nothing at all. A
392
+ // drop-in must never disarm the unit it overrides, so it falls back to the
393
+ // same static pair used during boot.
394
+ ...(memory
395
+ ? [
396
+ `# ${memory.measured ? 'measured headroom' : 'still booting — conservative fraction of total'}`,
397
+ `MemoryMax=${asMiB(memory.maxBytes)}`,
398
+ // Soft pressure below the hard stop: the kernel reclaims and throttles
399
+ // here, and only kills at MemoryMax. This is the line that failed on
400
+ // vmi2502773 — as a percentage of TOTAL it sat above what the machine
401
+ // could spare, so it never engaged.
402
+ `MemoryHigh=${asMiB(memory.highBytes)}`,
403
+ ]
404
+ : [
405
+ '# machine not measurable — same conservative pair as the fallback unit',
406
+ 'MemoryMax=55%',
407
+ 'MemoryHigh=44%',
408
+ ]),
174
409
  // Either a computed quota, or an explicit reset — both of which clear the
175
410
  // `CPUQuota=80%` baked into units written before 0.21.0.
176
411
  quota === null ? 'CPUQuota=' : `CPUQuota=${quota}%`,
@@ -186,9 +421,12 @@ export function buildLimitsOverride(cpuCount = os.cpus().length) {
186
421
  *
187
422
  * Deliberately version-based rather than content-based: an operator may add
188
423
  * their own directives to our file, and re-writing on every start would fight
189
- * them. Only the version number decides.
424
+ * them. The version number decides — plus, since 0.39.0 and only when `facts` are
425
+ * supplied, whether the measured ceiling still fits the machine (see
426
+ * `memoryCeilingHasDrifted`). Callers that pass no facts get the old, purely
427
+ * version-based answer.
190
428
  */
191
- export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome()) {
429
+ export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome(), facts = null) {
192
430
  let contents;
193
431
  try {
194
432
  contents = readFile(limitsOverridePath(home));
@@ -200,15 +438,61 @@ export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'u
200
438
  if (!line)
201
439
  return true;
202
440
  const version = Number.parseInt(line.slice(LIMITS_MARKER.length).trim(), 10);
203
- return !Number.isFinite(version) || version < LIMITS_VERSION;
441
+ if (!Number.isFinite(version) || version < LIMITS_VERSION)
442
+ return true;
443
+ return facts ? memoryCeilingHasDrifted(contents, facts) : false;
204
444
  }
205
- /** Write the drop-in. Returns false when nothing needed doing. */
206
- export function writeLimitsOverride(force = false, home = systemdUserHome()) {
207
- if (!force && !limitsOverrideIsOutdated(undefined, home))
445
+ /**
446
+ * Since 0.39.0 the ceiling is a measurement, and a measurement goes stale.
447
+ *
448
+ * A machine that gained a database and a dozen containers after the drop-in was
449
+ * written would keep a ceiling sized for the machine it used to be — which is the
450
+ * original bug wearing a different hat. So the version marker is no longer the
451
+ * only thing that can make the file outdated.
452
+ *
453
+ * The 15 % band is what keeps this from becoming a rewrite on every start:
454
+ * `MemAvailable` moves by hundreds of MB just from page cache, and re-writing our
455
+ * file that often would fight an operator who added their own directives to it
456
+ * (the reason the check was version-only to begin with).
457
+ */
458
+ const CEILING_DRIFT_TOLERANCE = 0.15;
459
+ function memoryCeilingHasDrifted(contents, facts) {
460
+ // Never re-measure a machine that is still booting: the static pair it is
461
+ // holding is deliberate, and «drift» against a lie is not drift.
462
+ if (!memoryPolicy(facts).measured)
463
+ return false;
464
+ // LAST match, not the first: systemd applies the last assignment, and the file
465
+ // explicitly invites the operator to add their own lines. Reading the first one
466
+ // would measure drift against a directive that is not in force.
467
+ const written = [...contents.matchAll(/^MemoryMax=(\d+)([KMG]?)$/gm)].at(-1);
468
+ // Anything we cannot read as a byte count is replaced, and that is deliberate:
469
+ // an EMPTY `MemoryMax=` is the 0.21.0 shape this release exists to remove, and
470
+ // `MemoryMax=55%` is the blind pair we write when the machine is unmeasurable —
471
+ // both must give way the moment a real measurement is available. The operator's
472
+ // own directives belong in a file that sorts after this one, which the header of
473
+ // every generated file says; a value they appended HERE is still honoured,
474
+ // because a plain `4G` parses above and is compared like any other.
475
+ if (!written?.[1])
476
+ return true;
477
+ const scale = { '': 1, K: 1024, M: MIB, G: 1024 * MIB }[written[2] ?? ''] ?? MIB;
478
+ const writtenBytes = Number(written[1]) * scale;
479
+ const wanted = memoryPolicy(facts).maxBytes;
480
+ return Math.abs(writtenBytes - wanted) / wanted > CEILING_DRIFT_TOLERANCE;
481
+ }
482
+ /**
483
+ * Write the drop-in. Returns false when nothing needed doing.
484
+ *
485
+ * The «never below what we already hold» guard lives in `memoryPolicy` itself
486
+ * (`CEILING_HEADROOM_OVER_CURRENT`), so writing, reading and the drift check all
487
+ * arrive at the same number — otherwise a write whose floor was binding would be
488
+ * seen as drifted on the very next call and rewritten forever.
489
+ */
490
+ export function writeLimitsOverride(force = false, home = systemdUserHome(), facts = readMemoryFacts()) {
491
+ if (!force && !limitsOverrideIsOutdated(undefined, home, facts))
208
492
  return false;
209
493
  const target = limitsOverridePath(home);
210
494
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
211
- fs.writeFileSync(target, buildLimitsOverride(), { mode: 0o644 });
495
+ fs.writeFileSync(target, buildLimitsOverride(undefined, facts), { mode: 0o644 });
212
496
  return true;
213
497
  }
214
498
  /**
@@ -19,6 +19,12 @@ export interface SupervisorOptions {
19
19
  runnerToken?: string;
20
20
  /** Test seam for the `self_update` command. */
21
21
  selfUpdate?: typeof selfUpdate;
22
+ /**
23
+ * How long a freshly started agent process may say nothing before the session
24
+ * says so out loud (ticket #225). Test seam — the default is a minute, and a
25
+ * test that had to wait one would not be written.
26
+ */
27
+ startupSilenceMs?: number;
22
28
  /**
23
29
  * Called after a successful update, once the reply is on the wire. The daemon
24
30
  * exits here and systemd starts the new build; without a handler the runner
@@ -113,11 +119,53 @@ export declare class Supervisor {
113
119
  * free CHAT session with no prompt at all (the agent boots, reports its
114
120
  * capabilities and waits for the first message).
115
121
  *
116
- * Returns whether an agent process actually started: a caller holding a user
117
- * message needs to know, because a refused launch means the message has to
118
- * stay queued rather than be marked delivered (session 9).
122
+ * Returns what came of it: a caller holding a user message needs to know,
123
+ * because anything but `ok` means the message has to stay queued rather than
124
+ * be marked delivered (session 9).
125
+ *
126
+ * NEVER throws (ticket #225). Everything from reading the project prompt to
127
+ * the adapter's own constructor runs inside one guard, because the caller
128
+ * chain above cannot tell the difference between «did not start» and
129
+ * «threw»: the message path swallows the exception into a log line, and the
130
+ * reconnect path lets it abort the restore of every OTHER session on the
131
+ * machine. A launch that fails is a state this session reports, not an
132
+ * exception somebody else has to remember to catch.
119
133
  */
120
134
  private launchAgent;
135
+ /**
136
+ * The agent process could not be started at all (ticket #225).
137
+ *
138
+ * Three things have to happen here, and until this ticket none of them did:
139
+ * the reason is said WHERE THE PERSON IS LOOKING (an `error` event is the
140
+ * feed's red line), the session stops claiming to be working, and the stack
141
+ * reaches journald for whoever has to fix the machine. The status is the
142
+ * honest one for a session with no process — the agent is not running, and a
143
+ * session left in `RUNNING` shows a stop button for a turn that does not
144
+ * exist.
145
+ *
146
+ * Never rethrows: this IS the handling. The caller gets `crashed` and decides
147
+ * what to do with the message it was holding.
148
+ */
149
+ private launchCrashed;
150
+ /** How long a freshly started agent may say nothing before we say so. */
151
+ private static readonly STARTUP_SILENCE_MS;
152
+ /**
153
+ * Watch for the first word out of a process we just started (ticket #225).
154
+ *
155
+ * «The adapter object exists» is not «the agent is running». A CLI that hangs
156
+ * before its first frame — a stuck hook, an MCP server that never answers, a
157
+ * transcript it cannot read — produces no events, no error and no exit, and
158
+ * the session sits in `RUNNING` forever. On a healthy launch the first event
159
+ * arrives in about two seconds, so a minute of silence is not a slow start,
160
+ * it is something worth saying out loud.
161
+ *
162
+ * Says it and stops there: no kill. A long conversation has the right to boot
163
+ * slowly, and killing it would cost the person the very turn they are waiting
164
+ * for.
165
+ */
166
+ private watchForFirstSignOfLife;
167
+ /** The process spoke, or went away — either way the watch is over. */
168
+ private clearStartupWatch;
121
169
  /**
122
170
  * The project's own prompt file, read fresh for THIS agent process.
123
171
  *