@bridge4dev/runner 0.13.1 → 0.22.1

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.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +402 -4
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +28 -1
  35. package/dist/service-unit.d.ts +48 -1
  36. package/dist/service-unit.js +109 -4
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
package/dist/recipe.js ADDED
@@ -0,0 +1,238 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { PROJECT_RECIPE_PATH, canonicalRecipeJson, parseProjectRecipe, recipeSteps, } from './recipe-schema.js';
5
+ import { evaluateRecipeCommand, maskString } from './policy.js';
6
+ /**
7
+ * `.devbridge/project.json` — read, never executed (session 14).
8
+ *
9
+ * The file in a repository is a PROPOSAL. Nothing in this module runs anything:
10
+ * it reads the file, says whether it parses, and hands back a fingerprint. The
11
+ * executable copy lives on the DevBridge side and gets there only when a human
12
+ * with manager rights approves it — because an agent can edit files in its own
13
+ * branch, and «the runner does what the file says» would be arbitrary command
14
+ * execution by diff.
15
+ */
16
+ /** A recipe file bigger than this is not a recipe. */
17
+ const RECIPE_MAX_BYTES = 64 * 1024;
18
+ /**
19
+ * A stable fingerprint of a recipe.
20
+ *
21
+ * `verify_start` carries it and the runner refuses a run whose recipe does not
22
+ * match: an approval is an approval of specific commands, and «the approved
23
+ * recipe» has to mean the same bytes on both sides.
24
+ */
25
+ export function recipeFingerprint(recipe) {
26
+ return crypto.createHash('sha256').update(canonicalRecipeJson(recipe)).digest('hex').slice(0, 32);
27
+ }
28
+ /**
29
+ * Read the proposal out of a checkout.
30
+ *
31
+ * `lstat` and not `stat`: a symlink at `.devbridge/project.json` pointing at
32
+ * `~/.aws/credentials` would otherwise be read and shown verbatim on the
33
+ * approval screen.
34
+ *
35
+ * The DIRECTORY is checked the same way, and separately (session 15): `lstat`
36
+ * on the file follows every symlink above it, so `.devbridge` itself being a
37
+ * link to somewhere else in the filesystem walked straight past the guard while
38
+ * the guard's own comment said it did not.
39
+ */
40
+ export function readRecipeProposal(root) {
41
+ const file = path.join(root, PROJECT_RECIPE_PATH);
42
+ const empty = {
43
+ path: file,
44
+ present: false,
45
+ raw: null,
46
+ recipe: null,
47
+ error: null,
48
+ sha: null,
49
+ steps: [],
50
+ commands: [],
51
+ refusals: [],
52
+ };
53
+ // Everything read here is shown to a human and hashed into an approval, so
54
+ // it has to come from inside the checkout — not from wherever a link points.
55
+ const insideCheckout = (() => {
56
+ try {
57
+ const realRoot = fs.realpathSync(root);
58
+ const realDir = fs.realpathSync(path.dirname(file));
59
+ const rel = path.relative(realRoot, realDir);
60
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
61
+ }
62
+ catch {
63
+ // No `.devbridge` directory at all — the ordinary case, and the read
64
+ // below reports it as «no recipe» on its own.
65
+ return true;
66
+ }
67
+ })();
68
+ if (!insideCheckout) {
69
+ return {
70
+ ...empty,
71
+ present: true,
72
+ error: `${PROJECT_RECIPE_PATH} points outside the repository`,
73
+ };
74
+ }
75
+ let raw;
76
+ try {
77
+ const stat = fs.lstatSync(file);
78
+ if (!stat.isFile()) {
79
+ return { ...empty, present: true, error: `${PROJECT_RECIPE_PATH} is not a regular file` };
80
+ }
81
+ if (stat.size > RECIPE_MAX_BYTES) {
82
+ return {
83
+ ...empty,
84
+ present: true,
85
+ error: `${PROJECT_RECIPE_PATH} is larger than ${Math.round(RECIPE_MAX_BYTES / 1024)} KB`,
86
+ };
87
+ }
88
+ raw = fs.readFileSync(file, 'utf8');
89
+ }
90
+ catch {
91
+ return empty;
92
+ }
93
+ const masked = maskString(raw).slice(0, RECIPE_MAX_BYTES);
94
+ let parsedJson;
95
+ try {
96
+ // JSONC in the plan's example is illustrative; the file itself is JSON.
97
+ // Comments are stripped rather than refused — a recipe people hand-write is
98
+ // exactly the kind of file that grows a `// why` line.
99
+ parsedJson = JSON.parse(stripJsonComments(raw));
100
+ }
101
+ catch (error) {
102
+ return {
103
+ ...empty,
104
+ present: true,
105
+ raw: masked,
106
+ error: `${PROJECT_RECIPE_PATH} is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`,
107
+ };
108
+ }
109
+ const parsed = parseProjectRecipe(parsedJson);
110
+ if (!parsed.ok) {
111
+ return { ...empty, present: true, raw: masked, error: parsed.error };
112
+ }
113
+ return {
114
+ path: file,
115
+ present: true,
116
+ raw: masked,
117
+ recipe: parsed.recipe,
118
+ error: null,
119
+ sha: recipeFingerprint(parsed.recipe),
120
+ steps: recipeSteps(parsed.recipe),
121
+ commands: recipeCommands(parsed.recipe),
122
+ refusals: recipeRefusals(parsed.recipe),
123
+ };
124
+ }
125
+ /**
126
+ * Every command in the recipe, in the order a person should read them —
127
+ * `preview.run` and `preview.stop` included, exactly as the refusal check
128
+ * already covers them.
129
+ */
130
+ export function recipeCommands(recipe) {
131
+ const entries = [];
132
+ for (const name of recipeSteps(recipe)) {
133
+ const run = recipe.steps?.[name]?.run;
134
+ if (run)
135
+ entries.push({ step: name, run });
136
+ }
137
+ if (recipe.preview?.run)
138
+ entries.push({ step: 'preview.run', run: recipe.preview.run });
139
+ if (recipe.preview?.stop)
140
+ entries.push({ step: 'preview.stop', run: recipe.preview.stop });
141
+ return entries.map(({ step, run }) => {
142
+ const masked = maskString(run);
143
+ return { step, run: masked, redacted: masked !== run };
144
+ });
145
+ }
146
+ /**
147
+ * Every command in the recipe that layer 1 would refuse, named by its step.
148
+ *
149
+ * Answered at read time, not at run time. A recipe whose `deploy` step contains
150
+ * `sudo systemctl restart` must fail the person reading the approval screen, not
151
+ * a build twenty minutes into a release.
152
+ */
153
+ export function recipeRefusals(recipe) {
154
+ const refusals = [];
155
+ // A command a person cannot read is a command they cannot approve. Masking is
156
+ // a rewrite, and the executed copy is the unmasked one — so a redaction here
157
+ // is not a cosmetic detail, it is the gap between what was shown and what
158
+ // will run. Blocking approval closes it, and moving the secret into `env`
159
+ // (which is never a command) is the way out.
160
+ for (const command of recipeCommands(recipe)) {
161
+ if (!command.redacted)
162
+ continue;
163
+ refusals.push({
164
+ step: command.step,
165
+ reason: 'the command contains something that looks like a secret, so it cannot be shown in full — move it into `env` and approve a command you can read',
166
+ });
167
+ }
168
+ for (const name of recipeSteps(recipe)) {
169
+ const run = recipe.steps?.[name]?.run;
170
+ if (!run)
171
+ continue;
172
+ const decision = evaluateRecipeCommand(run);
173
+ if (!decision.allowed)
174
+ refusals.push({ step: name, reason: decision.reason ?? 'refused' });
175
+ }
176
+ if (recipe.preview?.run) {
177
+ const decision = evaluateRecipeCommand(recipe.preview.run);
178
+ if (!decision.allowed) {
179
+ refusals.push({ step: 'preview.run', reason: decision.reason ?? 'refused' });
180
+ }
181
+ }
182
+ if (recipe.preview?.stop) {
183
+ const decision = evaluateRecipeCommand(recipe.preview.stop, {
184
+ isPreviewStop: true,
185
+ ...(recipe.preview.project ? { dockerProject: recipe.preview.project } : {}),
186
+ });
187
+ if (!decision.allowed) {
188
+ refusals.push({ step: 'preview.stop', reason: decision.reason ?? 'refused' });
189
+ }
190
+ }
191
+ return refusals;
192
+ }
193
+ /**
194
+ * Drop `//` and `/* *\/` comments without touching what is inside strings.
195
+ *
196
+ * A hand-rolled scanner rather than a regex: a regex that ignores string
197
+ * context eats the `//` in a URL, which is exactly what `health.url` is.
198
+ */
199
+ export function stripJsonComments(text) {
200
+ let out = '';
201
+ let inString = false;
202
+ let escaped = false;
203
+ for (let i = 0; i < text.length; i++) {
204
+ const ch = text[i];
205
+ if (inString) {
206
+ out += ch;
207
+ if (escaped)
208
+ escaped = false;
209
+ else if (ch === '\\')
210
+ escaped = true;
211
+ else if (ch === '"')
212
+ inString = false;
213
+ continue;
214
+ }
215
+ if (ch === '"') {
216
+ inString = true;
217
+ out += ch;
218
+ continue;
219
+ }
220
+ if (ch === '/' && text[i + 1] === '/') {
221
+ while (i < text.length && text[i] !== '\n')
222
+ i++;
223
+ out += '\n';
224
+ continue;
225
+ }
226
+ if (ch === '/' && text[i + 1] === '*') {
227
+ i += 2;
228
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
229
+ i++;
230
+ i++;
231
+ continue;
232
+ }
233
+ out += ch;
234
+ }
235
+ // A trailing comma is the other thing a hand-written file grows.
236
+ return out.replace(/,(\s*[}\]])/g, '$1');
237
+ }
238
+ //# sourceMappingURL=recipe.js.map
@@ -45,6 +45,13 @@ export interface SelfUpdateOptions {
45
45
  packageDir?: string | null;
46
46
  /** Test seam: is this process supervised (defaults to autodetect). */
47
47
  supervised?: boolean;
48
+ /**
49
+ * Test seam for the resource-limits drop-in. Injected rather than called
50
+ * directly because the real one writes into `$HOME` — a test that exercised
51
+ * the update path would otherwise reconfigure the developer's own service.
52
+ * Returns true when it wrote something (and therefore needs a daemon-reload).
53
+ */
54
+ writeLimits?: () => boolean;
48
55
  }
49
56
  /**
50
57
  * Directory of the installed runner package, or null when it is running from a
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
5
5
  import { log } from './log.js';
6
6
  import { stateDir } from './paths.js';
7
7
  import { RUNNER_VERSION } from './version.js';
8
- import { buildUnit, unitIsBroken, unitPath } from './service-unit.js';
8
+ import { buildUnit, limitsOverridePath, unitIsBroken, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
9
9
  const execFileAsync = promisify(execFile);
10
10
  const NPM_TIMEOUT_MS = 180_000;
11
11
  const VERIFY_TIMEOUT_MS = 30_000;
@@ -303,6 +303,33 @@ export async function selfUpdate(options) {
303
303
  `could not be repaired (${describe(error)}). Run \`devbridge-runner install-service\` on the server.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
304
304
  }
305
305
  }
306
+ // The resource policy is versioned separately from the code, because the unit
307
+ // is written once at install time and the numbers baked into it before 0.21.0
308
+ // are what killed live sessions (QA-112 BLOCKER-1). An update is the only
309
+ // moment we are guaranteed to be running on the server with the right to fix
310
+ // that, so it happens here — and the restart below is what makes it effective.
311
+ //
312
+ // Deliberately NOT fatal: a runner that refuses to update because it could not
313
+ // improve its own limits is strictly worse than one that updates without the
314
+ // improvement. It is logged loudly and `doctor` reports it.
315
+ try {
316
+ if ((options.writeLimits ?? writeLimitsOverride)()) {
317
+ await exec('systemctl', ['--user', 'daemon-reload'], {
318
+ timeout: VERIFY_TIMEOUT_MS,
319
+ env: npmEnv(),
320
+ });
321
+ log.warn('self-update: resource limits drop-in written', {
322
+ path: limitsOverridePath(),
323
+ version: LIMITS_VERSION,
324
+ });
325
+ }
326
+ }
327
+ catch (error) {
328
+ log.error('self-update: could not write the resource limits drop-in', {
329
+ error: describe(error),
330
+ hint: 'run `devbridge-runner doctor --fix` on the server',
331
+ });
332
+ }
306
333
  log.info('self-update: installed', { fromVersion, toVersion });
307
334
  return {
308
335
  ok: true,
@@ -19,8 +19,55 @@ export declare function unitExecTarget(argv1?: string): {
19
19
  execStart: string;
20
20
  viaCommand: boolean;
21
21
  };
22
- export declare function unitPath(): string;
22
+ export declare function unitPath(home?: string): string;
23
23
  export declare function buildUnit(execStart?: string): string;
24
+ /**
25
+ * Resource policy for the service, and why it does not live in the unit above.
26
+ *
27
+ * The unit is written ONCE, at install time. Until 0.21.0 it carried
28
+ * `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
29
+ * server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
30
+ *
31
+ * - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
32
+ * agent runs, is a CHILD of this service and therefore inside this cgroup.
33
+ * Three measured `claude` processes are 512/577/646 MB and one workspace
34
+ * `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
35
+ * - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
36
+ * tore down the whole service — and with it every other session on the
37
+ * machine. `Restart=always` then brought the daemon back with an empty
38
+ * session map, which is what the user sees as
39
+ * «Runner reconnected. The session was resumed»;
40
+ * - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
41
+ * what starves the event loop until the gateway's heartbeat gives up on it.
42
+ *
43
+ * So the policy is versioned and shipped as a drop-in instead. A drop-in can
44
+ * RESET a directive the main unit set (`MemoryMax=` with no value clears it),
45
+ * which is the only way to fix the servers that already have the bad numbers
46
+ * baked in — and it never overwrites a unit the operator edited by hand.
47
+ */
48
+ export declare const LIMITS_VERSION = 2;
49
+ export declare function limitsOverridePath(home?: string): string;
50
+ /**
51
+ * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
52
+ * box unreachable, but never so little that ordinary work is throttled.
53
+ *
54
+ * One core is reserved for the system (sshd, journald, the runner's own event
55
+ * loop). Below four cores there is nothing to reserve without crippling the
56
+ * agents, so no quota is set at all — an unusable dev server is a worse failure
57
+ * than a busy one.
58
+ */
59
+ export declare function cpuQuotaPercent(cpuCount?: number): number | null;
60
+ export declare function buildLimitsOverride(cpuCount?: number): string;
61
+ /**
62
+ * Is the shipped resource policy missing or from an older runner?
63
+ *
64
+ * Deliberately version-based rather than content-based: an operator may add
65
+ * their own directives to our file, and re-writing on every start would fight
66
+ * them. Only the version number decides.
67
+ */
68
+ export declare function limitsOverrideIsOutdated(readFile?: (p: string) => string, home?: string): boolean;
69
+ /** Write the drop-in. Returns false when nothing needed doing. */
70
+ export declare function writeLimitsOverride(force?: boolean, home?: string): boolean;
24
71
  /**
25
72
  * Does the installed unit point at something that no longer exists?
26
73
  *
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { systemdUserHome } from './paths.js';
4
5
  /**
5
6
  * The systemd user unit, and the one decision inside it that matters: what to
6
7
  * exec.
@@ -52,8 +53,8 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
52
53
  }
53
54
  return { execStart: script, viaCommand: false };
54
55
  }
55
- export function unitPath() {
56
- return path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
56
+ export function unitPath(home = systemdUserHome()) {
57
+ return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
57
58
  }
58
59
  export function buildUnit(execStart) {
59
60
  const target = execStart ?? unitExecTarget().execStart;
@@ -71,13 +72,117 @@ export function buildUnit(execStart) {
71
72
  `ExecStart=${command}`,
72
73
  'Restart=always',
73
74
  'RestartSec=5',
74
- 'CPUQuota=80%',
75
- 'MemoryMax=2G',
76
75
  '',
77
76
  '[Install]',
78
77
  'WantedBy=default.target',
79
78
  ].join('\n') + '\n');
80
79
  }
80
+ /**
81
+ * Resource policy for the service, and why it does not live in the unit above.
82
+ *
83
+ * The unit is written ONCE, at install time. Until 0.21.0 it carried
84
+ * `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
85
+ * server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
86
+ *
87
+ * - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
88
+ * agent runs, is a CHILD of this service and therefore inside this cgroup.
89
+ * Three measured `claude` processes are 512/577/646 MB and one workspace
90
+ * `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
91
+ * - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
92
+ * tore down the whole service — and with it every other session on the
93
+ * machine. `Restart=always` then brought the daemon back with an empty
94
+ * session map, which is what the user sees as
95
+ * «Runner reconnected. The session was resumed»;
96
+ * - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
97
+ * what starves the event loop until the gateway's heartbeat gives up on it.
98
+ *
99
+ * So the policy is versioned and shipped as a drop-in instead. A drop-in can
100
+ * RESET a directive the main unit set (`MemoryMax=` with no value clears it),
101
+ * which is the only way to fix the servers that already have the bad numbers
102
+ * baked in — and it never overwrites a unit the operator edited by hand.
103
+ */
104
+ export const LIMITS_VERSION = 2;
105
+ const LIMITS_MARKER = '# devbridge-limits-version:';
106
+ /** `zz-` so it sorts last: an operator's own drop-in should still win. */
107
+ const LIMITS_FILE = 'zz-devbridge-limits.conf';
108
+ export function limitsOverridePath(home = systemdUserHome()) {
109
+ return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service.d`, LIMITS_FILE);
110
+ }
111
+ /**
112
+ * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
113
+ * box unreachable, but never so little that ordinary work is throttled.
114
+ *
115
+ * One core is reserved for the system (sshd, journald, the runner's own event
116
+ * loop). Below four cores there is nothing to reserve without crippling the
117
+ * agents, so no quota is set at all — an unusable dev server is a worse failure
118
+ * than a busy one.
119
+ */
120
+ export function cpuQuotaPercent(cpuCount = os.cpus().length) {
121
+ return cpuCount >= 4 ? (cpuCount - 1) * 100 : null;
122
+ }
123
+ export function buildLimitsOverride(cpuCount = os.cpus().length) {
124
+ const quota = cpuQuotaPercent(cpuCount);
125
+ return ([
126
+ `${LIMITS_MARKER} ${LIMITS_VERSION}`,
127
+ '# Managed by devbridge-runner. Put your own overrides in a file that sorts',
128
+ '# after this one, or edit the unit itself — neither is touched by updates.',
129
+ '',
130
+ '[Unit]',
131
+ // Five fast failures used to leave the service in `failed` and the server
132
+ // offline until someone logged in. A dev runner must always come back;
133
+ // crash loops are surfaced through `lastExit` in hello, not by giving up.
134
+ 'StartLimitIntervalSec=0',
135
+ 'StartLimitBurst=0',
136
+ '',
137
+ '[Service]',
138
+ // The whole point of the change: one child's OOM must not take the fleet.
139
+ 'OOMPolicy=continue',
140
+ // Clears `MemoryMax=2G` from units written before 0.21.0.
141
+ 'MemoryMax=',
142
+ // Soft pressure instead of a hard ceiling: the kernel reclaims and
143
+ // throttles rather than killing, and the machine keeps a fifth of its
144
+ // memory for everything that is not this service.
145
+ 'MemoryHigh=80%',
146
+ // Either a computed quota, or an explicit reset — both of which clear the
147
+ // `CPUQuota=80%` baked into units written before 0.21.0.
148
+ quota === null ? 'CPUQuota=' : `CPUQuota=${quota}%`,
149
+ // Needed for `doctor` and for the memory/CPU figures the runner reports.
150
+ 'MemoryAccounting=yes',
151
+ 'CPUAccounting=yes',
152
+ // An agent running a monorepo build forks a lot; the default is per-user.
153
+ 'TasksMax=8192',
154
+ ].join('\n') + '\n');
155
+ }
156
+ /**
157
+ * Is the shipped resource policy missing or from an older runner?
158
+ *
159
+ * Deliberately version-based rather than content-based: an operator may add
160
+ * their own directives to our file, and re-writing on every start would fight
161
+ * them. Only the version number decides.
162
+ */
163
+ export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome()) {
164
+ let contents;
165
+ try {
166
+ contents = readFile(limitsOverridePath(home));
167
+ }
168
+ catch {
169
+ return true; // never written — every server that predates 0.21.0
170
+ }
171
+ const line = contents.split('\n').find((l) => l.startsWith(LIMITS_MARKER));
172
+ if (!line)
173
+ return true;
174
+ const version = Number.parseInt(line.slice(LIMITS_MARKER.length).trim(), 10);
175
+ return !Number.isFinite(version) || version < LIMITS_VERSION;
176
+ }
177
+ /** Write the drop-in. Returns false when nothing needed doing. */
178
+ export function writeLimitsOverride(force = false, home = systemdUserHome()) {
179
+ if (!force && !limitsOverrideIsOutdated(undefined, home))
180
+ return false;
181
+ const target = limitsOverridePath(home);
182
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
183
+ fs.writeFileSync(target, buildLimitsOverride(), { mode: 0o644 });
184
+ return true;
185
+ }
81
186
  /**
82
187
  * Does the installed unit point at something that no longer exists?
83
188
  *
@@ -1,4 +1,5 @@
1
1
  import { JournalStore } from './journal.js';
2
+ import { proposeCommitMessage } from './commit-message.js';
2
3
  import { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
3
4
  import type { RunnerWsClient } from './ws-client.js';
4
5
  import type { SessionDescriptor } from './protocol.js';
@@ -30,6 +31,14 @@ export interface SupervisorOptions {
30
31
  * machine's owner decides how much of their machine an agent fleet may take.
31
32
  */
32
33
  maxSessionsLimit?: number;
34
+ /**
35
+ * `[verify] enabled` from the runner's own config (session 14). The machine's
36
+ * owner has the last word on whether project recipes run here at all;
37
+ * `false` means the capability is not announced and no run can be started.
38
+ */
39
+ verifyEnabled?: boolean;
40
+ /** Test seam for the one-shot commit-message run. */
41
+ proposeCommitMessage?: typeof proposeCommitMessage;
33
42
  }
34
43
  export declare class Supervisor {
35
44
  private readonly ws;
@@ -54,10 +63,32 @@ export declare class Supervisor {
54
63
  private readonly repoLocks;
55
64
  /** An update is installing right now — a second one would fight it. */
56
65
  private selfUpdateInFlight;
66
+ /** Session 14: one project-recipe run per machine, and its verdict queue. */
67
+ private readonly verify;
68
+ private readonly verifyReports;
57
69
  constructor(ws: RunnerWsClient, opts: SupervisorOptions);
70
+ /**
71
+ * Push every unacked verdict at the API.
72
+ *
73
+ * Called on each reconnect and whenever a run finishes. Sending a report the
74
+ * API already has is harmless — it is keyed by `runId` and stored idempotently
75
+ * — while not sending one is a verdict that never existed.
76
+ */
77
+ private flushVerifyReports;
58
78
  get activeSessionIds(): string[];
59
79
  private onFrame;
60
80
  private startSession;
81
+ /**
82
+ * Where this session is going to work — a worktree of its own, or the project
83
+ * folder itself (session 16).
84
+ *
85
+ * The BRANCH path takes the repo lock because `worktree add` writes into the
86
+ * shared `.git` (registration + prune), exactly like commit/apply/revert. The
87
+ * DIRECT path takes none: it only READS which branch a folder is on, and
88
+ * queueing every session start behind whatever merge happens to be running
89
+ * would be a lock bought for nothing.
90
+ */
91
+ private prepareWorkspace;
61
92
  /**
62
93
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
63
94
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -100,6 +131,23 @@ export declare class Supervisor {
100
131
  */
101
132
  private pauseForBudget;
102
133
  private pumpEvents;
134
+ /**
135
+ * "…and N of them are waiting for an answer from you."
136
+ *
137
+ * An open question pins its slot deliberately (`isParkable`), so a message
138
+ * that blames a running turn sends the user to wait for something that will
139
+ * never happen. Empty when nothing is waiting.
140
+ */
141
+ private waitingForAnswerSuffix;
142
+ /**
143
+ * Close out every ask this session still has open, with a stated cause.
144
+ *
145
+ * Idempotent: the adapter reports its own `question_resolved` when it can, and
146
+ * `forwardEvent` clears the id — so by the time this runs the set is usually
147
+ * already empty. What it catches is the path where the adapter never got the
148
+ * chance, and the alternative there is a card that stays clickable forever.
149
+ */
150
+ private withdrawOpenQuestions;
103
151
  /**
104
152
  * Deliver messages that were held because every slot was taken.
105
153
  *
@@ -140,10 +188,27 @@ export declare class Supervisor {
140
188
  * returns false and the caller tells the user rather than thrashing.
141
189
  */
142
190
  private ensureCapacity;
143
- /** Idle after a finished turn — safe to kill the process and resume later. */
191
+ /**
192
+ * Idle after a finished turn — safe to kill the process and resume later.
193
+ *
194
+ * An open question is the exception (session 12): WAITING_INPUT there does
195
+ * NOT mean "the turn is over", it means the agent's tool call is parked on a
196
+ * human. Parking such a session killed a live turn — and, worse, killed the
197
+ * card the user was about to answer — the moment another session wanted a
198
+ * slot.
199
+ */
144
200
  private isParkable;
145
201
  private park;
146
202
  private forwardEvent;
203
+ /**
204
+ * The dashboard's answer to a parked question (session 12).
205
+ *
206
+ * A miss is reported in the feed rather than swallowed: the three cases that
207
+ * get here — a card from a previous life of the session, a second click, an
208
+ * ask the runner already withdrew — all look identical to the user unless
209
+ * somebody says so.
210
+ */
211
+ private onQuestionAnswer;
147
212
  private onUserMessage;
148
213
  /**
149
214
  * Run delivery work for one session, strictly after whatever is already
@@ -212,5 +277,47 @@ export declare class Supervisor {
212
277
  /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
213
278
  shutdown(): void;
214
279
  }
280
+ /**
281
+ * The first message the agent gets.
282
+ *
283
+ * Two shapes, and the difference is whether tickets were handed over
284
+ * (session 16, owner's decision):
285
+ *
286
+ * - **No tickets** — exactly what the human typed, and an empty prompt stays
287
+ * empty. That is how you get a session that boots and waits for you to talk
288
+ * first; it is a feature, not an oversight.
289
+ * - **Tickets handed to a TICKET session** — the assignment is always sent and
290
+ * cannot be removed: «Implement ticket #28.» Handing a ticket over IS the
291
+ * instruction, so making the human retype it was ceremony. Anything they
292
+ * typed follows underneath.
293
+ *
294
+ * What is NOT here any more: eight lines about which MCP tools to call and
295
+ * which statuses to move through. That is a standing convention of the project,
296
+ * true on the twentieth turn as much as the first, so it moved to the system
297
+ * prompt beside `CLAUDE.md` — which is read last and therefore overrides ours.
298
+ */
215
299
  export declare function composeInitialPrompt(descriptor: SessionDescriptor): string;
300
+ /**
301
+ * Extra system-prompt material: where this session is in git, and how tickets
302
+ * are meant to move (session 13).
303
+ *
304
+ * Facts about THIS SESSION, and nothing else. They exist in no file on disk —
305
+ * which branch the agent is on, that it must not push, where a plan belongs,
306
+ * what to do with the tickets it was given — so somebody has to say them, and
307
+ * that somebody is us.
308
+ *
309
+ * The project's own documentation is deliberately NOT here. Both agents read
310
+ * their own file natively, verified live: Claude picks up `CLAUDE.md` through
311
+ * its memory mechanism (since `settingSources` includes `'project'`), and Codex
312
+ * picks up `AGENTS.md` even under the runner's isolated `CODEX_HOME`. Pasting a
313
+ * copy on top of that was work we were doing for no one — and when it was
314
+ * switched off for Claude alone it briefly left repositories that carry only
315
+ * `AGENTS.md` with nothing at all, which is precisely the kind of hole a
316
+ * half-measure digs.
317
+ *
318
+ * A repository that wants both agents equipped ships both files, or symlinks
319
+ * one to the other. That is a repository convention and not something a runner
320
+ * should paper over.
321
+ */
322
+ export declare function composeWorkspaceContext(descriptor: SessionDescriptor): string;
216
323
  //# sourceMappingURL=supervisor.d.ts.map