cortad 0.3.1-rc.9 → 0.3.2

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 (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
package/local.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Cortad on your own machine. Run from your repository's root with the code the connect screen
3
3
  // showed:
4
- // npx cortad ABCD2345 [--port 3000] [--start "npm run dev"] [--verbose]
4
+ // npx cortad ABCD2345 [--port 3000] [--start "npm run dev"] [--proxy] [--verbose]
5
5
  //
6
6
  // What it does: signs in with the code, uploads your source files once (never .env, never
7
7
  // node_modules) so your code can be read, starts your app the way you start it, then holds one
@@ -12,6 +12,8 @@
12
12
  // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
13
13
  // never leaves this machine. Ctrl-C ends everything.
14
14
 
15
+ import { storesOf } from "./lib/stores.mjs";
16
+ import { notAnApp, withoutStores } from "./lib/app-port.mjs";
15
17
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
16
18
  import { spawn, execFile, execFileSync } from "node:child_process";
17
19
  import { createHash } from "node:crypto";
@@ -20,27 +22,42 @@ import { homedir, hostname, tmpdir } from "node:os";
20
22
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
21
23
  import { createInterface } from "node:readline";
22
24
  import { promisify } from "node:util";
23
- import { COMMANDS, main as face } from "./lib/cli.mjs";
24
- import { clearRunner, homeOf, projectOf, readToken, writeApp, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
25
- import { commandText, elapsedMs, RELOADER, sourceOf } from "./lib/fresh.mjs";
25
+ import { COMMANDS, main as face, unknownVerb, VERBS } from "./lib/cli.mjs";
26
+ import { addNews, homeOf, projectOf, readToken, writeApp, writeDigest, writeToken } from "./lib/home.mjs";
27
+ import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
28
+ import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree, supervisorOf } from "./lib/proc.mjs";
29
+ import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, writeRunner } from "./lib/runner.mjs";
26
30
  import { registerAll } from "./lib/register.mjs";
27
- import { finished } from "./lib/text.mjs";
31
+ import { machineText } from "./lib/read-text.mjs";
32
+ import { VERSION } from "./lib/spec.mjs";
33
+ import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
34
+ import { finished, unseenText } from "./lib/text.mjs";
28
35
  import { lockHolds, makeLock } from "./lib/lock.mjs";
29
36
  import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
30
37
  import { CAPTURED, makeCapture } from "./lib/replay.mjs";
31
38
  import { sampleHere } from "./lib/sample.mjs";
32
- import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
39
+ import { mintAcross, originFor, servicePort, waitForPort } from "./lib/service.mjs";
33
40
  import { listingUrl } from "./lib/listing.mjs";
34
- import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
35
- import { openSwitches } from "./lib/switches.mjs";
36
- import { keepData } from "./lib/data.mjs";
37
- import { markRun, writesDirOf } from "./lib/writes.mjs";
41
+ import { hookable, installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
42
+ import { openProxy } from "./lib/proxy.mjs";
43
+ import { liftedLimits, sessionLifts } from "./lib/switches.mjs";
44
+ import { makeKeeping } from "./lib/keeping.mjs";
45
+ import { downLine, makeHealth } from "./lib/health.mjs";
46
+ import { markRun, writesDirOf, writtenPaths } from "./lib/writes.mjs";
47
+ import { makeHeld } from "./lib/held.mjs";
48
+ import { answerAsk, ASK, makeAutoReach, takeAsk } from "./lib/auto-reach.mjs";
38
49
 
39
50
  const argv = process.argv.slice(2);
40
51
  // The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
41
52
  // client starts on every session, and the same verbs as shell commands. Neither runs the connect
42
53
  // below, and `npx cortad findings` is a verb, never a code.
43
54
  if (COMMANDS.has(argv[0] ?? "")) process.exit(await face(argv));
55
+ // A command word this version does not know is never taken for a connect code: `feedback`, read as
56
+ // the code FEEDBACK, signed the folder in again and ended the session it was typed in.
57
+ if (unknownVerb(argv[0])) {
58
+ console.error(`cortad ${argv[0]} is not a command of cortad ${VERSION}. Its commands are ${VERBS.slice(0, -1).join(", ")} and ${VERBS.at(-1)}; npx cortad@latest <command> runs the newest. A connect code is typed in capitals, as the connect screen shows it.`);
59
+ process.exit(1);
60
+ }
44
61
  const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
45
62
  const verbose = argv.includes("--verbose");
46
63
  // A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
@@ -49,6 +66,8 @@ const say = (line) => console.log(`cortad ${line}`);
49
66
  const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
50
67
 
51
68
  const explain = argv.includes("--explain");
69
+ // The app is seen through a local proxy (lib/proxy.mjs) even where the hook could load into it.
70
+ const proxyAsked = argv.includes("--proxy");
52
71
  // Started by lib/cli.mjs for a run on a later day: no code, the key the first connect left behind.
53
72
  const viaToken = argv.includes("--token");
54
73
  // What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
@@ -63,7 +82,7 @@ const step = (line) => {
63
82
  };
64
83
  const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
65
84
  const stepDone = (line) => { clearStep(); say(line); };
66
- if (!explain && !viaToken && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain | npx cortad status | run | findings | verify <id>");
85
+ if (!explain && !viaToken && !/^[A-Z0-9]{8}$/.test(code)) fail(`usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] [--proxy] | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`);
67
86
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
68
87
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
69
88
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
@@ -83,6 +102,34 @@ const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod"
83
102
  if (!MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
84
103
  fail(`no project here: this folder has no package.json or pyproject.toml, and neither does any folder in it. Run it from your repository's root: ${root}`);
85
104
  }
105
+ // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
106
+ // connection, and the path itself never leaves this machine.
107
+ const project = projectOf(root);
108
+
109
+ // One process holds a project's app up (lib/runner.mjs), and its state is what every reader reports.
110
+ // `reach: "ask"`: an agent's reach is sent by this process (lib/auto-reach.mjs), signed in where it can.
111
+ const me = { pid: process.pid, startedAt: startedAtOf(), by: viaToken ? "token" : "connect", reach: "ask", version: VERSION };
112
+ // The last record written, so a fact learned while the app is up is added to it.
113
+ let became = null;
114
+ // What this command did on this machine, once the app answers: said at connect, and kept in the
115
+ // record for `status` with show machine (lib/read-text.mjs machineText).
116
+ let machineOf = () => null;
117
+ const become = (state, fields = {}) => { became = { state, fields }; const machine = machineOf(); writeRunner(project, { ...me, ...(machine ? { machine } : {}), state, at: new Date().toISOString(), ...fields }); };
118
+ process.on("exit", () => releaseRunner(project));
119
+ // Started for a run while another process already holds this project's app up: that one serves it.
120
+ // A connect from the screen is the person starting over, so the one before it is ended first.
121
+ async function holdProject() {
122
+ for (let tries = 0; tries < 2; tries++) {
123
+ const held = claimRunner(project, { ...me, state: "starting", at: new Date().toISOString() });
124
+ if (!held) return;
125
+ if (viaToken) { say(`pid ${held.pid} already holds this project's app up and serves the run`); process.exit(0); }
126
+ say(`an earlier cortad (pid ${held.pid}) is holding this project's app up; stopping it so only this one runs`);
127
+ await replaceRunner(held);
128
+ }
129
+ fail("another cortad process keeps holding this project's app up. Stop it and run this again.");
130
+ }
131
+ // A verb waits on this record from the moment it starts this process, so it is taken first.
132
+ if (viaToken) await holdProject();
86
133
 
87
134
  // ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
88
135
  const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
@@ -148,21 +195,6 @@ function carriesKey(rel) {
148
195
  try { return holdsKeys(readFileSync(join(root, rel), "utf8"), envKeys); } catch { return false; }
149
196
  }
150
197
 
151
- // Values from your env files, read here and only here, so nothing a command prints can carry one.
152
- function secretValues(envFiles) {
153
- const values = new Set();
154
- for (const file of envFiles) {
155
- let text = "";
156
- try { text = readFileSync(file, "utf8"); } catch { continue; }
157
- for (const line of text.split("\n")) {
158
- const m = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)$/.exec(line);
159
- if (!m) continue;
160
- const v = m[1].trim().replace(/^(['"])(.*)\1$/, "$2");
161
- if (v.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(v)) values.add(v);
162
- }
163
- }
164
- return [...values].sort((a, b) => b.length - a.length);
165
- }
166
198
  // Origins your environment names (FRONTEND_URL, CORS_ORIGIN, ...): an app that trusts a browser
167
199
  // Origin is asked as that browser. Sent as origins only, never the variable's full value.
168
200
  function envOrigins(envFiles) {
@@ -182,43 +214,6 @@ function envOrigins(envFiles) {
182
214
  }
183
215
  return [...out].slice(0, 8);
184
216
  }
185
- // Your app's own request limits, raised for this session only, the way the sandbox raises them:
186
- // a run asks in twenty minutes what a person asks in a month, and a limiter that fires answers
187
- // instead of your AI. Numeric values under a limit-shaped name; switches and guards are left alone.
188
- const THROUGHPUT = /(RATE_?LIMIT|DAILY_LIMIT|HOURLY_LIMIT|MINUTE_LIMIT|REQUESTS_PER|TOKEN_BUDGET|MAX_SSE|MAX_CONCURRENT|THROTTLE|_RPM$|_RPS$|_QPS$)/;
189
- const GUARDED = /(AUTH|LOGIN|PASSWORD|BREAKER|LOCKOUT|ATTEMPT|FAIL|BAN|BLOCK)/;
190
- const SWITCH = /_(?:ENABLED|DISABLED|ENABLE|DISABLE)$|^(?:ENABLE|DISABLE)_/;
191
- // Limit-shaped names the code itself reads (process.env.GUEST_DAILY_LIMIT, os.environ.get("RATE_LIMIT_RPM"))
192
- // count too: a limit with a default in code and no line in .env is the one that closes on a run.
193
- const ENV_READ = /(?:process\.env(?:\.|\[\s*['"])|os\.(?:environ\.get|getenv)\(\s*['"]|os\.environ\[\s*['"]|\benv\(\s*['"]|Deno\.env\.get\(\s*['"])([A-Z][A-Z0-9_]*)/g;
194
- const lifted = (name) => (/(TOKEN_BUDGET|TOKENS)/.test(name) ? "1000000000" : "1000000");
195
- const liftable = (name) => THROUGHPUT.test(name) && !GUARDED.test(name) && !SWITCH.test(name);
196
- function liftedLimits(envFiles, sources = []) {
197
- // Only a limit the code reads is raised: a line in .env that nothing reads is not a limit, and
198
- // naming it as one raised was the tell an engineer caught first.
199
- const read = new Set();
200
- for (const file of sources) {
201
- if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
202
- let text = "";
203
- try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
204
- for (const m of text.matchAll(ENV_READ)) if (liftable(m[1])) read.add(m[1]);
205
- }
206
- const out = {};
207
- for (const file of envFiles) {
208
- let text = "";
209
- try { text = readFileSync(file, "utf8"); } catch { continue; }
210
- for (const line of text.split("\n")) {
211
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
212
- if (!m) continue;
213
- const [, name, raw] = m;
214
- const value = raw.trim().replace(/^(['"])(.*)\1$/, "$2");
215
- if (liftable(name) && /^\d+$/.test(value) && (!sources.length || read.has(name))) out[name] = lifted(name);
216
- }
217
- }
218
- for (const name of read) if (!(name in out)) out[name] = lifted(name);
219
- return out;
220
- }
221
-
222
217
  // Every name your env files set, with its value, read here and used only here. An example file is
223
218
  // read first so a real one's value wins: a placeholder key asked for a model listing comes back
224
219
  // refused, and that would read as your own key being refused.
@@ -266,8 +261,30 @@ async function inventoryOf(listings) {
266
261
  return { providers: providers.filter(Boolean), flags: switchStates(values) };
267
262
  }
268
263
  let secrets = [];
264
+ // A run's request to an endpoint your app answers later carries the first (src/customer/endpoint.ts),
265
+ // with how long the run waits for this reply; a reply with no answer says in the second why none came.
266
+ // The wait here ends a few seconds before the run's own and the wire's hold, so the reason reaches it.
267
+ const LATER_HEADER = "x-cortad-answer", LATER_MISSED = "x-cortad-later", LATER_MARGIN_MS = 5_000;
268
+ // The cookies each trial's own turns were handed, newest trials kept.
269
+ const trialJars = new Map();
270
+ // Endpoints that refused a new visitor: their trials carry the person's own session.
271
+ const ownSession = new Set();
272
+ const TRIAL_JARS = 500;
269
273
  let identities = null;
270
274
  let capture = null;
275
+ // The local proxy an app is seen through when the hook cannot load into it (lib/proxy.mjs).
276
+ let proxy = null;
277
+ // The port the run and the agent send to: the proxy's front when there is one, else the app's own.
278
+ const doorPort = () => proxy?.port ?? app?.port;
279
+ // The request the app answered in words while no model call came through the proxy, as "POST /chat".
280
+ let unseen = "";
281
+ // The runner's record of where to send: the front, with the app's own port beside it, and the
282
+ // endpoints that took a message and answered at once with no model call tied to it (lib/replay.mjs).
283
+ // The front listens on 127.0.0.1; the app on whichever loopback address it answered on.
284
+ const upAt = (port = app?.port) => {
285
+ const receipts = capture?.receipts() ?? [];
286
+ return { ...(proxy ? { port: proxy.port, host: "127.0.0.1", proxied: port, ...(unseen && !proxy.seen() ? { unseen } : {}) } : { port, host: appHost }), ...(receipts.length ? { receipts } : {}) };
287
+ };
271
288
  // The app's life, shared by the code that starts it, watches it and restarts it.
272
289
  let closing = false;
273
290
  let restarting = false;
@@ -275,7 +292,16 @@ let appGone = false;
275
292
  let lastSaid = "";
276
293
  let lastOutputAt = Date.now();
277
294
  let forgetTold = null;
278
- const mask = (text) => { let s = String(text ?? ""); for (const v of secrets) s = s.split(v).join("[masked]"); return s; };
295
+ // The run's requests your app has not answered yet: a slow health probe while any are open is our
296
+ // load on it, not a hang.
297
+ let answering = 0;
298
+ const held = makeHeld({ rows: () => capture?.usage()?.rows ?? [], say: (line) => say(line) });
299
+ // Closed while a store the app reaches is brought up and copied, until the app runs on the copy: the
300
+ // run's requests wait at it rather than write into the original.
301
+ let gate = null;
302
+ const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
303
+ const release = () => gate?.open();
304
+ const mask = (text) => maskWith(String(text ?? ""), secrets);
279
305
 
280
306
  // ---- the wire
281
307
  let box = "";
@@ -295,9 +321,30 @@ async function call(method, path, body, { raw = false, timeoutMs = 60_000, heade
295
321
 
296
322
  // ---- the box on this machine: your folder, read where it stands
297
323
  const work = join(tmpdir(), `cortad-${process.pid}`);
324
+ // Where the hook reads the hosts a trial's writes may reach (lib/trace.cjs, lib/pyhook), and the
325
+ // yes they come from.
326
+ const outboundFile = join(work, "outbound.json");
327
+ let consent = {};
328
+ // The person's yes to raising their app's own limits and opening its sign-in switches for this
329
+ // session, from the browser. Until it arrives the app runs with its guards as they are.
330
+ let limitsYes = false;
298
331
  mkdirSync(work, { recursive: true });
299
- const bootLog = join(work, "boot.log");
300
- writeFileSync(bootLog, "");
332
+ // What the app printed this session, beside the project's other records rather than in a temp folder
333
+ // the agent is never told of. Emptied once this process holds the project (holdProject), so a second
334
+ // command in the same folder never empties a live session's output. Masked once more at the end of
335
+ // the session, up to a size read whole, with the values learned while it ran.
336
+ const bootLog = join(homeOf(project), "app.log");
337
+ const MASKED_AT_END_MAX = 64 * 1024 * 1024;
338
+ // The same output in memory with when each piece came, so the lines printed during one request are
339
+ // its own (lib/proof.mjs). Masked before either keeps it, and never cut inside a value.
340
+ const printed = [];
341
+ const PRINTED = 2000;
342
+ const logTo = () => maskedLines(() => secrets, (text) => {
343
+ try { appendFileSync(bootLog, text, { mode: 0o600 }); } catch { /* still kept in memory */ }
344
+ printed.push({ at: Date.now(), text });
345
+ if (printed.length > PRINTED) printed.splice(0, printed.length - PRINTED);
346
+ });
347
+ const printedBetween = (from, to) => printed.filter((p) => p.at >= from && p.at <= to).map((p) => p.text).join("");
301
348
  // Files nobody may read through this program: keys, and git's own internals.
302
349
  const SECRET_PATH = /(?:^|\/)(?:\.git|\.ssh|\.gnupg|\.aws|\.npmrc|\.netrc|id_(?:rsa|ed25519|ecdsa)[^/]*|[^/]*\.(?:pem|key|p12|pfx|jks|keystore))(?:\/|$)/;
303
350
  // The engine's paths, as this machine has them. Its scratch files live in this program's own
@@ -306,7 +353,7 @@ const translate = (s) => String(s ?? "").split("/workspace/repo").join(root).spl
306
353
  const scratch = (p) => { const r = resolve(translate(p)); return r.startsWith("/tmp/") ? join(work, r.slice(5)) : r.startsWith(work + sep) ? r : null; };
307
354
  const readable = (p) => {
308
355
  const r = resolve(translate(p));
309
- if (r.startsWith(work + sep)) return r;
356
+ if (r === bootLog || r.startsWith(work + sep)) return r;
310
357
  let landed; try { landed = realpathSync(r); } catch { return null; }
311
358
  const home = realpathSync(root);
312
359
  if (!landed.startsWith(home + sep) || ENV_FILE.test(basename(landed)) || SECRET_PATH.test(landed)) return null;
@@ -341,20 +388,35 @@ async function verb(job) {
341
388
  }
342
389
  case "fetch": {
343
390
  // Only the app's own port: this machine's other services are not the world.
344
- const port = Number(b.port) || app?.port;
345
- if (!app || port !== app.port) return { error: `refused: port ${port} is not your app` };
391
+ const port = Number(b.port) || doorPort();
392
+ if (!app || (port !== app.port && port !== doorPort())) return { error: `refused: port ${port} is not your app` };
346
393
  const path = typeof b.path === "string" && b.path.startsWith("/") ? b.path : "/";
347
394
  const headers = {};
348
395
  for (const [k, v] of Object.entries(b.headers ?? {})) if (!/^(host|content-length|connection)$/i.test(k)) headers[k] = String(v);
396
+ // Held from here, before anything is awaited, so a cancel from the run never arrives first.
397
+ const turn = String(Object.entries(headers).find(([k]) => k.toLowerCase() === "x-cortad-turn")?.[1] ?? "");
398
+ // Every request of ours is tagged, a knock or a revisit as much as a trial, so the hook never
399
+ // takes one for the person's: it would become the request trials copy and the sign-in they carry.
400
+ if (!turn) headers["x-cortad-turn"] = `cortad-${String(job.id).replace(/[^\w.-]/g, "").slice(0, 64)}`;
401
+ const letGo = held.hold(job.id, turn || headers["x-cortad-turn"]);
402
+ // An endpoint your app answers later: it takes the message and its model answers afterwards,
403
+ // outside the request (a queue's worker). Its own reply only says it took the message.
404
+ const laterAt = Object.keys(headers).find((k) => k.toLowerCase() === LATER_HEADER);
405
+ const laterMs = laterAt === undefined ? NaN : Number(headers[laterAt]);
406
+ const later = laterAt !== undefined && (headers[laterAt] === "later" || laterMs > 0);
407
+ if (laterAt !== undefined) delete headers[laterAt];
408
+ if (later) capture?.forget(turn || headers["x-cortad-turn"]);
349
409
  // A request that speaks as one of your app's own callers carries the role, not the token:
350
410
  // the token was issued on this machine and is put in here, so it never travels.
351
411
  const marker = Object.keys(headers).find((k) => k.toLowerCase() === AS_HEADER);
412
+ let spokenAs = null;
352
413
  if (marker) {
353
414
  const role = headers[marker];
354
415
  delete headers[marker];
355
- if (role === CAPTURED) {
356
- // Speaking as the person who sent the message we watched: every header their own client sent.
357
- for (const [name, value] of Object.entries(capture?.headers() ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
416
+ if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
417
+ spokenAs = role;
418
+ // Speaking as one of the customers whose requests we watched: every header their own client sent.
419
+ for (const [name, value] of Object.entries(capture?.headers(role) ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
358
420
  } else {
359
421
  const held = await identities?.headerFor(role);
360
422
  if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
@@ -362,7 +424,9 @@ async function verb(job) {
362
424
  }
363
425
  const method = String(b.method ?? "GET").toUpperCase();
364
426
  const init = { method, headers, redirect: "manual" };
365
- if (b.body !== undefined && method !== "GET" && method !== "HEAD") init.body = typeof b.body === "string" ? b.body : JSON.stringify(b.body);
427
+ // Speaking as one of several customers, the ids in the body are that customer's own.
428
+ const body = spokenAs ? capture?.bodyAs(spokenAs, `${method} ${path.split("?")[0]}`, b.body) ?? b.body : b.body;
429
+ if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = typeof body === "string" ? body : JSON.stringify(body);
366
430
  // A dev server restarts when a file is saved, and during a run files are saved: by the agent
367
431
  // working its plan, and by you. For those seconds nothing is listening. A turn that meets a
368
432
  // closed door is held until your app answers again and sent then, once, instead of being
@@ -373,18 +437,47 @@ async function verb(job) {
373
437
  // method", which is what your chat looked like from the outside. So the chain is walked the
374
438
  // way a browser walks it, one GET with redirects followed, and the request is sent again with
375
439
  // the session your app just handed out. The cookie is kept here and never leaves this machine.
376
- const url = hostUrl(port, path);
377
- const yours = (to) => { try { const u = new URL(to, url); return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(u.hostname) && u.port === String(port) ? u.href : null; } catch { return null; } };
378
- let jar = "";
440
+ // Through the proxy's front when there is one, which is where the request is seen.
441
+ const url = proxy ? `http://127.0.0.1:${proxy.port}${path}` : hostUrl(port, path);
442
+ // An address the app wrote from its own port, as an app that keeps its address in its settings
443
+ // does, is followed through the front as well.
444
+ const yours = (to) => { try { const u = new URL(to, url); return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(u.hostname) && [new URL(url).port, String(app.port)].includes(u.port) ? new URL(u.pathname + u.search, url).href : null; } catch { return null; } };
445
+ // Each trial keeps the cookies your app set on its own earlier turns, so a conversation your app
446
+ // holds in a cookie carries from one turn to the next and never into another trial.
447
+ const trial = turn.replace(/:\d+$/, "");
448
+ let jar = trial ? trialJars.get(trial) ?? "" : "";
379
449
  // Held as long as the run waits for this reply: a course generator that takes ninety seconds
380
450
  // a reply is waited on for three of them. A run that says nothing gets the old 170 seconds.
381
451
  const holdMs = Math.min(Math.max(Number(b.waitMs) || 170_000, 1_000), 630_000);
452
+ const began = Date.now();
453
+ // The trial's own cookies take the place of the captured ones of the same name: a session your
454
+ // app keys its conversation on is this trial's, never the one every trial was handed at capture.
455
+ const cookieAt = Object.keys(headers).find((k) => k.toLowerCase() === "cookie");
456
+ const { [cookieAt ?? ""]: given = "", ...cookieless } = headers;
457
+ const cookie = () => [...new Map([...given.split(/;\s*/), ...jar.split(/;\s*/)].filter(Boolean).map((pair) => [pair.split("=")[0].trim(), pair])).values()].join("; ");
458
+ // A cookie your app hands any new visitor is that visitor's session: each trial asks for its own,
459
+ // so one trial's answer in progress never holds another's (AI Answers answers one at a time per
460
+ // visitor, and every trial carried the agent's one session). An endpoint that refuses a new
461
+ // visitor keeps the person's session for this trial and every later one.
462
+ const door = `${method} ${path.split("?")[0]}`;
463
+ const fresh = Boolean(trial && given && !trialJars.has(trial) && !ownSession.has(door));
464
+ const visitor = async () => {
465
+ const res = await fetch(url, { method: "GET", headers: cookieless, redirect: "manual", signal: AbortSignal.any([letGo, AbortSignal.timeout(15_000)]) }).catch(() => null);
466
+ await res?.arrayBuffer().catch(() => null);
467
+ return (res?.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).filter(Boolean).join("; ");
468
+ };
382
469
  const sent = (at, over = {}) => fetch(at, {
383
470
  ...init, ...over,
384
- headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
385
- signal: AbortSignal.timeout(holdMs),
471
+ headers: { ...(jar ? cookieless : headers), ...(over.headers ?? {}), ...(jar ? { cookie: cookie() } : {}) },
472
+ signal: AbortSignal.any([letGo, AbortSignal.timeout(holdMs)]),
386
473
  });
387
- const keep = (res) => { const set = res.headers.getSetCookie?.() ?? []; if (set.length) jar = [jar, ...set.map((c) => c.split(";")[0])].filter(Boolean).join("; "); };
474
+ const keep = (res) => {
475
+ const set = res.headers.getSetCookie?.() ?? [];
476
+ if (!set.length) return;
477
+ const pairs = new Map([...jar.split(/;\s*/), ...set.map((c) => c.split(";")[0])].filter(Boolean).map((pair) => [pair.split("=")[0].trim(), pair]));
478
+ jar = [...pairs.values()].join("; ");
479
+ if (trial) { trialJars.delete(trial); trialJars.set(trial, jar); if (trialJars.size > TRIAL_JARS) trialJars.delete(trialJars.keys().next().value); }
480
+ };
388
481
  const sentBack = (res) => (res.status >= 300 && res.status < 400 ? yours(res.headers.get("location") ?? "") : null);
389
482
  const warm = async () => {
390
483
  let at = url;
@@ -395,8 +488,17 @@ async function verb(job) {
395
488
  }
396
489
  };
397
490
  const ask = async () => {
491
+ if (fresh) { jar = await visitor(); if (jar) trialJars.set(trial, jar); }
398
492
  let res = await sent(url);
399
493
  keep(res);
494
+ if (fresh && jar && (res.status === 401 || res.status === 403)) {
495
+ ownSession.add(door);
496
+ jar = "";
497
+ trialJars.delete(trial);
498
+ await res.arrayBuffer().catch(() => null);
499
+ res = await sent(url);
500
+ keep(res);
501
+ }
400
502
  if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
401
503
  const buf = Buffer.from(await res.arrayBuffer());
402
504
  // An OpenAPI document can run to megabytes; the caller asks for it whole.
@@ -405,15 +507,31 @@ async function verb(job) {
405
507
  const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
406
508
  return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
407
509
  };
408
- try { return await ask(); }
510
+ const counted = async () => { await gate; letGo.throwIfAborted(); answering += 1; try { return await ask(); } finally { answering -= 1; } };
511
+ // The turn's answer on an endpoint your app answers later: the model's last words, once the hook
512
+ // has seen its calls go quiet, within the time the run waits for this reply. None came: the
513
+ // reply says the status of the model call that gave no words, or the seconds waited.
514
+ const answered = async (got) => {
515
+ if (!later || got.status < 200 || got.status >= 300) return got;
516
+ const until = began + Math.min(holdMs, laterMs > 0 ? laterMs : Infinity) - LATER_MARGIN_MS;
517
+ const said = capture ? await capture.answered(turn || headers["x-cortad-turn"], until, letGo) : null;
518
+ if (letGo.aborted) return { error: "cancelled by the run" };
519
+ if (!said?.reply.trim()) return { ...got, headers: { ...got.headers, [LATER_MISSED]: JSON.stringify(said ? { model: said.status } : { waited: Math.round((Date.now() - began) / 1000) }) } };
520
+ return { ...got, headers: { "content-type": "application/json" }, body: mask(JSON.stringify({ reply: said.reply })), truncated: false };
521
+ };
522
+ try { return await answered(await counted()); }
409
523
  catch (e) {
524
+ if (letGo.aborted) return { error: "cancelled by the run" };
410
525
  const code = String(e.cause?.code ?? e.code ?? "");
411
526
  if (!/ECONNREFUSED|ECONNRESET|EPIPE|UND_ERR_SOCKET/.test(code)) return { error: `nothing answered at port ${port}: ${code || e.message}` };
412
- for (let i = 0; i < 45 && !(await answers(port)); i++) await new Promise((r) => setTimeout(r, 1000));
413
- try { return { ...(await ask()), heldForRestart: true }; }
414
- catch (again) { return { error: `nothing answered at port ${port}: ${String(again.cause?.code ?? again.message)}` }; }
415
- }
527
+ // Held through a restart of ours however long it takes: a request it cut is sent again after it.
528
+ for (let i = 0; (i < 45 || restarting) && !letGo.aborted && !(await answers(app.port)); i++) await new Promise((r) => setTimeout(r, 1000));
529
+ try { return { ...(await answered(await counted())), heldForRestart: true }; }
530
+ catch (again) { return { error: letGo.aborted ? "cancelled by the run" : `nothing answered at port ${port}: ${String(again.cause?.code ?? again.message)}` }; }
531
+ } finally { held.done(job.id); }
416
532
  }
533
+ // The run gave up on a request it asked for: its wait ran out, or the run ended.
534
+ case "cancel": return { cancelled: held.letGo([String(b.id ?? "")]) };
417
535
  case "read": {
418
536
  const p = readable(b.path);
419
537
  if (!p) return { error: "bad path" };
@@ -430,7 +548,10 @@ async function verb(job) {
430
548
  if (b.append) appendFileSync(mine, bytes); else writeFileSync(mine, bytes);
431
549
  // The read's tools, kept for this project too: the next start hands them to the hook before
432
550
  // the app loads its code, which a Node app needs to have its tool functions wrapped.
433
- if (mine === join(work, "tools.json")) { try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(join(homeOf(project), "tools.json"), bytes, { mode: 0o600 }); } catch { /* kept for this session only */ } }
551
+ if (mine === join(work, "tools.json")) {
552
+ try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(join(homeOf(project), "tools.json"), bytes, { mode: 0o600 }); } catch { /* kept for this session only */ }
553
+ void recordTools();
554
+ }
434
555
  return { success: true, stderr: "" };
435
556
  }
436
557
  catch (e) { return { success: false, stderr: String(e.message) }; }
@@ -452,6 +573,18 @@ async function verb(job) {
452
573
  return { text, root, size: all.length, ...(jvm ? { files: files.filter((f) => /\.(?:java|kt|scala|groovy)$/.test(f)).slice(0, 20_000) } : {}) };
453
574
  } catch { return { text: "", root, size: 0 }; }
454
575
  }
576
+ // The person changed what they said yes to while this command runs.
577
+ case "consent": await allowOutbound(b); return { ok: true };
578
+ // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
579
+ // One marked news (a run ending) is also kept for the hook, so an agent that was not listening
580
+ // hears it on its next prompt or edit.
581
+ case "say": {
582
+ // Printed in a terminal and handed to a coding agent: no control characters, no direction overrides.
583
+ const line = String(b.line ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, " ").replace(/\s+/g, " ").trim().slice(0, 400);
584
+ if (line) say(line);
585
+ if (line && b.news) { try { addNews(project, line); } catch { /* said in this terminal only */ } }
586
+ return { ok: true };
587
+ }
455
588
  case "restart": return restartApp();
456
589
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
457
590
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
@@ -460,8 +593,11 @@ async function verb(job) {
460
593
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
461
594
  // What your app spent on its providers, from the hook inside it. An app this command did not
462
595
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
463
- // Masked like every other reply: a tool's answer can carry a value from their env files.
464
- case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
596
+ // Masked like every other reply: a tool's answer can carry a value from their env files. The
597
+ // calls of the `turn` asked about also say what their model was told.
598
+ // `toolsRecorded: false`: the app was started before the read's list of tools, so a tool that ran
599
+ // was not seen; a run says "not recorded", never that none ran.
600
+ case "usage": return capture ? { ...JSON.parse(mask(JSON.stringify(capture.usage(b.turn) ?? {}))), ...(launched && toolsKnown() ? { toolsRecorded: toolsRecorded() } : {}) } : {};
465
601
  // Every route your app holds, read by the hook off the app itself, masked like the meter.
466
602
  case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
467
603
  // A world is ended from this terminal, never from the cloud.
@@ -480,7 +616,9 @@ const hostUrl = (port, path = "/") => `http://${appHost === "::1" ? "[::1]" : ap
480
616
  const answers = async (port) => {
481
617
  for (const host of [appHost, appHost === "::1" ? "127.0.0.1" : "::1"]) {
482
618
  try {
483
- await fetch(`http://${host === "::1" ? "[::1]" : host}:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
619
+ const res = await fetch(`http://${host === "::1" ? "[::1]" : host}:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
620
+ const head = await res.text().then((t) => t.slice(0, 300), () => "");
621
+ if (notAnApp(head)) return false;
484
622
  appHost = host;
485
623
  return true;
486
624
  } catch { /* the other family next */ }
@@ -500,34 +638,18 @@ let appDir = root;
500
638
  // ---- a sign-in mounted in another workspace
501
639
  // One app served its AI from apps/api and mounted sign-in in apps/dashboard: a test account can
502
640
  // only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
503
- const PORT_IN_SCRIPT = /(?:^|\s)(?:PORT=|-p[ =]|--port[ =])(\d{2,5})\b/;
504
- // The port a framework serves on when nobody names one. sveltekit and astro before vite: both bring
505
- // vite with them and neither uses its port.
506
- const FRAMEWORK_PORT = [["next", 3000], ["nuxt", 3000], ["@remix-run/serve", 3000], ["@remix-run/dev", 3000], ["@sveltejs/kit", 5173], ["astro", 4321], ["vite", 5173]];
507
- // No scan of the usual ports: whatever answers on 3000 or 8080 on this machine is very often not
508
- // this workspace, and a test account made against somebody else's service is the worst kind of
509
- // wrong. Only the port this workspace itself names, and if nothing is there, it is started.
510
-
511
- // What that workspace serves on: its own script says so, or the framework it is written in does.
512
- function servicePort(cwd) {
513
- let pkg = null;
514
- try { pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); } catch { /* not a Node workspace */ }
515
- const script = ["dev", "develop", "start:dev", "serve", "start"].map((s) => pkg?.scripts?.[s]).find(Boolean) ?? "";
516
- const named = Number(PORT_IN_SCRIPT.exec(script)?.[1]);
517
- if (named) return named;
518
- const deps = Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies });
519
- const framework = FRAMEWORK_PORT.find(([dep]) => deps.includes(dep))?.[1];
520
- if (framework) return framework;
521
- if (existsSync(join(cwd, "manage.py"))) return 8000;
522
- if (existsSync(join(cwd, "Gemfile"))) return 3000;
523
- const py = ["requirements.txt", "pyproject.toml"].map((f) => { try { return readFileSync(join(cwd, f), "utf8"); } catch { return ""; } }).join("\n");
524
- return /\bdjango\b/i.test(py) ? 8000 : /\bflask\b/i.test(py) ? 5000 : null;
525
- }
526
-
527
641
  const toldService = new Set();
528
- const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`your sign-in lives in ${dir}, so I signed in there`); } return port; };
529
- // Everything else this command started, stopped with it.
642
+ const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`signed in a test account through ${dir}`); } return port; };
643
+ // Everything else this command started, stopped with it: each started the way the app is, on the
644
+ // same copies of its data.
530
645
  const sidecars = [];
646
+ function sidecar(cmd, cwd) {
647
+ const kid = spawnTied(cmd, { cwd, env: { ...process.env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}), ...keeping.envNow() } });
648
+ sidecars.push(kid);
649
+ kid.stdout.on("data", logTo());
650
+ kid.stderr.on("data", logTo());
651
+ return kid;
652
+ }
531
653
  // The port that workspace's sign-in answers on. One it is already serving on comes first: starting
532
654
  // a second copy of a dashboard that is already up costs a minute and takes its port.
533
655
  async function serviceUp(group) {
@@ -535,15 +657,7 @@ async function serviceUp(group) {
535
657
  if (named && named !== app?.port && (await answers(named))) return signedInAt(group.dir, named);
536
658
  // Its own port taken by the app leaves nothing to wait on: whatever answers there is the app.
537
659
  if (!named || named === app?.port || !group.plan?.cmd) return null;
538
- const kid = spawn("/bin/sh", ["-c", group.plan.cmd], {
539
- cwd: group.plan.cwd,
540
- env: { ...process.env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) },
541
- stdio: ["ignore", "pipe", "pipe"], detached: true,
542
- });
543
- sidecars.push(kid);
544
- const keep = (d) => appendFileSync(bootLog, d.toString());
545
- kid.stdout.on("data", keep);
546
- kid.stderr.on("data", keep);
660
+ sidecar(group.plan.cmd, group.plan.cwd);
547
661
  return (await waitForPort(named, 90_000)) ? signedInAt(group.dir, named) : null;
548
662
  }
549
663
 
@@ -564,6 +678,10 @@ async function startApp() {
564
678
  const took = await takeOver(wanted);
565
679
  if (took) return took;
566
680
  say("your app was already running, so its request limits stay as they are; if it answers 429, stop it and run this without --port and they are raised for the session");
681
+ if (proxyAsked) {
682
+ await useProxy();
683
+ say(`to have its model calls seen, start your app with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
684
+ }
567
685
  return { port: wanted, cmd: null };
568
686
  }
569
687
  const plan = startPlan({ root, typed: flag("--start"), onPath });
@@ -581,36 +699,49 @@ async function startApp() {
581
699
  pinned = pinnedNode();
582
700
  if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
583
701
  const sourceFiles = files.map((f) => join(root, f));
584
- const lifted = { ...liftedLimits(envFiles, sourceFiles), ...openSwitches(envFiles, sourceFiles) };
585
- const raised = Object.keys(liftedLimits(envFiles, sourceFiles));
586
- const opened = Object.keys(openSwitches(envFiles, sourceFiles));
702
+ const lifted = sessionLifts(limitsYes, envFiles, sourceFiles);
703
+ const raised = Object.keys(lifted).filter((n) => /^\d+$/.test(lifted[n]));
704
+ const opened = Object.keys(lifted).filter((n) => !raised.includes(n));
587
705
  if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
588
706
  // Said out loud, because it changes who their app lets in for as long as this command runs.
589
- if (opened.length) say(`your app's own sign-in switch, for this session only: ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
707
+ if (opened.length) say(`for this session your app runs with ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
590
708
  launched = { cmd, lifted };
709
+ if (proxyAsked || !hookable(appDir)) await useProxy();
591
710
  step(`starting your app: ${cmd}`);
592
711
  const up = await launch(180_000);
593
712
  if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
594
- // Already running: a second start dies on the port the first one holds. The one that is running
595
- // is the app, so it is used as it stands rather than treated as a failure.
596
- // "Another next dev server is already running" is the same fact in a framework's own words: their
597
- // app is up, started from the terminal they were already working in, and the port it holds is not
598
- // always the one we asked for. Their running app is the app.
599
- if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)|another .{0,20}(?:dev )?server is already running|already running (?:on|at) (?:http|port)/i.test(up.tail)) {
600
- const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
601
- for (const port of new Set(ports)) {
602
- if (await answers(port)) {
603
- launched = null; child = null; data = null;
604
- const took = await takeOver(port);
605
- if (took) return took;
606
- say(`your app is already running on port ${port}, so that one is used; its request limits stay as they are`);
607
- return { port, cmd: null };
608
- }
713
+ // Already running: a second start dies on the port the first one holds. Held by a process running
714
+ // in this repository, started from the terminal the person was already working in, it is the app
715
+ // and is used as it stands. "Another next dev server is already running" is the same fact in a
716
+ // framework's own words, and the port it holds is not always the one asked for. A holder running
717
+ // from any other folder is another program: it is named, never tested as this app.
718
+ const ports = up.held ? [] : up.exited !== null && ALREADY.test(up.tail) ? [...new Set([...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1])))] : [];
719
+ const holders = up.held ? [up.held] : (await Promise.all(ports.map(holderOf))).filter(Boolean);
720
+ for (const held of holders) {
721
+ if (!ownFolder(held.cwd)) return { port: null, said: up.tail, why: whyNot({ ...up, held }) };
722
+ if (await answers(held.port)) {
723
+ launched = null; child = null;
724
+ await keeping.forget();
725
+ const took = await takeOver(held.port);
726
+ if (took) return took;
727
+ say(`your app is already running on port ${held.port}, so that one is used; its request limits stay as they are`);
728
+ return { port: held.port, cmd: null };
609
729
  }
610
730
  }
611
731
  if (up.tail) console.error(up.tail);
732
+ return { port: null, said: up.tail, why: whyNot(up) };
733
+ }
734
+ // A start that failed because another process holds the port, in the words a framework uses.
735
+ const ALREADY = /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)|another .{0,20}(?:dev )?server is already running|already running (?:on|at) (?:http|port)/i;
736
+ const rootReal = realpathSync(root);
737
+ const ownFolder = (dir) => { try { const at = realpathSync(dir); return at === rootReal || at.startsWith(rootReal + sep); } catch { return false; } };
738
+ // Why a start did not come up, in a sentence that stands without the terminal: the runner's state
739
+ // carries it to the verbs, and the screen gets the app's own words beside it.
740
+ function whyNot(up) {
741
+ const h = up.held;
742
+ if (h) return `port ${h.port} is in use by another program (pid ${h.pid}${h.command ? `, ${h.command}` : ""}), so your app cannot listen there${up.fixed ? "; your app sets that port in its own code, so it cannot be moved" : ""}. Stop that program, then save a file here: your app is started again by itself.`;
612
743
  const wrongNode = pinned.major && !pinned.bin ? ` This project pins Node ${pinned.major} and this shell runs Node ${shellNode}: switch to ${pinned.major}.` : "";
613
- return { port: null, said: up.tail, why: `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}; what it said is above.${wrongNode}` };
744
+ return `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}.${wrongNode} Fix it and save a file here: your app is started again by itself.`;
614
745
  }
615
746
 
616
747
  // An app the person started keeps the limits it started with, and a run needs more than a person
@@ -629,39 +760,20 @@ async function takeOver(port) {
629
760
  if (answer && !/^y(?:es)?$/i.test(answer)) return null;
630
761
  const top = await supervisorOf(pid);
631
762
  step(`stopping your app (pid ${top}) to start it with higher limits`);
632
- await stopApp(top);
763
+ await stopTree(top);
633
764
  for (let i = 0; i < 50 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
634
765
  if (await answers(port)) { say("your app did not stop, so it is used as it is"); return null; }
635
766
  appDir = plan.cwd ?? root;
636
767
  pinned = pinnedNode();
637
768
  if (plan.within) say(`your app is in ${plan.within}, started there with: ${plan.cmd}`);
638
769
  say(`higher request limits for this session: ${names.join(", ")}`);
639
- launched = { cmd: plan.cmd, lifted };
770
+ launched = { cmd: plan.cmd, lifted, byTerminal: true };
640
771
  step(`starting your app: ${plan.cmd}`);
641
772
  const up = await launch(180_000);
642
773
  if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
643
774
  if (up.tail) console.error(up.tail);
644
775
  return { port: null, said: up.tail, why: "your app did not come back after the restart." };
645
776
  }
646
- // The process listening on a port.
647
- async function listenerOn(port) {
648
- try { return Number((await exec("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])).stdout.trim().split("\n")[0]) || null; } catch { return null; }
649
- }
650
- // The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
651
- // while the parent is a runner, never into the person's own shell or terminal.
652
- const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
653
- async function chainOf(pid) {
654
- const rows = (await exec("ps", ["-axo", "pid=,ppid=,etime=,args="])).stdout.trim().split("\n").map((l) => l.trim());
655
- const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), etime: m[3], args: m[4] }] : [0, null]; }));
656
- const chain = table.get(pid) ? [table.get(pid)] : [];
657
- for (let i = 0; i < 8 && chain.length; i++) {
658
- const parent = table.get(chain.at(-1).ppid);
659
- if (!parent || !RUNNER.test(parent.args)) break;
660
- chain.push(parent);
661
- }
662
- return chain;
663
- }
664
- const supervisorOf = async (pid) => (await chainOf(pid)).at(-1)?.pid ?? pid;
665
777
 
666
778
  // What the running app was started from, noted in app.json for the verbs, which run in another
667
779
  // process (lib/fresh.mjs): when it started, whether this command started it, whether it reloads on
@@ -697,11 +809,17 @@ let pinned = { major: null, bin: null };
697
809
 
698
810
  // Their app could not start because its dependencies are not on this machine. That is an install
699
811
  // nobody ran, not a broken app: it is installed once, with the manager the project locked, and the
700
- // app is started again. Every way the app is started comes through here, so it is fixed in one place.
812
+ // app is started again. So is a store or service it reaches that nothing runs yet (backUp). Every
813
+ // way the app is started comes through here, so it is fixed in one place.
701
814
  async function launch(waitMs) {
702
- const up = await start(waitMs);
815
+ let up = await start(waitMs);
816
+ if (up.port && !proxy && !(await hookedAt(up.port))) up = await startProxied(up, waitMs);
703
817
  // Only an app that stopped: one still running has not failed to start, whatever it printed.
704
818
  if (up.port || up.exited === null) return up;
819
+ if (await keeping.backUp(app?.port).finally(release)) {
820
+ up = await start(waitMs);
821
+ if (up.port || up.exited === null) return up;
822
+ }
705
823
  const ok = await installOnce(up.tail);
706
824
  // An install that failed is the reason their app cannot start, and it goes where the app's own
707
825
  // output goes: the terminal, and the screen that is waiting for the app.
@@ -710,6 +828,32 @@ async function launch(waitMs) {
710
828
  const back = await start(waitMs);
711
829
  return back.port ? back : { ...back, tail: said(back.tail) };
712
830
  }
831
+ // Whether the hook loaded into the process answering on `port`. A machine that cannot name that
832
+ // process leaves the app as it is.
833
+ const hookedAt = async (port) => { const pid = await listenerOn(port); return !pid || capture.hookedAt(port, pid); };
834
+ // The proxy, opened once: the app's model calls are seen there from the next start on.
835
+ async function useProxy() {
836
+ if (proxy) return;
837
+ const noted = () => { if (became?.state === "up") become("up", { ...became.fields, ...upAt(became.fields.proxied) }); };
838
+ // A first model call seen through it is said to the run, for an app this command did not start.
839
+ proxy = await openProxy({
840
+ file: capture.file, rulesFile: capture.rulesFile, target: () => ({ host: appHost, port: app?.port }),
841
+ onSeen: () => { forgetTold?.(); noted(); },
842
+ onUnseen: (door) => { unseen = door; noted(); say(unseenText(door)); },
843
+ });
844
+ say("your app's model calls are seen through a local proxy on this machine, so the line of code that made each call is not known. During a run, its calls to other services go out as they would for a real customer: a conversation that books, charges or emails does it for real");
845
+ }
846
+ // An app run in a container: the proxy's settings never reach it, and 127.0.0.1 inside it is not this machine.
847
+ const IN_CONTAINER = /^\s*(?:sudo\s+)?(?:docker|podman)(?:-compose)?\b/;
848
+ // The app answered, and the hook is not in it: it is started again with its model settings on the
849
+ // proxy. One in a container would come back the same, so it is left up, seen at the front alone.
850
+ async function startProxied(up, waitMs) {
851
+ await useProxy();
852
+ if (IN_CONTAINER.test(launched?.cmd ?? "")) return up;
853
+ await stopTree(child.pid);
854
+ for (let i = 0; i < 75 && (await listenerOn(up.port)); i++) await new Promise((r) => setTimeout(r, 200));
855
+ return start(waitMs);
856
+ }
713
857
  let installed = false;
714
858
  let installSaid = "";
715
859
  async function installOnce(said) {
@@ -728,9 +872,9 @@ async function installOnce(said) {
728
872
  const done = await new Promise((r) => {
729
873
  const child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, CI: "1" }, stdio: ["ignore", "pipe", "pipe"] });
730
874
  let tail = "";
731
- const keep = (d) => { tail = (tail + d.toString()).slice(-4000); appendFileSync(bootLog, d.toString()); if (verbose) process.stdout.write(d.toString()); };
732
- child.stdout.on("data", keep);
733
- child.stderr.on("data", keep);
875
+ const keep = (log) => (d) => { tail = (tail + d.toString()).slice(-4000); log(d); if (verbose) process.stdout.write(d.toString()); };
876
+ child.stdout.on("data", keep(logTo()));
877
+ child.stderr.on("data", keep(logTo()));
734
878
  const timer = setTimeout(() => child.kill("SIGKILL"), 15 * 60_000);
735
879
  child.on("close", (code) => { clearTimeout(timer); r({ ok: code === 0, tail }); });
736
880
  child.on("error", (e) => { clearTimeout(timer); r({ ok: false, tail: String(e.message) }); });
@@ -753,11 +897,11 @@ async function freePortAbove(port) {
753
897
  for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
754
898
  return null;
755
899
  }
756
- // The port a bind failure names, in the app's own words: uvicorn's "('127.0.0.1', 8105): address
757
- // already in use", Node's "EADDRINUSE: address already in use :::3000".
758
- const portInError = (said) => Number(/(\d{4,5})\)?:?\s*address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)\D{0,40}?(\d{4,5})\b/i.exec(said)?.[1] ?? 0);
900
+ const PORT_FLAG = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/;
901
+ // The port a start names: its command's flag, or PORT as this command sets it or an env file does.
902
+ const namedPort = (cmd, lifted) => Number(PORT_FLAG.exec(cmd)?.[2] ?? lifted.PORT ?? envExports(envFiles).PORT ?? 0) || 0;
759
903
  async function freed(cmd, lifted) {
760
- const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
904
+ const m = PORT_FLAG.exec(cmd);
761
905
  // The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
762
906
  // on that port was reported as "your app stopped" instead of moved past.
763
907
  const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(envExports(envFiles).PORT) || 0;
@@ -769,42 +913,51 @@ async function freed(cmd, lifted) {
769
913
  return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
770
914
  }
771
915
 
772
- // Your app's database, read once per session: a copy made before the first start, and every
773
- // restart after it runs against the same copy, so a crash mid-run does not lose what the run made.
774
- // Told in the terminal here and to the run with the app (announce), so status and the report say it.
775
- let data = null;
776
- function dataOnce(started) {
777
- if (data) return data;
778
- const values = { ...envExports(envFiles), ...process.env };
779
- const from = {};
780
- for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
781
- let text = "";
782
- try { text = readFileSync(file, "utf8"); } catch { continue; }
783
- for (const line of text.split("\n")) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); if (m && !(m[1] in process.env)) from[m[1]] = file; }
784
- }
785
- data = keepData({ values, from, appDir, root, work, sources: files.map((f) => join(root, f)), started });
786
- for (const line of data.said) say(line.charAt(0).toLowerCase() + line.slice(1));
787
- return data;
788
- }
916
+ // Your app's data, read once per session: copies made before the first start, and every restart
917
+ // after it runs against the same copies, so a crash mid-run does not lose what the run made. What
918
+ // was copied, started, or left where trials write into it is told in the terminal here and to the
919
+ // run with the app (announce), so status says it beside Run and the report keeps it.
920
+ const keeping = makeKeeping({
921
+ root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold,
922
+ launch: (plan) => sidecar(plan.cmd, plan.cwd),
923
+ connections: () => capture?.connections() ?? [],
924
+ settings: () => {
925
+ const from = {};
926
+ for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
927
+ let text = "";
928
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
929
+ for (const line of text.split("\n")) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); if (m && !(m[1] in process.env)) from[m[1]] = file; }
930
+ }
931
+ return { values: { ...envExports(envFiles), ...process.env }, from, appDir, sources: files.map((f) => join(root, f)) };
932
+ },
933
+ });
789
934
 
790
- async function start(waitMs) {
935
+ async function start(waitMs, tries = 3) {
791
936
  const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
792
937
  // The record of what the app writes opens with the app: the first run starts itself on the
793
938
  // server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
794
939
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
940
+ toolsAtStart = toolsNow();
795
941
  // Taken before the app reads a file: a save after this moment is a change it has not loaded.
796
942
  const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
797
- note.files = sourceOf(root, files);
943
+ // The settings files count as what it loaded: an env file is rarely tracked, and an agent's edit to
944
+ // one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
945
+ note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
946
+ storesAt ??= storesNamed();
798
947
  loaded = note;
799
- child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...process.env, ...lifted, ...dataOnce(true).env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
948
+ const copies = await keeping.env(true);
949
+ await allowOutbound();
950
+ const base = { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
951
+ child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(base) : capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) } });
800
952
  const mine = child;
953
+ become("starting", { app: mine.pid ?? null });
801
954
  let seen = "";
802
- const onData = (d) => {
803
- const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s);
955
+ const onData = (log) => (d) => {
956
+ const s = d.toString(); log(d); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s);
804
957
  if (!note.reloads && RELOADER.test(s)) { note.reloads = true; if (note.answered && loaded === note) noteApp(); }
805
958
  };
806
- mine.stdout.on("data", onData);
807
- mine.stderr.on("data", onData);
959
+ mine.stdout.on("data", onData(logTo()));
960
+ mine.stderr.on("data", onData(logTo()));
808
961
  let exited = null;
809
962
  appGone = false;
810
963
  mine.on("exit", (code) => { exited = code ?? 1; if (child === mine) appGone = true; });
@@ -817,21 +970,24 @@ async function start(waitMs) {
817
970
  if (exited !== null && !taken) return { port: null, exited, tail: tail() };
818
971
  // nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
819
972
  // the wait ran its whole three minutes on one app with the reason sitting in the output.
820
- // Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
821
- // that holds the port is theirs and already running, which startApp turns into attaching to it.
822
- if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
823
- await stopApp(mine.pid);
824
- // The port this start named was taken between the check and the bind: the next free one, at
825
- // most three times, before the failure is theirs to read.
826
- const named = /(?:--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd)?.[1] ?? lifted.PORT ?? envExports(envFiles).PORT ?? (portInError(seen) || undefined);
827
- if (/EADDRINUSE|address already in use/i.test(seen) && named && lost.size < 3) { lost.add(Number(named)); return start(waitMs); }
828
- return { port: null, exited: 1, tail: tail() };
973
+ // Also: the port is taken, under a watcher that does not exit when its app cannot listen. The
974
+ // holder is named, and startApp attaches to it only when it runs from this repository.
975
+ if (taken || /app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running/i.test(seen)) {
976
+ await stopTree(mine.pid);
977
+ if (!taken) return { port: null, exited: 1, tail: tail() };
978
+ // Taken between the check and the bind: a port this start names moves to the next free one, a
979
+ // bounded number of times. A port the app sets in its own code does not follow it, and starting
980
+ // the app again only meets the same holder, so the holder is named instead.
981
+ const named = namedPort(cmd, lifted);
982
+ const port = portInError(seen) || named;
983
+ if (named && port === named && tries > 1) { lost.add(named); return start(waitMs, tries - 1); }
984
+ return { port: null, exited: 1, tail: tail(), held: port ? await holderOf(port) : null, fixed: Boolean(port) && port !== named };
829
985
  }
830
986
  // The port is what the app's own process group listens on. Never a guess: a developer's
831
987
  // machine has other things on 3000 and 8080, and one of them answered for the app once.
832
988
  const ports = await listening(mine.pid);
833
989
  // The socket can open before the server answers; the log line is the tiebreak among several.
834
- const plain = seen.replace(/\x1b\[[0-9;]*m/g, "");
990
+ const plain = withoutStores(seen.replace(/\x1b\[[0-9;]*m/g, ""));
835
991
  const said = [...plain.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b|\bport\s*[:=]?\s*(\d{4,5})\b/gi)].map((m) => Number(m[1] || m[2]));
836
992
  for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
837
993
  if (await answers(port)) {
@@ -842,25 +998,94 @@ async function start(waitMs) {
842
998
  }
843
999
  await new Promise((r) => setTimeout(r, 1000));
844
1000
  }
1001
+ // A start given up on is stopped: the next one never runs beside it.
1002
+ await stopTree(mine.pid);
845
1003
  return { port: null, exited: null, tail: seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-12).join("\n") };
846
1004
  }
847
1005
 
1006
+ const COPIED = "starting your app again so it runs against the copies made for this session";
1007
+ // A restart waits this long for the run's requests already at your app, then goes ahead: one it
1008
+ // cuts is sent again once your app answers.
1009
+ const DRAIN_MS = 20_000;
1010
+ // One start of your app at a time, whoever asks for it: a save, a verb, the copies made for this
1011
+ // session, or the health loop bringing back an app that stopped. One asked for while another is under
1012
+ // way is that one, so a second file saved while a crashed app comes back never kills its start.
1013
+ let restartDone = null;
1014
+ // The read's list of tools as the app was started with it: the hook wraps a tool's function only when
1015
+ // the list is there before the file holding it loads (lib/trace.cjs).
1016
+ const toolsNow = () => { try { return readFileSync(join(work, "tools.json"), "utf8"); } catch { return ""; } };
1017
+ let toolsAtStart = "";
1018
+ const toolsKnown = () => toolsNow() !== "" && toolsNow() !== "[]";
1019
+ const toolsRecorded = () => !toolsKnown() || toolsNow() === toolsAtStart;
1020
+ // A list that arrives after the app started: the app is started again at once, behind the gate. The
1021
+ // server writes it as a run starts, before its first conversation, and before Cortad's first test
1022
+ // requests; any request already at the app is waited for, and one cut is sent again.
1023
+ async function recordTools() {
1024
+ if (restartDone) await restartDone;
1025
+ if (!launched || !child || closing || toolsRecorded()) return;
1026
+ await restartApp("starting your app again so the tools your code gives its model are recorded");
1027
+ }
1028
+ function restartOnce(work) {
1029
+ restartDone ??= (async () => {
1030
+ restarting = true;
1031
+ try { return await work(); } finally { restarting = false; restartDone = null; }
1032
+ })();
1033
+ return restartDone;
1034
+ }
848
1035
  // The same command again, for an app that does not reload on save. Only an app this program
849
- // started: one you started yourself is yours to restart.
850
- async function restartApp() {
851
- restarting = true;
852
- try { return await restartNow(); } finally { restarting = false; }
1036
+ // started: one you started yourself is yours to restart. New requests wait at the gate meanwhile.
1037
+ function restartApp(why = "starting your app again so it runs the code you saved, since it does not reload by itself") {
1038
+ if (!launched || !child) return Promise.resolve({ error: "You started this app yourself, so restart it in your own terminal. Most dev servers reload on save." });
1039
+ return restartOnce(async () => {
1040
+ hold();
1041
+ say(why);
1042
+ try {
1043
+ for (const end = Date.now() + DRAIN_MS; answering > 0 && Date.now() < end;) await new Promise((r) => setTimeout(r, 250));
1044
+ return await restartNow();
1045
+ } finally { release(); }
1046
+ });
1047
+ }
1048
+ // Saved since your app started, in a file it runs: bytes written back as they were are not a change,
1049
+ // and neither is a file the app wrote itself.
1050
+ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
1051
+ root, app: { startedAt: new Date(loaded.at).toISOString(), files: loaded.files }, skip: writtenPaths(writesDirOf(homeOf(project))),
1052
+ }).changed.length > 0;
1053
+ // Every save that changes what your app runs starts it again, when it does not reload by itself: an
1054
+ // agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
1055
+ // at again after each restart, for a save made while it was under way.
1056
+ // The stores the settings name, as copies are made of them: a change here is a change of what the app
1057
+ // writes into, so its copies and holds are made again for the new settings, and said again.
1058
+ const storesNamed = () => { try { return JSON.stringify(storesOf({ ...envExports(envFiles), ...process.env }).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
1059
+ let storesAt = null;
1060
+ // Looked at before the first wait too: the app now starts while the code is uploaded, and a save made
1061
+ // before this watch began was otherwise never seen.
1062
+ async function restartOnSave() {
1063
+ while (!closing) {
1064
+ while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) {
1065
+ const named = storesNamed();
1066
+ const moved = storesAt !== null && named !== storesAt;
1067
+ storesAt = named;
1068
+ if (moved) await keeping.forget().catch(() => {});
1069
+ await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
1070
+ if (moved) await announce().catch(() => null);
1071
+ }
1072
+ await sourceChanged();
1073
+ }
853
1074
  }
854
1075
  async function restartNow() {
855
- if (!launched || !child) return { error: "You started this app yourself, so restart it in your own terminal. Most dev servers reload on save." };
856
1076
  const port = app.port;
857
- await stopApp(child.pid);
1077
+ await stopTree(child.pid);
858
1078
  // Until the port is free, not merely silent: a listener still closing does not answer and still
859
1079
  // holds the bind, and the restarted app was moved to the next port while the run kept knocking on
860
1080
  // this one (resumeforge's verify played seven trials against nothing).
861
1081
  for (let i = 0; i < 75 && (await listenerOn(port)); i++) await new Promise((r) => setTimeout(r, 200));
862
1082
  const up = await launch(90_000);
863
- if (!up.port) return { error: up.exited !== null ? `Your app exited ${up.exited} on restart.\n${up.tail}` : "Your app was restarted but did not answer within 90 seconds." };
1083
+ if (!up.port) {
1084
+ const error = up.held ? sentence(whyNot(up)) : up.exited !== null ? `Your app exited ${up.exited} on restart.\n${up.tail}` : "Your app was restarted but did not answer within 90 seconds.";
1085
+ become("failed", { error: mask(error).slice(-600) });
1086
+ return { error };
1087
+ }
1088
+ become("up", { ...upAt(up.port), app: child?.pid ?? null });
864
1089
  if (up.port !== port) {
865
1090
  // It moved anyway: the world follows the app, never the other way round.
866
1091
  app = up;
@@ -870,32 +1095,35 @@ async function restartNow() {
870
1095
  }
871
1096
  return { restarted: true, port: up.port };
872
1097
  }
873
- // TCP ports your app is listening on: every process descended from the one this program started.
874
- // By descent, not by process group: nodemon, pm2 and concurrently put the real server in a group of
875
- // its own, and an app started through one of them was never seen to open its port.
876
- async function familyOf(pid) {
877
- const table = (await exec("ps", ["-axo", "pid=,ppid="])).stdout.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
878
- const family = new Set([pid]);
879
- for (let grew = true; grew;) { grew = false; for (const [p, parent] of table) if (family.has(parent) && !family.has(p)) { family.add(p); grew = true; } }
880
- return [...family];
881
- }
882
- async function listening(pid) {
1098
+ const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
1099
+ // The last lines the app printed, masked: what the runner's state carries of its own words.
1100
+ const lastLines = (said) => mask(String(said ?? "").split("\n").filter(Boolean).slice(-12).join("\n")).slice(-1500);
1101
+ const failedWith = (why, said) => become("failed", { error: mask(why), ...(said ? { said: lastLines(said) } : {}) });
1102
+ // ---- leaving: every way out stops the app this command started. This orderly path stops it and
1103
+ // tells the server; a death no handler sees (SIGKILL) is covered by the watchdog lib/proc.mjs ties
1104
+ // to the app, and the exit hook above releases the project either way.
1105
+ async function close(code = 0) {
1106
+ if (closing) return;
1107
+ closing = true;
883
1108
  try {
884
- const { stdout } = await exec("lsof", ["-nP", "-a", "-p", (await familyOf(pid)).join(","), "-iTCP", "-sTCP:LISTEN", "-Fn"]);
885
- return [...new Set([...stdout.matchAll(/^n.*:(\d+)$/gm)].map((m) => Number(m[1])).filter((p) => p > 0))];
886
- } catch { return []; }
887
- }
888
- // Your app and everything it started, stopped for certain. A process group is not enough: nodemon
889
- // gives the real server a group of its own, and one app's server outlived this command as an
890
- // orphan still holding its port and its production connections. Asked first, then made to.
891
- async function stopApp(pid) {
892
- const family = await familyOf(pid).catch(() => [pid]);
893
- const signal = (sig) => { for (const p of family) { try { process.kill(p, sig); } catch { /* already gone */ } } try { process.kill(-pid, sig); } catch { /* no group left */ } };
894
- const alive = () => family.some((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
895
- signal("SIGTERM");
896
- for (let i = 0; i < 15 && alive(); i++) await new Promise((r) => setTimeout(r, 200));
897
- if (alive()) signal("SIGKILL");
1109
+ if (child?.pid) await stopTree(child.pid);
1110
+ proxy?.close();
1111
+ // A copy lives on its store, and a store can be a service of the repository started beside the
1112
+ // app: the copies go first, while it still answers.
1113
+ await keeping.close();
1114
+ for (const kid of sidecars) if (kid.pid) await stopTree(kid.pid);
1115
+ // A sign-in the app printed before its request reached this command is masked now. Only by the
1116
+ // process that writes the file: another may still be appending to it.
1117
+ if (readRunner(project)?.pid === process.pid) {
1118
+ try { if (statSync(bootLog).size <= MASKED_AT_END_MAX) writeFileSync(bootLog, mask(readFileSync(bootLog, "utf8"))); } catch { /* left as it was written */ }
1119
+ }
1120
+ if (box) await call("DELETE", `/local/${box}`, undefined, { timeoutMs: 5000 }).catch(() => {});
1121
+ await exec("rm", ["-rf", work]).catch(() => {});
1122
+ } finally { process.exit(code); }
898
1123
  }
1124
+ const quit = (line) => { try { console.error(`cortad ${line}`); } catch { /* the terminal is gone */ } return close(1); };
1125
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => close(0));
1126
+ process.on("uncaughtException", (e) => { void quit(e?.stack ?? String(e)); });
899
1127
 
900
1128
  // ---- go
901
1129
  say("getting ready");
@@ -926,10 +1154,12 @@ if (explain) {
926
1154
  `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, data files, node_modules, .git`,
927
1155
  `env files values read here only: to hide them in replies, to sign in a test account, and to ask your providers what your keys reach. Variable names and whether a switch is on or off go up; no value does`,
928
1156
  `would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan?.cmd ? `${plan.cmd} (in ${rel(plan.cwd)})` : plan?.noServer ? "nothing: this repository has no server to run" : "asks you how your app starts"}`,
929
- `would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (for this session only)`}`,
930
- `loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
1157
+ `would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (only after your yes, for this session only)`}`,
1158
+ `loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records each request during which your app calls a model, what it answered and the model calls on the way; header values stay here`,
1159
+ `other apps a local proxy on 127.0.0.1 in front of your app's port and where its model settings point (OPENAI_BASE_URL and the others), recording the same; it passes each call on to the provider your settings name`,
931
1160
  `your files never written by this program; your own coding agent edits them`,
932
- `your database a database file your env or code names is copied to this program's temp folder and your app is started on the copy; a database server is named, not copied`,
1161
+ `your database a database file your env or code names is copied to this program's temp folder; a Postgres, Redis or MongoDB database or a Qdrant collection on this machine is copied on its own server under a name of ours and deleted at the end; your app is started on the copies. Any other store is named, not copied`,
1162
+ `your services a store your compose file runs, or a second service of this repository, is started when your app reaches for it and nothing answers there, and stopped at the end`,
933
1163
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
934
1164
  `for your agent an MCP entry and a skill in each coding agent's own home folder (Claude Code, Codex, Cursor), and a key in ~/.cortad for later runs`,
935
1165
  ``,
@@ -937,21 +1167,19 @@ if (explain) {
937
1167
  ].join("\n"));
938
1168
  process.exit(0);
939
1169
  }
940
- if (kept.length) say(`kept on this machine, ${kept.length === 1 ? "it holds" : "they hold"} keys: ${kept.join(", ")}`);
1170
+ if (kept.length) say(`not uploaded, left on this machine: ${kept.join(", ")}`);
941
1171
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
942
1172
  identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
943
- // One message sent in their own app tells us the door for certain. The route and the body go up,
944
- // masked like everything else; the sign-in that message carried stays here.
945
- capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, onDoor: (door) => {
946
- let body = door.body;
947
- try { body = JSON.parse(mask(JSON.stringify(door.body))); } catch { /* sent as it is */ }
948
- call("POST", `/local/${box}/captured`, { ...door, body }).catch(() => {});
1173
+ // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
1174
+ // masked like everything else, and nothing goes up that the mask could not read; the sign-in each
1175
+ // request carried stays here.
1176
+ capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
1177
+ let masked;
1178
+ try { masked = JSON.parse(mask(JSON.stringify(proof))); } catch { return; }
1179
+ call("POST", `/local/${box}/proof`, masked).catch(() => {});
949
1180
  } });
950
1181
  if (!files.length) fail("no source files here to read.");
951
1182
 
952
- // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
953
- // connection, and the path itself never leaves this machine.
954
- const project = projectOf(root);
955
1183
  try { copyFileSync(join(homeOf(project), "tools.json"), join(work, "tools.json")); } catch { /* no run has read this project's tools yet */ }
956
1184
  // The key the last connect left for this project, if any: the token face signs in with it. A connect
957
1185
  // from the screen always asks for a fresh one, since the code may belong to another account or site.
@@ -960,14 +1188,16 @@ if (viaToken && !stored) fail("this project has no stored key. Run the command f
960
1188
  // A network that drops while connecting ends here in a sentence, never a stack trace: running the
961
1189
  // command again starts a clean connection.
962
1190
  const unreachable = (err) => fail(`could not reach ${origin.host}: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}. Check your connection and run the command again.`);
1191
+ // The key goes up beside a code too: a code that has lapsed while this project's key still holds is
1192
+ // the same person coming back, and the server signs them in by the key instead of refusing.
963
1193
  const attach = await call("POST", "/local/attach", { ...(viaToken ? {} : { code }), name: basename(root), project },
964
- viaToken ? { headers: { authorization: `Bearer ${stored}` } } : {}).catch(unreachable);
1194
+ stored ? { headers: { authorization: `Bearer ${stored}` } } : {}).catch(unreachable);
965
1195
  if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
966
1196
  box = attach.data.box;
967
1197
  key = attach.data.key;
968
- // This process is the one holding the app up for this project: lib/cli.mjs reads the file before
969
- // starting another.
970
- writeRunner(project, { pid: process.pid, startedAt: new Date().toISOString(), by: viaToken ? "token" : "connect" });
1198
+ // Taken once the code is accepted: a mistyped one never ends a runner that was working.
1199
+ if (!viaToken) await holdProject();
1200
+ writeFileSync(bootLog, "", { mode: 0o600 });
971
1201
 
972
1202
  const list = join(work, "files.txt");
973
1203
  writeFileSync(list, files.join("\n") + "\n");
@@ -992,6 +1222,9 @@ const head = (() => {
992
1222
  return /^[a-f0-9]{40}$/.test(sha) ? sha : "";
993
1223
  } catch { return ""; }
994
1224
  })();
1225
+ // Started now, so it comes up while the code is packed and sent; told to the server once it has the code.
1226
+ const firstStart = startApp();
1227
+ firstStart.catch(() => { /* appLife reads the failure */ });
995
1228
  const archive = join(work, "tree.tgz");
996
1229
  step("connecting");
997
1230
  await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
@@ -1016,12 +1249,15 @@ for (let off = 0; off < bytes.length; off += PART) {
1016
1249
  }
1017
1250
  }
1018
1251
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
1019
- stepDone(moved ? "connected · your change is in; the read from before stands" : resumed ? "connected · nothing changed since last time" : "connected");
1252
+ stepDone(moved ? "connected: your change is in, and what Cortad read before still stands" : resumed ? "connected: nothing changed since last time" : "connected");
1020
1253
  // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
1021
1254
  // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
1022
- if (!viaToken) {
1023
- const added = await registerAll().catch((err) => { if (verbose) say(`could not register with your coding agents: ${err?.message ?? err}`); return []; });
1024
- if (added.length) say(`added to ${added.join(", ")} · ask ${added.length === 1 ? "it" : "them"} for cortad any time`);
1255
+ // CORTAD_NO_REGISTER=1 leaves the coding agents' global skill and MCP entries alone: a stack under
1256
+ // test on this machine must never repoint the person's own agents at an unpublished build.
1257
+ if (!viaToken && process.env.CORTAD_NO_REGISTER !== "1") {
1258
+ const got = await registerAll().catch((err) => { if (verbose) say(`could not register with your coding agents: ${err?.message ?? err}`); return { clients: [], changed: [] }; });
1259
+ const and = (list) => new Intl.ListFormat("en", { type: "conjunction" }).format(list);
1260
+ if (got.changed.length) say(`For the person: ${and(got.clients)} can now use Cortad: its MCP server and skill were added to ${got.changed.map((f) => `~/${f}`).join(", ")}. This repository was not changed.`);
1025
1261
  }
1026
1262
 
1027
1263
  lock = await makeLock({ root, work });
@@ -1046,58 +1282,133 @@ function sourceChanged() {
1046
1282
  });
1047
1283
  }
1048
1284
  // `watching` says whether a message sent to this app can be seen arriving: only an app this command
1049
- // started carries the hook, and only a runtime the hook exists for.
1050
- const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: dataOnce(Boolean(launched)).said.slice(0, 8), watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
1285
+ // started carries the hook, and only a runtime the hook exists for. `proves`: each request seen
1286
+ // reaching the model is posted as its door's proof, so the run waits for those.
1287
+ // `blocked`: each store off this machine no copy could be made of, which holds Run until the person
1288
+ // says yes in the browser. The answer carries that yes (setup.consent), and the hosts it names are
1289
+ // written where the hook reads which outbound writes of a trial may leave.
1290
+ const announce = async () => {
1291
+ // Through the proxy, a message is seen arriving at its front, and its model calls once the app was started with its settings on the proxy.
1292
+ const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
1293
+ const started = Boolean(launched);
1294
+ const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
1295
+ if (told.ok) await allowOutbound(told.data?.consent);
1296
+ return told;
1297
+ };
1298
+
1299
+ // What the person said yes to, as the hook reads it: the hosts a trial's writes may reach.
1300
+ async function allowOutbound(given) {
1301
+ if (given && typeof given === "object") {
1302
+ const list = (v, re) => (Array.isArray(v) ? v.filter((x) => typeof x === "string" && re.test(x)).slice(0, 50) : []);
1303
+ consent = { hosts: list(given.hosts, /^[a-z0-9.-]{1,253}$/i).map((h) => h.toLowerCase()), stores: list(given.stores, /^[a-f0-9]{16}$/) };
1304
+ await liftWith(given.limits === true);
1305
+ }
1306
+ try { writeFileSync(outboundFile, JSON.stringify({ pass: await keeping.outbound(consent) }), { mode: 0o600 }); } catch { /* every write of a trial stays held */ }
1307
+ }
1308
+
1309
+ // A yes given or taken back in the browser restarts an app this command started with its guards
1310
+ // raised or restored; an app raised by the terminal's own yes keeps that.
1311
+ async function liftWith(yes) {
1312
+ if (yes === limitsYes) return;
1313
+ limitsYes = yes;
1314
+ if (!launched || !child || launched.byTerminal) return;
1315
+ const lifted = sessionLifts(yes, envFiles, files.map((f) => join(root, f)));
1316
+ if (JSON.stringify(lifted) === JSON.stringify(launched.lifted ?? {})) return;
1317
+ launched = { ...launched, lifted };
1318
+ if (app) app.lifted = Object.keys(lifted);
1319
+ void restartApp(yes ? `starting your app again with its own limits raised for this session: ${Object.keys(lifted).join(", ")}` : "starting your app again with its own limits as they are");
1320
+ }
1051
1321
 
1052
1322
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
1053
1323
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
1054
1324
  // world still looking for your chat asks again by itself. Nothing is ever rerun by hand.
1055
- async function appLife() {
1325
+ async function appLife(firstStart) {
1056
1326
  for (let first = true; ; first = false) {
1057
- const got = await startApp();
1327
+ const got = await (first ? firstStart : startApp());
1058
1328
  if (got.port) { app = got; if (!launched) await noteAttached(app.port); break; }
1329
+ failedWith(got.why, got.said);
1059
1330
  await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
1060
- say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
1331
+ say(got.why);
1061
1332
  // Said with the same words the agent prompt waits for, so an agent holding the terminal reports
1062
1333
  // the failure instead of waiting for a line that never comes.
1063
1334
  if (first) say("your app did not start. Go back to the browser; it says what stopped it. Ctrl-C disconnects.");
1064
1335
  await sourceChanged();
1336
+ become("starting");
1065
1337
  say("saw your change, starting your app again");
1066
1338
  }
1067
1339
  const told = await announce();
1068
1340
  clearStep();
1069
- if (!told.ok) fail(told.data?.error ?? `could not register your app (${told.status})`);
1341
+ if (!told.ok) return quit(told.data?.error ?? `could not register your app (${told.status})`);
1342
+ machineOf = () => (app ? {
1343
+ cmd: app.cmd ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1344
+ hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
1345
+ changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
1346
+ copies: Object.keys(keeping.envNow()).length ? "made" : "none",
1347
+ } : null);
1348
+ become("up", { ...upAt(), app: launched ? child?.pid ?? null : null });
1070
1349
  say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
1350
+ if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
1351
+ if (proxy) say(`send requests to port ${proxy.port}: it passes them to your app and Cortad sees each one. Requests sent straight to port ${app.port} are not seen`);
1352
+ for (const line of machineText(machineOf())) say(line);
1071
1353
  say("leave this open. Go back to the browser; Ctrl-C disconnects.");
1072
1354
  let downSince = 0;
1073
1355
  let toldDown = false;
1074
1356
  let appTold = true;
1357
+ // The stores and services its settings name, brought up once the app's own port is known; a
1358
+ // store copied only now is one the app was not started on.
1359
+ if (launched) {
1360
+ try {
1361
+ const got = await keeping.services(app.port).catch(() => ({}));
1362
+ if (got.copied && child) await restartApp(COPIED);
1363
+ if (got.changed) appTold = false;
1364
+ } finally { release(); }
1365
+ }
1366
+ // Only once the app runs on the copies made for this session.
1367
+ void reachLoop();
1075
1368
  forgetTold = () => { appTold = false; };
1369
+ void restartOnSave().catch((e) => say(`could not watch your files for a save: ${e?.message ?? e}`));
1370
+ const health = makeHealth({ host: () => appHost, gone: () => Boolean(launched && appGone), inFlight: () => answering });
1076
1371
  for (let up = true; !closing;) {
1077
1372
  await new Promise((r) => setTimeout(r, 2000));
1078
1373
  if (closing || restarting) continue;
1079
- const now = await answers(app.port);
1080
- if (now) {
1374
+ const state = await health.check(app.port);
1375
+ if (state === "up") {
1081
1376
  downSince = 0; toldDown = false;
1082
1377
  // Said until it is heard. Said once, it was lost when their app came back while the network
1083
1378
  // was down, and the screen went on showing an app that had stopped while it answered turns.
1379
+ if (!up) become("up", { ...upAt(), app: launched ? child?.pid ?? null : null });
1380
+ else if (became?.state === "up") {
1381
+ const now = upAt();
1382
+ if (JSON.stringify(now.receipts) !== JSON.stringify(became.fields.receipts)) { const { receipts: _was, ...fields } = became.fields; become("up", { ...fields, ...now }); }
1383
+ }
1384
+ const reach = await keeping.watch(app.port).catch(() => ({}));
1385
+ try { if (reach.copied && launched && child) await restartApp(COPIED); } finally { release(); }
1386
+ if (reach.changed) appTold = false;
1084
1387
  if (!up || !appTold) { up = true; appTold = Boolean((await announce().catch(() => null))?.ok); }
1085
1388
  continue;
1086
1389
  }
1390
+ // Running, holding its port, and slow because it is answering the run's own requests: working,
1391
+ // not dead. Nothing is said and nothing restarts, and a silence after it is timed from here.
1392
+ if (state === "busy") { if (!up) downSince = Date.now(); continue; }
1087
1393
  if (up) { up = false; downSince = Date.now(); }
1088
1394
  appTold = false;
1089
1395
  const downFor = Date.now() - downSince;
1090
- // Not answering. A dev server restarting on a save is busy and says so in its output: it is
1091
- // left alone, however long its own startup takes.
1092
- // A watcher that has said its app crashed is not busy, it is waiting, and says so.
1396
+ // Not answering. A dev server restarting on a save says so in its output: it is left alone,
1397
+ // however long its own startup takes.
1398
+ // A watcher that has said its app crashed is not reloading, it is waiting, and says so.
1093
1399
  const crashed = /app crashed - waiting|Failed running|waiting for (?:file )?changes before restart/i.test(lastSaid.slice(-600));
1094
- const busy = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
1095
- if (busy || downFor < (crashed ? 4_000 : 10_000)) continue;
1400
+ const reloading = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
1401
+ if (reloading || downFor < (crashed ? 4_000 : 10_000)) continue;
1096
1402
  // Down for real. The screen is told, so it never shows an app that is not there.
1097
1403
  // `exited`: the process this command started is gone or its watcher said it crashed, not a process
1098
1404
  // running and not answering. The run tells the two apart: one is their crash, the other may be our
1099
1405
  // load. An app it only attached to is not its to watch, so nothing is said either way.
1100
- if (!toldDown) toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000), ...(launched ? { exited: Boolean(appGone || crashed) } : {}) }).catch(() => null))?.ok);
1406
+ if (!toldDown) {
1407
+ // Started again below, except an app given by --port, which is picked up when it answers again.
1408
+ if (!launched && flag("--port")) failedWith(`your app stopped answering on port ${app.port}; it is picked up again when it answers`, lastSaid);
1409
+ else become("starting");
1410
+ toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000), ...(launched ? { exited: Boolean(appGone || crashed) } : {}) }).catch(() => null))?.ok);
1411
+ }
1101
1412
  // An app that went quiet or exited did not stop because of a save (it was killed, it ran out
1102
1413
  // of memory, it crashed on a request), so waiting for a save would wait forever. It is started
1103
1414
  // here, whoever started it first: an app this command only attached to (yours, from another
@@ -1105,24 +1416,87 @@ async function appLife() {
1105
1416
  // knows how it starts. Only an app given by --port is left to you, and picked up when it returns.
1106
1417
  if (launched && !appGone && !crashed && downFor < 25_000) continue;
1107
1418
  if (!launched && flag("--port")) continue;
1108
- restarting = true;
1109
- try {
1110
- if (child?.pid) await stopApp(child.pid);
1111
- say("your app stopped answering, starting it again");
1419
+ await restartOnce(async () => {
1420
+ if (child?.pid) await stopTree(child.pid);
1421
+ say(downLine(state, { port: app.port, downMs: downFor, crashed }));
1112
1422
  let back = launched ? await launch(180_000) : await startApp();
1113
1423
  while (!back.port && !closing) {
1424
+ const why = back.why ?? whyNot(back);
1425
+ failedWith(why, back.tail || back.said);
1114
1426
  await call("POST", `/local/${box}/stopped`, { said: mask(back.tail || back.said || lastSaid).slice(-2000) }).catch(() => {});
1115
- say("your app did not come back. Fix it and save: it is started again by itself.");
1427
+ say(`your app did not come back: ${why}`);
1116
1428
  await sourceChanged();
1429
+ become("starting");
1117
1430
  say("saw your change, starting your app again");
1118
1431
  back = launched ? await launch(180_000) : await startApp();
1119
1432
  }
1120
- if (back.port && !launched) await noteAttached(back.port);
1121
- if (back.port) { app = { ...app, ...(back.cmd !== undefined ? back : {}), port: back.port }; up = true; downSince = 0; toldDown = false; appTold = Boolean((await announce().catch(() => null))?.ok); say(`your app is answering again on port ${app.port}`); }
1122
- } finally { restarting = false; }
1433
+ if (!back.port) return { error: "This command is closing." };
1434
+ if (!launched) await noteAttached(back.port);
1435
+ app = { ...app, ...(back.cmd !== undefined ? back : {}), port: back.port };
1436
+ become("up", { ...upAt(), app: launched ? child?.pid ?? null : null });
1437
+ up = true; downSince = 0; toldDown = false;
1438
+ appTold = Boolean((await announce().catch(() => null))?.ok);
1439
+ say(`your app is answering again on port ${app.port}`);
1440
+ return { restarted: true, port: app.port };
1441
+ });
1442
+ }
1443
+ }
1444
+ void appLife(firstStart).catch((e) => quit(String(e?.message ?? e)));
1445
+
1446
+ // The page Cortad's test requests come from: one the env names on the app's port, else one on this
1447
+ // machine (a local frontend in front of this API). A hosted address such as a Supabase URL is no page
1448
+ // of the app's, so an app whose env names none is asked with no Origin, as a server would ask it.
1449
+ const LOCAL_ORIGIN = /^https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/;
1450
+ function ownOrigin(port) {
1451
+ const local = envOrigins(envFiles).filter((o) => LOCAL_ORIGIN.test(o));
1452
+ return local.find((o) => new URL(o).port === String(port)) ?? local[0] ?? null;
1453
+ }
1454
+ // Counted with the run's own requests, so a restart drains them, and sent only through the open gate.
1455
+ async function countedFetch(url, init) {
1456
+ while (gate) await gate;
1457
+ answering += 1;
1458
+ try {
1459
+ const res = await fetch(url, init);
1460
+ const body = await res.arrayBuffer();
1461
+ return new Response(body.byteLength ? body : null, { status: res.status, statusText: res.statusText, headers: res.headers });
1462
+ } finally { answering -= 1; }
1463
+ }
1464
+ // Cortad's own test request to each endpoint the read lists, sent from here as soon as status lists
1465
+ // them (lib/auto-reach.mjs), and the agent's reach the same way. Status is asked every 5 seconds
1466
+ // while the code is read, then every minute; an ask from the agent is taken within a second.
1467
+ const autoReach = makeAutoReach({
1468
+ status: async () => {
1469
+ const token = readToken(project);
1470
+ const res = token ? await call("GET", "/mcp/status?quiet=1", undefined, { headers: { authorization: `Bearer ${token}` } }).catch(() => null) : null;
1471
+ return res?.ok ? res.data : null;
1472
+ },
1473
+ runner: () => (became?.state === "up" && !restarting ? { state: "up", ...became.fields } : null),
1474
+ // One test request per endpoint is what the person's own agent sends by hand, so it does not wait on
1475
+ // the yes a run's writes into an uncopied store need; it waits only while a copy is being made.
1476
+ waiting: async () => (gate ? "Your app's data is being copied for this session." : null),
1477
+ origin: () => (app ? ownOrigin(app.port) : null),
1478
+ signIn: async (m) => { const role = typeof m.as === "string" ? m.as : identities?.roles()[0]; return role ? identities.headerFor(role) : null; },
1479
+ stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
1480
+ asked: () => existsSync(join(homeOf(project), ASK)),
1481
+ report: async (answers) => {
1482
+ if (!answers.length && reachedBox === box) return;
1483
+ const to = box;
1484
+ if ((await call("POST", `/local/${to}/reached`, { answers }, { timeoutMs: 15_000 })).ok) reachedBox = to;
1485
+ },
1486
+ say,
1487
+ fetchImpl: countedFetch,
1488
+ });
1489
+ // The connection the server last heard sends its test requests itself; a reattach is a new one.
1490
+ let reachedBox = "";
1491
+ async function reachLoop() {
1492
+ for (let dueAt = 0; !closing; await new Promise((r) => setTimeout(r, 1000))) {
1493
+ const ask = takeAsk(homeOf(project));
1494
+ if (!ask && Date.now() < dueAt) continue;
1495
+ const out = await autoReach(ask).catch(() => null);
1496
+ if (ask) { try { answerAsk(homeOf(project), ask.id, out?.text ?? "Nothing was sent.\nnext: status"); } catch { /* the agent's wait runs out */ } }
1497
+ dueAt = Date.now() + (out?.wait ?? 30_000);
1123
1498
  }
1124
1499
  }
1125
- void appLife().catch((e) => fail(String(e?.message ?? e)));
1126
1500
 
1127
1501
  // Keep the laptop awake while a world stands on it.
1128
1502
  if (process.platform === "darwin") spawn("caffeinate", ["-i", "-w", String(process.pid)], { stdio: "ignore", detached: true }).unref();
@@ -1133,30 +1507,19 @@ const IDLE_MS = 10 * 60_000;
1133
1507
  if (argv.includes("--until-idle") && stored) {
1134
1508
  let idleSince = Date.now();
1135
1509
  setInterval(async () => {
1136
- const res = await call("GET", "/mcp/status", undefined, { headers: { authorization: `Bearer ${stored}` } }).catch(() => null);
1510
+ const res = await call("GET", "/mcp/status?quiet=1", undefined, { headers: { authorization: `Bearer ${stored}` } }).catch(() => null);
1137
1511
  if (res?.ok && res.data?.run && !finished(res.data.run)) idleSince = Date.now();
1138
1512
  else if (Date.now() - idleSince > IDLE_MS) { say("no run for ten minutes, leaving"); await close(0); }
1139
1513
  }, 60_000).unref();
1140
1514
  }
1141
1515
 
1142
- async function close(code = 0) {
1143
- if (closing) return;
1144
- closing = true;
1145
- clearRunner(project);
1146
- await call("DELETE", `/local/${box}`).catch(() => {});
1147
- if (child?.pid) await stopApp(child.pid);
1148
- for (const kid of sidecars) if (kid.pid) await stopApp(kid.pid);
1149
- await exec("rm", ["-rf", work]).catch(() => {});
1150
- process.exit(code);
1151
- }
1152
- process.on("SIGINT", () => close(0));
1153
- process.on("SIGTERM", () => close(0));
1154
1516
  // A verb in another process asks for the app to be started again before a run, because its code
1155
1517
  // changed and nothing reloaded it (lib/fresh.mjs). The new start is noted as the app answers; one
1156
- // that fails is noted with its reason. A restart already under way answers the same way.
1518
+ // that fails is the runner's failed state, with its reason. A restart already under way answers the
1519
+ // same way.
1157
1520
  process.on("SIGUSR2", () => {
1158
1521
  if (restarting || !app) return;
1159
- void restartApp().then((out) => { if (out.error) writeApp(project, { runner: process.pid, failedAt: new Date().toISOString(), error: mask(out.error).slice(-600) }); });
1522
+ void restartApp();
1160
1523
  });
1161
1524
 
1162
1525
  let quiet = 0;
@@ -1177,6 +1540,7 @@ for (;;) {
1177
1540
  if (!back?.ok) { say("this session ended on the server. Run the command again for a new one."); await close(1); }
1178
1541
  if (app?.port) await announce().catch(() => {});
1179
1542
  forgetTold?.();
1543
+ capture?.proveAgain();
1180
1544
  continue;
1181
1545
  }
1182
1546
  if (!res.ok) { quiet += 1; await new Promise((r) => setTimeout(r, 2000)); continue; }