cortad 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/local.mjs CHANGED
@@ -1,31 +1,33 @@
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"] [--proxy] [--verbose]
4
+ // npx cortad ABCD2345 -- <how you start your app> your command, run here with Cortad's hook inside it
5
+ // npx cortad ABCD2345 --port 3000 an app already running: its replies are seen, its model calls are not
5
6
  //
6
7
  // What it does: signs in with the code, uploads your source files once (never .env, never
7
- // node_modules) so your code can be read, starts your app the way you start it, then holds one
8
- // outbound connection open and does what a run asks: a request to your app, a file read, a shell
9
- // line. The shell is locked by the operating system (lib/lock.mjs) and cannot write your code;
8
+ // node_modules) so your code can be read, runs your start command as you typed it with nothing
9
+ // added but the hook, then holds one outbound connection open and does what a run asks: a request to
10
+ // your app, a file read, a shell line. It guesses nothing about your environment: your command
11
+ // decides the settings, the databases and the services, and nothing is copied or started beside it. The shell is locked by the operating system (lib/lock.mjs) and cannot write your code;
10
12
  // nothing here writes your files at all. It also makes Cortad known to the coding agents on this
11
13
  // machine (lib/register.mjs): an MCP entry and a skill, so `npx cortad mcp` answers them from then
12
14
  // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
13
15
  // never leaves this machine. Ctrl-C ends everything.
14
16
 
15
- import { storesOf, withoutStoreSettings } from "./lib/stores.mjs";
16
17
  import { notAnApp, withoutStores } from "./lib/app-port.mjs";
17
18
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
19
+ import { parseEnv } from "./lib/env-files.mjs";
20
+ import { historyParts, repoFacts } from "./lib/commits.mjs";
18
21
  import { spawn, execFile, execFileSync } from "node:child_process";
19
22
  import { createHash } from "node:crypto";
20
23
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
21
- import { homedir, hostname, tmpdir } from "node:os";
24
+ import { hostname, tmpdir } from "node:os";
22
25
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
- import { createInterface } from "node:readline";
24
26
  import { promisify } from "node:util";
25
27
  import { COMMANDS, main as face, unknownVerb, VERBS } from "./lib/cli.mjs";
26
28
  import { addNews, homeOf, projectOf, readToken, writeApp, writeDigest, writeToken } from "./lib/home.mjs";
27
29
  import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
28
- import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree, supervisorOf } from "./lib/proc.mjs";
30
+ import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree } from "./lib/proc.mjs";
29
31
  import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, writeRunner } from "./lib/runner.mjs";
30
32
  import { registerAll } from "./lib/register.mjs";
31
33
  import { connectedText } from "./lib/read-text.mjs";
@@ -33,15 +35,11 @@ import { VERSION } from "./lib/spec.mjs";
33
35
  import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
34
36
  import { finished, unseenText } from "./lib/text.mjs";
35
37
  import { lockHolds, makeLock } from "./lib/lock.mjs";
36
- import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
37
- import { CAPTURED, makeCapture } from "./lib/replay.mjs";
38
+ import { AS_HEADER, CAPTURED, makeCapture } from "./lib/replay.mjs";
38
39
  import { sampleHere } from "./lib/sample.mjs";
39
- import { mintAcross, originFor, servicePort, waitForPort } from "./lib/service.mjs";
40
- import { listingUrl } from "./lib/listing.mjs";
41
- import { hookable, installPlan, missingDependency, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
40
+ import { hookable, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
42
41
  import { openProxy } from "./lib/proxy.mjs";
43
- import { liftedLimits, sessionLifts } from "./lib/switches.mjs";
44
- import { makeKeeping } from "./lib/keeping.mjs";
42
+ import { liftedLimits } from "./lib/switches.mjs";
45
43
  import { downLine, makeHealth } from "./lib/health.mjs";
46
44
  import { markRun, writesDirOf, writtenPaths } from "./lib/writes.mjs";
47
45
  import { makeHeld } from "./lib/held.mjs";
@@ -58,18 +56,21 @@ if (unknownVerb(argv[0])) {
58
56
  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
57
  process.exit(1);
60
58
  }
61
- const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
62
- const verbose = argv.includes("--verbose");
59
+ // Everything after `--` is the start command, as typed; `--start "cmd"` says the same for an older skill.
60
+ const dash = argv.indexOf("--");
61
+ const own = dash >= 0 ? argv.slice(0, dash) : argv;
62
+ const flag = (name) => { const i = own.indexOf(name); return i >= 0 ? own[i + 1] : undefined; };
63
+ const verbose = own.includes("--verbose");
63
64
  // A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
64
- let code = (argv.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
65
+ let code = (own.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
65
66
  const say = (line) => console.log(`cortad ${line}`);
66
67
  const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
67
68
 
68
- const explain = argv.includes("--explain");
69
+ const explain = own.includes("--explain");
69
70
  // 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");
71
+ const proxyAsked = own.includes("--proxy");
71
72
  // Started by lib/cli.mjs for a run on a later day: no code, the key the first connect left behind.
72
- const viaToken = argv.includes("--token");
73
+ const viaToken = own.includes("--token");
73
74
  // What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
74
75
  // this package before anything here runs, and the first thing we printed used to be after the whole
75
76
  // upload: a minute or more of a cursor sitting still, which reads as nothing happening.
@@ -84,8 +85,9 @@ const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b
84
85
  const stepDone = (line) => { clearStep(); say(line); };
85
86
  // `npx cortad` alone connects too: with the key this folder kept from its last connect, or, with none,
86
87
  // by one approval in the browser (pairing below). A word that is neither a code nor a flag is refused.
87
- const words = argv.filter((a, i) => !a.startsWith("-") && !["--port", "--start"].includes(argv[i - 1] ?? ""));
88
- if (!explain && !viaToken && words.length && !/^[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(" | ")}`);
88
+ const words = own.filter((a, i) => !a.startsWith("-") && !["--port", "--start"].includes(own[i - 1] ?? ""));
89
+ const USAGE = `usage: npx cortad <code from the connect screen> -- <how you start your app> | npx cortad <code> --port <port of your running app> | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`;
90
+ if (!explain && !viaToken && words.length && !/^[A-Z0-9]{8}$/.test(code)) fail(USAGE);
89
91
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
90
92
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
91
93
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
@@ -101,13 +103,39 @@ const root = process.cwd();
101
103
  const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
102
104
  // A repository whose root carries no manifest of its own is still a repository: crewai-examples
103
105
  // keeps one project per folder under crews/, and the whole of it was refused at the door. The root
104
- // is accepted when a project lives below it.
105
- if (!MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
106
- 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}`);
106
+ // is accepted when a project lives below it, and whenever the person says how the app starts or
107
+ // where it answers: a single file with no manifest is an app too, and nothing here needs the manifest.
108
+ const startSaid = argv.includes("--") || argv.includes("--start") || argv.includes("--port") || argv.includes("--token");
109
+ if (!startSaid && !MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
110
+ 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, or say how your app starts: npx cortad <code> -- <your start command>`);
107
111
  }
108
112
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
109
113
  // connection, and the path itself never leaves this machine.
110
114
  const project = projectOf(root);
115
+ // How this app starts, kept on this machine for the sessions a run starts by itself (--token) and
116
+ // for the next connect: the command typed after `--`, or the port of an app already running.
117
+ const startFile = join(homeOf(project), "start.json");
118
+ const keptStart = (() => { try { return JSON.parse(readFileSync(startFile, "utf8")); } catch { return {}; } })();
119
+ // Typed after --, or with --start, or saved from the last connect of this folder: a person who
120
+ // reconnects types the code alone, and the runner a run starts is given nothing at all.
121
+ const typedNow = dash >= 0 ? own.length === argv.length ? "" : argv.slice(dash + 1).join(" ").trim() : flag("--start") ?? "";
122
+ const typed = typedNow || (flag("--port") ? "" : keptStart.cmd ?? "");
123
+ const fromSaved = !typedNow && Boolean(typed);
124
+ // A port saved beside the command (a container's) is watched again on a reconnect with the code alone.
125
+ const wantedPort = Number(flag("--port")) || (!typedNow ? Number(keptStart.port) || 0 : 0);
126
+ const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
127
+ // Nothing was said about how the app starts: the one suggestion the repository makes obvious is
128
+ // printed, never run. A guess shown to a person is fine; a guess acted on at runtime is what put an
129
+ // app on a placeholder key and its library in an empty copy.
130
+ if (!explain && !viaToken && !typed && !wantedPort) {
131
+ const plan = startPlan({ root, onPath });
132
+ const shown = code || "<code>";
133
+ console.error(`cortad how does your app start? Run this again with your command after --:`);
134
+ console.error(`cortad npx cortad ${shown} -- ${plan?.cmd ?? "<your start command>"}${plan?.cmd ? ` (from this folder that looks like it${plan.within ? `, in ${plan.within}` : ""}; change it if not)` : ""}`);
135
+ console.error(`cortad or, if your app is already running: npx cortad ${shown} --port ${plan?.cmd ? "<its port>" : "3000"}`);
136
+ process.exit(2);
137
+ }
138
+ if (viaToken && !typed && !wantedPort) fail("this project has no saved start command. Run npx cortad <code> -- <how you start your app> in this folder once.");
111
139
 
112
140
  // One process holds a project's app up (lib/runner.mjs), and its state is what every reader reports.
113
141
  // `reach: "ask"`: an agent's reach is sent by this process (lib/auto-reach.mjs), signed in where it can.
@@ -193,71 +221,6 @@ function carriesKey(rel) {
193
221
  try { return holdsKeys(readFileSync(join(root, rel), "utf8"), envKeys); } catch { return false; }
194
222
  }
195
223
 
196
- // Origins your environment names (FRONTEND_URL, CORS_ORIGIN, ...): an app that trusts a browser
197
- // Origin is asked as that browser. Sent as origins only, never the variable's full value.
198
- function envOrigins(envFiles) {
199
- const out = new Set();
200
- for (const file of envFiles) {
201
- let text = "";
202
- try { text = readFileSync(file, "utf8"); } catch { continue; }
203
- for (const line of text.split("\n")) {
204
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
205
- if (!m || !/ORIGIN|URL|HOST|DOMAIN|FRONTEND|CLIENT|SITE|WEB/i.test(m[1])) continue;
206
- // OPENAI_BASE_URL names where a key is sent, not a page a browser opens the app from.
207
- if (/(?:BASE_URL|API_BASE|ENDPOINT)$/i.test(m[1]) && !/ORIGIN|FRONTEND|CLIENT|SITE|WEB|PUBLIC|APP/i.test(m[1])) continue;
208
- for (const v of m[2].replace(/^(['"])(.*)\1$/, "$2").split(",")) {
209
- try { const u = new URL(v.trim()); if (/^https?:$/.test(u.protocol)) out.add(u.origin); } catch { /* not an origin */ }
210
- }
211
- }
212
- }
213
- return [...out].slice(0, 8);
214
- }
215
- // Every name your env files set, with its value, read here and used only here. An example file is
216
- // read first so a real one's value wins: a placeholder key asked for a model listing comes back
217
- // refused, and that would read as your own key being refused.
218
- function envValues(envFiles) {
219
- const example = /\.(example|sample)$/;
220
- const out = {};
221
- for (const file of [...envFiles.filter((f) => example.test(f)), ...envFiles.filter((f) => !example.test(f))]) {
222
- let text = "";
223
- try { text = readFileSync(file, "utf8"); } catch { continue; }
224
- for (const line of text.split("\n")) {
225
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
226
- if (m) out[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
227
- }
228
- }
229
- return out;
230
- }
231
- // Switch-shaped names and the word each one reads as. The name and the word travel, never the value.
232
- const SWITCH_NAME = /(?:_ENABLED|_DISABLED|_MODE|_ON|_OFF|_FLAG)$|^(?:ENABLE|USE|DISABLE|SKIP)_/;
233
- const switchStates = (values) => Object.fromEntries(Object.entries(values)
234
- .filter(([name]) => SWITCH_NAME.test(name))
235
- .map(([name, value]) => [name, value === "" ? "unset" : /^(?:1|true|yes|on)$/i.test(value) ? "on" : /^(?:0|false|no|off)$/i.test(value) ? "off" : "set"]));
236
-
237
- // What your app can actually reach: each provider key your env files set is asked that provider
238
- // for its own model listing, from this machine. Your key stays here; what goes back is the name it
239
- // is set under, the host, what the host answered, the model ids, and which switches are on.
240
- const MODEL_ID = /^[\w./:@-]{1,160}$/;
241
- async function inventoryOf(listings) {
242
- const values = envValues(envFiles);
243
- const providers = await Promise.all(Object.entries(listings)
244
- .filter(([name]) => values[name])
245
- .map(async ([name, canonical]) => {
246
- const url = listingUrl(name, canonical, values);
247
- let host = "";
248
- try { host = new URL(url).host; } catch { return null; }
249
- try {
250
- const res = await fetch(url, { headers: { authorization: `Bearer ${values[name]}` }, signal: AbortSignal.timeout(20_000) });
251
- const body = res.ok ? await res.json().catch(() => null) : null;
252
- const models = (Array.isArray(body?.data) ? body.data : [])
253
- .map((m) => String(m?.id ?? "")).filter((id) => MODEL_ID.test(id)).slice(0, 1500);
254
- return { env: name, host, status: res.status, models };
255
- } catch {
256
- return { env: name, host, status: 0, models: [] };
257
- }
258
- }));
259
- return { providers: providers.filter(Boolean), flags: switchStates(values) };
260
- }
261
224
  let secrets = [];
262
225
  // A run's request to an endpoint your app answers later carries the first (src/customer/endpoint.ts),
263
226
  // with how long the run waits for this reply; a reply with no answer says in the second why none came.
@@ -268,7 +231,6 @@ const trialJars = new Map();
268
231
  // Endpoints that refused a new visitor: their trials carry the person's own session.
269
232
  const ownSession = new Set();
270
233
  const TRIAL_JARS = 500;
271
- let identities = null;
272
234
  let capture = null;
273
235
  // The local proxy an app is seen through when the hook cannot load into it (lib/proxy.mjs).
274
236
  let proxy = null;
@@ -294,8 +256,7 @@ let forgetTold = null;
294
256
  // load on it, not a hang.
295
257
  let answering = 0;
296
258
  const held = makeHeld({ rows: () => capture?.usage()?.rows ?? [], say: (line) => say(line) });
297
- // Closed while a store the app reaches is brought up and copied, until the app runs on the copy: the
298
- // run's requests wait at it rather than write into the original.
259
+ // Closed while the app is started again: the run's requests wait at it and are sent once it answers.
299
260
  let gate = null;
300
261
  const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
301
262
  const release = () => gate?.open();
@@ -340,13 +301,6 @@ async function call(method, path, body, { raw = false, timeoutMs = 60_000, heade
340
301
 
341
302
  // ---- the box on this machine: your folder, read where it stands
342
303
  const work = join(tmpdir(), `cortad-${process.pid}`);
343
- // Where the hook reads the hosts a trial's writes may reach (lib/trace.cjs, lib/pyhook), and the
344
- // yes they come from.
345
- const outboundFile = join(work, "outbound.json");
346
- let consent = {};
347
- // The person's yes to raising their app's own limits and opening its sign-in switches for this
348
- // session, from the browser. Until it arrives the app runs with its guards as they are.
349
- let limitsYes = false;
350
304
  mkdirSync(work, { recursive: true });
351
305
  // What the app printed this session, beside the project's other records rather than in a temp folder
352
306
  // the agent is never told of. Emptied once this process holds the project (holdProject), so a second
@@ -393,9 +347,14 @@ async function verb(job) {
393
347
  if (verbose) say(`$ ${cmd.slice(0, 160)}`);
394
348
  // No lock, no shell: a world never gets an unlocked one on this machine.
395
349
  if (!lock) return { success: false, exitCode: 126, stdout: "", stderr: "refused: this machine has no sandbox tool (sandbox-exec or bubblewrap), so no shell line is run here" };
396
- const { file, args } = lock.wrap(cmd);
350
+ // A world's command (opts.app) runs as the app: with this terminal's settings, which are the
351
+ // app's own where the person started it from here, and with the app's reach on the network,
352
+ // since it runs the app's own AI against the app's model provider.
353
+ const asApp = b.opts?.app === true;
354
+ const { file, args } = lock.wrap(cmd, { network: asApp });
355
+ const env = asApp ? { ...process.env, ...plainEnv } : plainEnv;
397
356
  return new Promise((done) => {
398
- const child = spawn(file, args, { cwd: root, env: plainEnv, stdio: ["ignore", "pipe", "pipe"] });
357
+ const child = spawn(file, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
399
358
  let out = "", err = "";
400
359
  const cap = (s, chunk) => (s.length < 20_000_000 ? s + chunk : s);
401
360
  child.stdout.on("data", (d) => { out = cap(out, d.toString()); });
@@ -417,6 +376,7 @@ async function verb(job) {
417
376
  // Every request of ours is tagged, a knock or a revisit as much as a trial, so the hook never
418
377
  // takes one for the person's: it would become the request trials copy and the sign-in they carry.
419
378
  if (!turn) headers["x-cortad-turn"] = `cortad-${String(job.id).replace(/[^\w.-]/g, "").slice(0, 64)}`;
379
+ if (verbose) say(`$ ${String(b.method ?? "GET").toUpperCase()} ${path.split("?")[0]} as ${headers[AS_HEADER] ?? headers[AS_HEADER.toLowerCase()] ?? "nobody"}, turn ${headers["x-cortad-turn"]}`);
420
380
  const letGo = held.hold(job.id, turn || headers["x-cortad-turn"]);
421
381
  // An endpoint your app answers later: it takes the message and its model answers afterwards,
422
382
  // outside the request (a queue's worker). Its own reply only says it took the message.
@@ -425,8 +385,9 @@ async function verb(job) {
425
385
  const later = laterAt !== undefined && (headers[laterAt] === "later" || laterMs > 0);
426
386
  if (laterAt !== undefined) delete headers[laterAt];
427
387
  if (later) capture?.forget(turn || headers["x-cortad-turn"]);
428
- // A request that speaks as one of your app's own callers carries the role, not the token:
429
- // the token was issued on this machine and is put in here, so it never travels.
388
+ // A request that speaks as one of your app's own callers carries the role, not the sign-in: the
389
+ // sign-in was seen on this machine, on the request the person or their agent sent, and is put in
390
+ // here, so it never travels. There is no other caller: Cortad makes no accounts.
430
391
  const marker = Object.keys(headers).find((k) => k.toLowerCase() === AS_HEADER);
431
392
  let spokenAs = null;
432
393
  if (marker) {
@@ -434,16 +395,8 @@ async function verb(job) {
434
395
  delete headers[marker];
435
396
  if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
436
397
  spokenAs = role;
437
- // Speaking as one of the customers whose requests we watched: every header their own client sent.
438
398
  const theirs = capture?.headers(role);
439
399
  for (const [name, value] of Object.entries(theirs ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
440
- // No request of theirs is held (a new connect): sent from the app's own address, as Cortad's
441
- // own test request is, so an app that lets visitors in by Origin lets this one in.
442
- const from = !theirs && app ? ownOrigin(app.port) : null;
443
- if (from && !Object.keys(headers).some((k) => k.toLowerCase() === "origin")) { headers.origin = from; headers.referer = `${from}/`; }
444
- } else {
445
- const held = await identities?.headerFor(role);
446
- if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
447
400
  }
448
401
  }
449
402
  const method = String(b.method ?? "GET").toUpperCase();
@@ -580,11 +533,8 @@ async function verb(job) {
580
533
  }
581
534
  catch (e) { return { success: false, stderr: String(e.message) }; }
582
535
  }
583
- case "mint": return identities ? JSON.parse(mask(JSON.stringify(await mintAcross({
584
- recipes: b.recipes, root, appDir, onPath, appPort: app?.port, portFor: serviceUp,
585
- mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
586
- originFor: (port) => originFor(port, envOrigins(envFiles)),
587
- })))) : { identities: [] };
536
+ // Cortad makes no accounts in your sign-in system: the request your agent sent carries the one it uses.
537
+ case "mint": return { identities: [] };
588
538
  // The last of what your app printed, for a reply it failed: the traceback a developer reads.
589
539
  // Masked like everything else, and the folder named so its paths can be read as your files.
590
540
  case "log": {
@@ -600,7 +550,7 @@ async function verb(job) {
600
550
  // The person changed what they said yes to while this command runs.
601
551
  // Said in the terminal: an agent that read "one question for the person" here told them the run
602
552
  // was still waiting two minutes after they had answered.
603
- case "consent": await allowOutbound(b); say("the person's answer on the card in the browser arrived"); return { ok: true };
553
+ case "consent": say("the person's answer on the card in the browser arrived"); return { ok: true };
604
554
  // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
605
555
  // One marked news (a run ending) is also kept for the hook, so an agent that was not listening
606
556
  // hears it on its next prompt or edit.
@@ -612,10 +562,9 @@ async function verb(job) {
612
562
  return { ok: true };
613
563
  }
614
564
  case "restart": return restartApp();
615
- case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
616
- // Your own pages, read here rather than in a world's shell: that shell is sealed away from
617
- // every env file and every host but this one, so inside it no store of yours has an address.
618
- case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, envValues(envFiles), root)))) };
565
+ // Your own pages, read here rather than in a world's shell, with this terminal's settings: the
566
+ // ones your app was started with.
567
+ case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, { ...process.env }, root)))) };
619
568
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
620
569
  // What your app spent on its providers, from the hook inside it. An app this command did not
621
570
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
@@ -651,157 +600,52 @@ const answers = async (port) => {
651
600
  }
652
601
  return false;
653
602
  };
654
- // The values an env file beside the app declares, exported into its process the way `source .env`
655
- // would: an app with no dotenv loader of its own crashed every route without them. Example files
656
- // hold placeholders and are left out; the shell's own environment and the lifted limits win.
657
- function envExports(envFiles) {
658
- return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
659
- }
660
- const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
661
- // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
662
- let appDir = root;
603
+ // Where the app's model settings point, for the proxy that stands in for a hook the app cannot load:
604
+ // this terminal's values, else the ones the repository's env files hold. Read here and handed to no
605
+ // process: the proxy forwards each call to the address the app's own settings name, and the app is
606
+ // handed only the proxy's address in their place (lib/proxy.mjs env).
607
+ const modelHints = () => ({ ...Object.assign({}, ...envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => { try { return parseEnv(readFileSync(f, "utf8"), process.env); } catch { return {}; } })), ...process.env });
608
+ // Where their app runs inside this repository: where the person's command runs it.
609
+ const appDir = root;
663
610
 
664
- // ---- a sign-in mounted in another workspace
665
- // One app served its AI from apps/api and mounted sign-in in apps/dashboard: a test account can
666
- // only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
667
- const toldService = new Set();
668
- const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`signed in a test account through ${dir}`); } return port; };
669
- // Everything else this command started, stopped with it: each started the way the app is, on the
670
- // same copies of its data.
671
- const sidecars = [];
672
- function sidecar(cmd, cwd) {
673
- 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() } });
674
- sidecars.push(kid);
675
- kid.stdout.on("data", logTo());
676
- kid.stderr.on("data", logTo());
677
- return kid;
678
- }
679
- // The port that workspace's sign-in answers on. One it is already serving on comes first: starting
680
- // a second copy of a dashboard that is already up costs a minute and takes its port.
681
- async function serviceUp(group) {
682
- const named = servicePort(group.cwd);
683
- if (named && named !== app?.port && (await answers(named))) return signedInAt(group.dir, named);
684
- // Its own port taken by the app leaves nothing to wait on: whatever answers there is the app.
685
- if (!named || named === app?.port || !group.plan?.cmd) return null;
686
- sidecar(group.plan.cmd, group.plan.cwd);
687
- return (await waitForPort(named, 90_000)) ? signedInAt(group.dir, named) : null;
688
- }
689
-
690
- async function ask(question) {
691
- if (!process.stdin.isTTY) return null;
692
- const rl = createInterface({ input: process.stdin, output: process.stdout });
693
- const line = await new Promise((r) => rl.question(question, r));
694
- rl.close();
695
- return line.trim() || null;
696
- }
697
611
  let child = null;
698
- // An app this command did not start carries nothing that counts its model calls, and no run plays
699
- // against it (the API holds Run): databuddy's free run played 39 conversations into a route that
700
- // answers from code.
701
- const UNSEEN = "your app was already running, so Cortad cannot see the calls it makes to its model, and no run can start.";
612
+ // An app this command did not start carries nothing that counts its model calls: its replies are seen
613
+ // through the proxy, and the person is told so, as what they chose.
614
+ const UNSEEN = "your app was already running, so Cortad sees its replies and not the calls it makes to its model.";
615
+ const keepStart = (record) => { Object.assign(keptStart, record); try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(startFile, JSON.stringify(keptStart), { mode: 0o600 }); } catch { /* kept for this session only */ } };
702
616
  async function startApp() {
703
- const wanted = Number(flag("--port"));
704
- // "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
705
- // read as the latter it said nothing was answering and never ran the command it was given.
706
- if (wanted && !flag("--start")) {
707
- if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
708
- const took = await takeOver(wanted);
709
- if (took) return took;
710
- if (proxyAsked) {
711
- await useProxy();
712
- say(`your app was already running, so Cortad cannot see the calls it makes to its model until it is started with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
713
- } else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
714
- return { port: wanted, cmd: null };
715
- }
716
- const plan = startPlan({ root, typed: flag("--start"), onPath });
717
- // A package is not a broken app, and asking its owner how it starts is the wrong question.
718
- const cmd = plan?.cmd ?? (plan?.noServer ? null : await ask("How do you start your app? (for example: npm run dev) "));
719
- // Your code is already here and already being read: quitting now would throw away what you have
720
- // paid for. This stays connected, the read finishes, and the browser says what is missing.
721
- if (!cmd) {
722
- return { port: null, said: "", noStart: true,
723
- why: plan?.noServer
724
- ? "this repository has no server to run; connect the app that serves it. If it does have one, run this again with --start \"how you start it\"."
725
- : "your code is being read, but I could not work out how your app starts, so nothing is running to ask. Run the command again with --start \"how you start it\", or start your app yourself and run it again with --port <the port it answers on>." };
617
+ // "--port 8106 -- ..." is "start it, it will answer on 8106", not "it is already on 8106".
618
+ if (wantedPort && !typed) {
619
+ if (!(await answers(wantedPort))) return { port: null, said: "", why: `nothing is answering on port ${wantedPort}, so there is nothing to ask. Start your app, then run this again.`, noStart: true };
620
+ await useProxy();
621
+ say(`${UNSEEN} To see them, start it through Cortad instead: npx cortad ${code || "<code>"} -- <how you start your app>`);
622
+ keepStart({ port: wantedPort });
623
+ return { port: wantedPort, cmd: null };
726
624
  }
727
- appDir = plan?.cwd ?? root;
728
- pinned = pinnedNode();
729
- if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
730
625
  const sourceFiles = files.map((f) => join(root, f));
731
- const lifted = sessionLifts(limitsYes, envFiles, sourceFiles);
732
- const raised = Object.keys(lifted).filter((n) => /^\d+$/.test(lifted[n]));
733
- const opened = Object.keys(lifted).filter((n) => !raised.includes(n));
626
+ // Only its own request limits, raised for this session by name: who the app lets in is never touched.
627
+ const lifted = liftedLimits(sourceFiles);
628
+ const raised = Object.keys(lifted);
734
629
  if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
735
- // Said out loud, because it changes who their app lets in for as long as this command runs.
736
- if (opened.length) say(`for this session your app runs with ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
737
- launched = { cmd, lifted };
738
- if (proxyAsked || !hookable(appDir)) await useProxy();
739
- step(`starting your app: ${cmd}`);
630
+ launched = { cmd: typed, lifted };
631
+ if (proxyAsked || !hookable(appDir, typed)) await useProxy();
632
+ step(`starting your app with: ${shownCommand(typed, mask)}${fromSaved ? " (saved from your last connect)" : ""}`);
740
633
  const up = await launch(180_000);
741
- if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
742
- // Already running: a second start dies on the port the first one holds. Held by a process running
743
- // in this repository, started from the terminal the person was already working in, it is the app
744
- // and is used as it stands. "Another next dev server is already running" is the same fact in a
745
- // framework's own words, and the port it holds is not always the one asked for. A holder running
746
- // from any other folder is another program: it is named, never tested as this app.
747
- 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])))] : [];
748
- const holders = up.held ? [up.held] : (await Promise.all(ports.map(holderOf))).filter(Boolean);
749
- for (const held of holders) {
750
- if (!ownFolder(held.cwd)) return { port: null, said: up.tail, why: whyNot({ ...up, held }) };
751
- if (await answers(held.port)) {
752
- launched = null; child = null;
753
- await keeping.forget();
754
- const took = await takeOver(held.port);
755
- if (took) return took;
756
- say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
757
- return { port: held.port, cmd: null };
758
- }
634
+ if (up.port) {
635
+ keepStart({ cmd: typed, ...(wantedPort ? { port: wantedPort } : {}) });
636
+ // A container never saw the proxy's settings: the one line that makes its model calls visible is said here.
637
+ if (proxy && IN_CONTAINER.test(typed)) say(containerLine());
638
+ return { port: up.port, cmd: launched.cmd, lifted: raised };
759
639
  }
760
640
  if (up.tail) console.error(up.tail);
761
641
  return { port: null, said: up.tail, why: whyNot(up) };
762
642
  }
763
- // A start that failed because another process holds the port, in the words a framework uses.
764
- 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;
765
- const rootReal = realpathSync(root);
766
- const ownFolder = (dir) => { try { const at = realpathSync(dir); return at === rootReal || at.startsWith(rootReal + sep); } catch { return false; } };
767
643
  // Why a start did not come up, in a sentence that stands without the terminal: the runner's state
768
644
  // carries it to the verbs, and the screen gets the app's own words beside it.
769
645
  function whyNot(up) {
770
646
  const h = up.held;
771
- 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.`;
772
- const wrongNode = pinned.major && !pinned.bin ? ` This project pins Node ${pinned.major} and this shell runs Node ${shellNode}: switch to ${pinned.major}.` : "";
773
- 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.`;
774
- }
775
-
776
- // An app the person started keeps the limits it started with, and a run needs more than a person
777
- // uses in a month. With their yes, the command stops that app and starts it the way it would have,
778
- // limits raised for the session. Only in a terminal, only when the code reads a limit, and only
779
- // when the command knows how to start the app.
780
- async function takeOver(port) {
781
- const lifted = liftedLimits(envFiles, files.map((f) => join(root, f)));
782
- const names = Object.keys(lifted);
783
- if (!names.length || !process.stdin.isTTY) return null;
784
- const plan = startPlan({ root, typed: flag("--start"), onPath });
785
- if (!plan?.cmd) return null;
786
- const pid = await listenerOn(port);
787
- if (!pid) return null;
788
- const answer = await ask(`your app is running with its own request limits (${names.join(", ")}). Restart it with them raised for this session? [Y/n] `);
789
- if (answer && !/^y(?:es)?$/i.test(answer)) return null;
790
- const top = await supervisorOf(pid);
791
- step(`stopping your app (pid ${top}) to start it with higher limits`);
792
- await stopTree(top);
793
- for (let i = 0; i < 50 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
794
- if (await answers(port)) { say("your app did not stop, so it is used as it is"); return null; }
795
- appDir = plan.cwd ?? root;
796
- pinned = pinnedNode();
797
- if (plan.within) say(`your app is in ${plan.within}, started there with: ${plan.cmd}`);
798
- say(`higher request limits for this session: ${names.join(", ")}`);
799
- launched = { cmd: plan.cmd, lifted, byTerminal: true };
800
- step(`starting your app: ${plan.cmd}`);
801
- const up = await launch(180_000);
802
- if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
803
- if (up.tail) console.error(up.tail);
804
- return { port: null, said: up.tail, why: "your app did not come back after the restart." };
647
+ 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. Stop that program and save a file here, and your app is started again by itself; or run this command with --port ${h.port} to use the one running as it is.`;
648
+ return `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}. What it printed is above, and it is your app's own output. Fix it and save a file here: your app is started again by itself with the same command.`;
805
649
  }
806
650
 
807
651
  // What the running app was started from, noted in app.json for the verbs, which run in another
@@ -821,41 +665,15 @@ async function noteAttached(port) {
821
665
 
822
666
  // Your app, started the way you start it, and watched until one of its own ports answers.
823
667
  let launched = null;
824
- // The Node this project pins, when the shell that ran this command has another. Strapi refuses
825
- // Node 26 outright, and a version manager that never switched in this shell is the usual reason.
826
- // ponytail: reads .nvmrc and .node-version only; engines ranges when a project pins no other way.
827
- const shellNode = Number(process.versions.node.split(".")[0]);
828
- function pinnedNode() {
829
- let want = "";
830
- for (const f of [".nvmrc", ".node-version"].flatMap((n) => [join(appDir, n), join(root, n)])) { try { want = readFileSync(f, "utf8").trim(); } catch { /* not pinned here */ } if (want) break; }
831
- const major = Number(/^v?(\d+)/.exec(want)?.[1]);
832
- if (!major || major === shellNode) return { major: null, bin: null };
833
- const under = (dir, tail = "bin") => { try { return readdirSync(dir).filter((v) => v.replace(/^v/, "").startsWith(`${major}.`)).sort().reverse().map((v) => join(dir, v, tail)); } catch { return []; } };
834
- const bins = [`/opt/homebrew/opt/node@${major}/bin`, `/usr/local/opt/node@${major}/bin`, ...under(join(homedir(), ".nvm/versions/node")), ...under(join(homedir(), ".local/share/fnm/node-versions"), "installation/bin"), ...under(join(homedir(), ".volta/tools/image/node"))];
835
- return { major, bin: bins.find((d) => existsSync(join(d, "node"))) ?? null };
836
- }
837
- let pinned = { major: null, bin: null };
838
668
 
839
- // Their app could not start because its dependencies are not on this machine. That is an install
840
- // nobody ran, not a broken app: it is installed once, with the manager the project locked, and the
841
- // app is started again. So is a store or service it reaches that nothing runs yet (backUp). Every
842
- // way the app is started comes through here, so it is fixed in one place.
669
+ // Every way the app is started comes through here. An app the hook could not load into (another
670
+ // runtime, a container) is started again with its model settings pointed at the proxy, so its calls
671
+ // are still seen. Nothing else is fixed for the app: a dependency it misses, a service it reaches for
672
+ // and finds down, a port it cannot bind, are its own to report, and they are reported in its own words.
843
673
  async function launch(waitMs) {
844
- let up = await start(waitMs);
845
- if (up.port && !proxy && !(await hookedAt(up.port))) up = await startProxied(up, waitMs);
846
- // Only an app that stopped: one still running has not failed to start, whatever it printed.
847
- if (up.port || up.exited === null) return up;
848
- if (await keeping.backUp(app?.port).finally(release)) {
849
- up = await start(waitMs);
850
- if (up.port || up.exited === null) return up;
851
- }
852
- const ok = await installOnce(up.tail);
853
- // An install that failed is the reason their app cannot start, and it goes where the app's own
854
- // output goes: the terminal, and the screen that is waiting for the app.
855
- const said = (tail) => (installSaid ? `${installSaid}\n${tail}` : tail);
856
- if (!ok) return installSaid ? { ...up, tail: said(up.tail) } : up;
857
- const back = await start(waitMs);
858
- return back.port ? back : { ...back, tail: said(back.tail) };
674
+ const up = await start(waitMs);
675
+ if (up.port && !proxy && !(await hookedAt(up.port))) return startProxied(up, waitMs);
676
+ return up;
859
677
  }
860
678
  // Whether the hook loaded into the process answering on `port`. A machine that cannot name that
861
679
  // process leaves the app as it is.
@@ -866,122 +684,53 @@ async function useProxy() {
866
684
  const noted = () => { if (became?.state === "up") become("up", { ...became.fields, ...upAt(became.fields.proxied) }); };
867
685
  // A first model call seen through it is said to the run, for an app this command did not start.
868
686
  proxy = await openProxy({
687
+ // The same model port on every connect of this folder: a container's settings name it once.
688
+ modelPort: Number(keptStart.modelPort) || 0,
869
689
  file: capture.file, rulesFile: capture.rulesFile, target: () => ({ host: appHost, port: app?.port }),
870
690
  onSeen: () => { forgetTold?.(); noted(); },
871
691
  onUnseen: (door) => { unseen = door; noted(); say(unseenText(door)); },
872
692
  });
693
+ keepStart({ modelPort: proxy.modelPort });
873
694
  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");
874
695
  }
875
696
  // An app run in a container: the proxy's settings never reach it, and 127.0.0.1 inside it is not this machine.
697
+ const containerLine = () => {
698
+ // Only the settings the app's own environment names, else the OpenAI one: every SDK's would be a list nobody reads.
699
+ const hints = modelHints();
700
+ const handed = proxy.env(hints);
701
+ const named = Object.keys(handed).filter((name) => name in hints);
702
+ const settings = (named.length ? named : ["OPENAI_BASE_URL"]).map((name) => `${name}=${handed[name].replace("127.0.0.1", "host.docker.internal")}`);
703
+ return `your app runs in a container, where Cortad's hook cannot load: its replies are seen through the proxy, its model calls are not. To see them, give the container ${settings.length === 1 ? "this setting" : "these settings"} and start it again with this command (the same port on every connect of this folder): ${settings.join(", ")}`;
704
+ };
876
705
  const IN_CONTAINER = /^\s*(?:sudo\s+)?(?:docker|podman)(?:-compose)?\b/;
877
706
  // The app answered, and the hook is not in it: it is started again with its model settings on the
878
707
  // proxy. One in a container would come back the same, so it is left up, seen at the front alone.
879
708
  async function startProxied(up, waitMs) {
880
709
  await useProxy();
881
- if (IN_CONTAINER.test(launched?.cmd ?? "")) return up;
710
+ if (IN_CONTAINER.test(launched?.cmd ?? "")) {
711
+ say(containerLine());
712
+ return up;
713
+ }
882
714
  await stopTree(child.pid);
883
715
  for (let i = 0; i < 75 && (await listenerOn(up.port)); i++) await new Promise((r) => setTimeout(r, 200));
884
716
  return start(waitMs);
885
717
  }
886
- let installed = false;
887
- let installSaid = "";
888
- async function installOnce(said) {
889
- if (installed) return false;
890
- const name = missingDependency(said);
891
- const cmd = name && installPlan(appDir, onPath, root);
892
- if (!cmd) return false;
893
- installed = true;
894
- stepDone(`your app needs ${name}, which is not installed here`);
895
- const started = Date.now();
896
- const secondsSoFar = () => Math.round((Date.now() - started) / 1000);
897
- // A line that rewrites itself in a terminal, and every tenth second where there is none.
898
- const tick = () => { const n = secondsSoFar(); const line = `installing your app's dependencies, ${n} seconds so far`; if (process.stdout.isTTY) step(line); else if (n % 10 === 0) say(line); };
899
- tick();
900
- const ticking = setInterval(tick, 1000);
901
- const done = await new Promise((r) => {
902
- const child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, CI: "1" }, stdio: ["ignore", "pipe", "pipe"] });
903
- let tail = "";
904
- const keep = (log) => (d) => { tail = (tail + d.toString()).slice(-4000); log(d); if (verbose) process.stdout.write(d.toString()); };
905
- child.stdout.on("data", keep(logTo()));
906
- child.stderr.on("data", keep(logTo()));
907
- const timer = setTimeout(() => child.kill("SIGKILL"), 15 * 60_000);
908
- child.on("close", (code) => { clearTimeout(timer); r({ ok: code === 0, tail }); });
909
- child.on("error", (e) => { clearTimeout(timer); r({ ok: false, tail: String(e.message) }); });
910
- });
911
- clearInterval(ticking);
912
- if (!done.ok) { installSaid = `installing your app's dependencies failed after ${secondsSoFar()} seconds:\n${done.tail.split("\n").filter(Boolean).slice(-12).join("\n")}`; lastSaid = done.tail; stepDone(`installing your app's dependencies failed after ${secondsSoFar()} seconds`); return false; }
913
- stepDone(`installed your app's dependencies in ${secondsSoFar()} seconds`);
914
- // A Python project that had no interpreter of its own has one now, and it is the one to start with.
915
- const again = startPlan({ root, typed: flag("--start"), onPath });
916
- if (again?.cmd && again.cmd !== launched.cmd) { launched = { ...launched, cmd: again.cmd }; say(`starting your app: ${again.cmd}`); }
917
- return true;
918
- }
919
-
920
- // The port their start command names, moved when something else already holds it: two apps on one
921
- // laptop both said --port 8000, and the second attached to the first's server as its own.
922
- // Ports this program tried and lost: two connects on one machine can pick the same free port in
923
- // the same second, and the one that binds second sees "address already in use".
924
- const lost = new Set();
925
- async function freePortAbove(port) {
926
- for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
927
- return null;
928
- }
929
- const PORT_FLAG = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/;
930
- // The port a start names: its command's flag, or PORT as this command sets it or an env file does.
931
- const namedPort = (cmd, lifted) => Number(PORT_FLAG.exec(cmd)?.[2] ?? lifted.PORT ?? envExports(envFiles).PORT ?? 0) || 0;
932
- async function freed(cmd, lifted) {
933
- const m = PORT_FLAG.exec(cmd);
934
- // The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
935
- // on that port was reported as "your app stopped" instead of moved past.
936
- const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(envExports(envFiles).PORT) || 0;
937
- if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
938
- const port = await freePortAbove(named);
939
- if (!port) return { cmd, lifted };
940
- say(`port ${named} is taken on this machine, so your app starts on ${port}`);
941
- // The same port their env file names, moved the same way.
942
- return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
943
- }
944
718
 
945
- // Your app's data, read once per session: copies made before the first start, and every restart
946
- // after it runs against the same copies, so a crash mid-run does not lose what the run made. What
947
- // was copied, started, or left where trials write into it is told in the terminal here and to the
948
- // run with the app (announce), so status says it beside Run and the report keeps it.
949
- const keeping = makeKeeping({
950
- root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
951
- launch: (plan) => sidecar(plan.cmd, plan.cwd),
952
- connections: () => capture?.connections() ?? [],
953
- copies: () => capture?.copies() ?? [],
954
- settings: () => {
955
- const from = {};
956
- for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
957
- let text = "";
958
- try { text = readFileSync(file, "utf8"); } catch { continue; }
959
- 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; }
960
- }
961
- return { values: { ...envExports(envFiles), ...process.env }, from, appDir, sources: files.map((f) => join(root, f)) };
962
- },
963
- });
964
-
965
- async function start(waitMs, tries = 3) {
966
- const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
719
+ async function start(waitMs) {
720
+ const { cmd, lifted } = launched;
967
721
  // The record of what the app writes opens with the app: the first run starts itself on the
968
722
  // server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
969
723
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
970
724
  toolsAtStart = toolsNow();
971
725
  // Taken before the app reads a file: a save after this moment is a change it has not loaded.
972
726
  const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
973
- // The settings files count as what it loaded: an env file is rarely tracked, and an agent's edit to
974
- // one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
975
- note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
976
- storesAt ??= storesNamed();
727
+ note.files = sourceOf(root, files);
977
728
  loaded = note;
978
- const copies = await keeping.env(true);
979
- await allowOutbound();
980
- // A start command the person typed says which databases the app uses: the store settings of their
981
- // settings files are left for the app to load, since handed over here they outrank the
982
- // environment that command chose, and no copy is pointed at.
983
- const base = flag("--start") ? { ...withoutStoreSettings(envExports(envFiles)), ...process.env, ...lifted } : { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
984
- 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 ?? ""}` } : {}) } });
729
+ // The app gets this terminal's environment, as a start by hand would, its own request limits raised
730
+ // for the session, and the hook. No env file is read into it: its own loader reads its own files, in
731
+ // its own order, which is the only way a test file's placeholder key never outranks the real one.
732
+ const base = { ...process.env, ...lifted };
733
+ child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(modelHints()) : capture.env(base)), FORCE_COLOR: "0" } });
985
734
  const mine = child;
986
735
  become("starting", { app: mine.pid ?? null });
987
736
  let seen = "";
@@ -1008,13 +757,10 @@ async function start(waitMs, tries = 3) {
1008
757
  if (taken || /app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running/i.test(seen)) {
1009
758
  await stopTree(mine.pid);
1010
759
  if (!taken) return { port: null, exited: 1, tail: tail() };
1011
- // Taken between the check and the bind: a port this start names moves to the next free one, a
1012
- // bounded number of times. A port the app sets in its own code does not follow it, and starting
1013
- // the app again only meets the same holder, so the holder is named instead.
1014
- const named = namedPort(cmd, lifted);
1015
- const port = portInError(seen) || named;
1016
- if (named && port === named && tries > 1) { lost.add(named); return start(waitMs, tries - 1); }
1017
- return { port: null, exited: 1, tail: tail(), held: port ? await holderOf(port) : null, fixed: Boolean(port) && port !== named };
760
+ // The port your command names is held by another program: it is named, and your command is
761
+ // never rewritten to another port.
762
+ const port = portInError(seen);
763
+ return { port: null, exited: 1, tail: tail(), held: port ? await holderOf(port) : null };
1018
764
  }
1019
765
  // The port is what the app's own process group listens on. Never a guess: a developer's
1020
766
  // machine has other things on 3000 and 8080, and one of them answered for the app once.
@@ -1022,7 +768,7 @@ async function start(waitMs, tries = 3) {
1022
768
  // The socket can open before the server answers; the log line is the tiebreak among several.
1023
769
  const plain = withoutStores(seen.replace(/\x1b\[[0-9;]*m/g, ""));
1024
770
  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]));
1025
- for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
771
+ for (const port of [...new Set([...(wantedPort ? [wantedPort] : []), ...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
1026
772
  if (await answers(port)) {
1027
773
  note.answered = true;
1028
774
  if (loaded === note) noteApp();
@@ -1036,7 +782,6 @@ async function start(waitMs, tries = 3) {
1036
782
  return { port: null, exited: null, tail: seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-12).join("\n") };
1037
783
  }
1038
784
 
1039
- const COPIED = "starting your app again so it runs against the copies made for this session";
1040
785
  // A restart waits this long for the run's requests already at your app, then goes ahead: one it
1041
786
  // cuts is sent again once your app answers.
1042
787
  const DRAIN_MS = 20_000;
@@ -1085,23 +830,11 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
1085
830
  }).changed.length > 0;
1086
831
  // Every save that changes what your app runs starts it again, when it does not reload by itself: an
1087
832
  // agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
1088
- // at again after each restart, for a save made while it was under way.
1089
- // The stores the settings name, as copies are made of them: a change here is a change of what the app
1090
- // writes into, so its copies and holds are made again for the new settings, and said again.
1091
- 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 ""; } };
1092
- let storesAt = null;
1093
- // Looked at before the first wait too: the app now starts while the code is uploaded, and a save made
1094
- // before this watch began was otherwise never seen.
833
+ // at again after each restart, for a save made while it was under way, and before the first wait too:
834
+ // the app starts while the code is uploaded, and a save made before this watch began was otherwise never seen.
1095
835
  async function restartOnSave() {
1096
836
  while (!closing) {
1097
- while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) {
1098
- const named = storesNamed();
1099
- const moved = storesAt !== null && named !== storesAt;
1100
- storesAt = named;
1101
- if (moved) await keeping.forget().catch(() => {});
1102
- await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
1103
- if (moved) await announce().catch(() => null);
1104
- }
837
+ while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) await restartApp();
1105
838
  await sourceChanged();
1106
839
  }
1107
840
  }
@@ -1119,13 +852,14 @@ async function restartNow() {
1119
852
  return { error };
1120
853
  }
1121
854
  become("up", { ...upAt(up.port), app: child?.pid ?? null });
1122
- if (up.port !== port) {
1123
- // It moved anyway: the world follows the app, never the other way round.
1124
- app = up;
1125
- const told = await announce().catch(() => null);
1126
- if (!told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
1127
- say(`your app came back on port ${up.port}; the run follows it there`);
1128
- }
855
+ // Said to the server every time, on the same port too: what its endpoints' test messages met before
856
+ // the restart is not what they meet now, and a key fixed in the env file left Run held on the old
857
+ // refusal. On another port the world follows the app, never the other way round.
858
+ const moved = up.port !== port;
859
+ if (moved) app = up;
860
+ const told = await announce().catch(() => null);
861
+ if (moved && !told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
862
+ if (moved) say(`your app came back on port ${up.port}; the run follows it there`);
1129
863
  return { restarted: true, port: up.port };
1130
864
  }
1131
865
  const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
@@ -1141,10 +875,6 @@ async function close(code = 0) {
1141
875
  try {
1142
876
  if (child?.pid) await stopTree(child.pid);
1143
877
  proxy?.close();
1144
- // A copy lives on its store, and a store can be a service of the repository started beside the
1145
- // app: the copies go first, while it still answers.
1146
- await keeping.close();
1147
- for (const kid of sidecars) if (kid.pid) await stopTree(kid.pid);
1148
878
  // A sign-in the app printed before its request reached this command is masked now. Only by the
1149
879
  // process that writes the file: another may still be appending to it.
1150
880
  if (readRunner(project)?.pid === process.pid) {
@@ -1175,24 +905,22 @@ if (explain) {
1175
905
  const going = new Set([...files, ...kept]);
1176
906
  const left = listed ? [...listed].filter((f) => !going.has(f) && !ENV_FILE.test(basename(f))).sort() : [];
1177
907
  const bytes = files.reduce((n, f) => { try { return n + statSync(join(root, f)).size; } catch { return n; } }, 0);
1178
- const plan = startPlan({ root, typed: flag("--start"), onPath });
1179
908
  const rel = (f) => relative(root, f) || ".";
1180
909
  console.log([
1181
910
  `cortad --explain (nothing is sent or started by this)`,
1182
911
  ``,
1183
- `talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
912
+ `talks to ${origin.origin}, and localhost on your app's port only`,
1184
913
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
1185
914
  ...(kept.length ? [`kept here ${kept.length} file${kept.length === 1 ? " that holds" : "s that hold"} keys: ${kept.slice(0, 6).join(", ")}${kept.length > 6 ? ", ..." : ""}`] : []),
1186
915
  ...(left.length ? [`also not sent ${left.length} tracked media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
1187
- `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
1188
- `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`,
1189
- `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"}`,
1190
- `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)`}`,
916
+ `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules`,
917
+ `env files read here only, to hide their values in what leaves and to know where your app's model settings point; nothing in them is handed to your app or sent`,
918
+ `would start ${wantedPort ? `nothing: uses your app on port ${wantedPort}, replies seen and model calls not` : typed ? `${typed} (your command, as typed, in ${rel(appDir)})` : "nothing: run again with your start command after --"}`,
919
+ `would raise ${wantedPort || !typed ? "nothing" : `${Object.keys(liftedLimits(files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (your app's own limits, for this session only; who it lets in is never touched)`}`,
1191
920
  `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`,
1192
921
  `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`,
1193
922
  `your files never written by this program; your own coding agent edits them`,
1194
- `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`,
1195
- `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`,
923
+ `your data nothing is copied, started or pointed elsewhere: your app uses the databases and services your command gives it, and test conversations write where it writes, after your yes on the card`,
1196
924
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
1197
925
  `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`,
1198
926
  ``,
@@ -1202,7 +930,6 @@ if (explain) {
1202
930
  }
1203
931
  if (kept.length) say(`left on this machine because it holds a key: ${kept.join(", ")}`);
1204
932
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
1205
- identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
1206
933
  // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
1207
934
  // masked like everything else, and nothing goes up that the mask could not read; the sign-in each
1208
935
  // request carried stays here.
@@ -1252,8 +979,8 @@ const treeDigest = (() => {
1252
979
  }
1253
980
  return h.digest("hex");
1254
981
  })();
1255
- // The commit this tree is. Your .git is never uploaded -- nothing of your history leaves this
1256
- // machine -- so the one sha the report needs is read here and sent as forty characters. A folder
982
+ // The commit this tree is. Your .git is never uploaded; the one sha the report needs is read here
983
+ // and sent as forty characters, and the commits themselves follow the code (sendHistory). A folder
1257
984
  // that is not a checkout sends nothing, and the report says so rather than inventing one.
1258
985
  const head = (() => {
1259
986
  try {
@@ -1273,25 +1000,51 @@ const PART = 1_500_000;
1273
1000
  let resumed = false;
1274
1001
  let moved = false;
1275
1002
  const parts = Math.max(1, Math.ceil(bytes.length / PART));
1276
- for (let off = 0; off < bytes.length; off += PART) {
1277
- const last = off + PART >= bytes.length;
1278
- step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
1279
- // The last part asks for this machine's key when none is stored yet: minted once the repository
1280
- // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
1281
- // code asks too, since the code may be another account's: the key kept from the account before
1282
- // signed the coding agent's tools into that one, its runs and its counts.
1283
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
1284
- { raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
1285
- if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
1286
- if (last) {
1287
- resumed = put.data?.resumed === true;
1288
- moved = put.data?.moved === true;
1289
- if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
1290
- writeDigest(project, treeDigest);
1003
+ // The whole upload, part by part. A part that did not land is the upload's to send again, from its
1004
+ // first part, once, a few seconds later: a part numbered 0 begins the archive afresh on the server, so
1005
+ // a second pass never lands on what the first left. A refusal of this machine's key is not retried.
1006
+ async function sendTree() {
1007
+ for (let off = 0; off < bytes.length; off += PART) {
1008
+ const last = off + PART >= bytes.length;
1009
+ step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
1010
+ // The last part asks for this machine's key when none is stored yet: minted once the repository
1011
+ // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
1012
+ // code asks too, since the code may be another account's: the key kept from the account before
1013
+ // signed the coding agent's tools into that one, its runs and its counts.
1014
+ const put = await call("PUT", `/local/${box}/tree?part=${Math.floor(off / PART)}&last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
1015
+ { raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch((err) => ({ ok: false, status: 0, err }));
1016
+ if (!put.ok) return put;
1017
+ if (last) {
1018
+ resumed = put.data?.resumed === true;
1019
+ moved = put.data?.moved === true;
1020
+ if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
1021
+ writeDigest(project, treeDigest);
1022
+ }
1023
+ }
1024
+ return { ok: true };
1025
+ }
1026
+ let sent = await sendTree();
1027
+ if (!sent.ok && sent.status !== 401 && sent.status !== 404) {
1028
+ await new Promise((r) => setTimeout(r, 2000 + Math.random() * 3000));
1029
+ sent = await sendTree();
1030
+ }
1031
+ if (!sent.ok) sent.err ? unreachable(sent.err) : fail(sent.data?.error ?? `upload failed (${sent.status})`);
1032
+ // Their commit history follows the code: the commits newer than the ones Cortad holds, the whole
1033
+ // history the first time, each diff cut to the files above and to lines that carry no key
1034
+ // (lib/commits.mjs). It goes on after the connect is reported and never stops it.
1035
+ async function sendHistory() {
1036
+ const last = await call("GET", `/local/${box}/commits/last`);
1037
+ if (!last.ok) return;
1038
+ const keeps = (rel) => { const parts = rel.split("/"); return !parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) && !SKIP_FILE.test(parts.at(-1)) && !ENV_FILE.test(parts.at(-1)) && !kept.includes(rel); };
1039
+ const parts = historyParts(root, typeof last.data?.sha === "string" ? last.data.sha : null, { keep: keeps, holdsKey: (line) => holdsKeys(line, envKeys) });
1040
+ for (const [i, commits] of parts.entries()) {
1041
+ const put = await call("PUT", `/local/${box}/commits`, { commits, ...(i === 0 ? { repo: repoFacts(root) } : {}) }, { timeoutMs: 120_000 });
1042
+ if (!put.ok) throw new Error(put.data?.error ?? `history upload failed (${put.status})`);
1291
1043
  }
1292
1044
  }
1293
1045
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
1294
1046
  stepDone(moved ? "connected: your change is in, and what Cortad read before still stands" : resumed ? "connected: nothing changed since last time" : "connected");
1047
+ if (head) void sendHistory().catch((err) => { if (verbose) say(`commit history not sent: ${err?.message ?? err}`); });
1295
1048
  // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
1296
1049
  // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
1297
1050
  // CORTAD_NO_REGISTER=1 leaves the coding agents' global skill and MCP entries alone: a stack under
@@ -1323,43 +1076,15 @@ function sourceChanged() {
1323
1076
  } catch { setTimeout(done, 15_000); }
1324
1077
  });
1325
1078
  }
1326
- // `watching` says whether a message sent to this app can be seen arriving: only an app this command
1327
- // started carries the hook, and only a runtime the hook exists for. `proves`: each request seen
1328
- // reaching the model is posted as its door's proof, so the run waits for those.
1329
- // `blocked`: each store off this machine no copy could be made of, which holds Run until the person
1330
- // says yes in the browser. The answer carries that yes (setup.consent), and the hosts it names are
1331
- // written where the hook reads which outbound writes of a trial may leave.
1079
+ // `proves`: each request seen reaching the model is posted as its door's proof, so the run waits for
1080
+ // those; false for an app seen through the proxy alone. `reached`: the servers the app has connected
1081
+ // to so far, by address, so the card can say where test conversations write before the person's yes.
1082
+ // `watching` and `metered` say the same as `proves` to a server from before this version.
1332
1083
  const announce = async () => {
1333
- // 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.
1334
1084
  const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
1335
- const started = Boolean(launched);
1336
- const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: shownCommand(app.cmd, mask), origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
1337
- if (told.ok) await allowOutbound(told.data?.consent);
1338
- return told;
1085
+ return call("POST", `/local/${box}/app`, { port: doorPort(), cmd: shownCommand(app.cmd, mask), lifted: app.lifted ?? [], watching: hooked, metered: hooked, proves: hooked, reached: reachedNow() });
1339
1086
  };
1340
-
1341
- // What the person said yes to, as the hook reads it: the hosts a trial's writes may reach.
1342
- async function allowOutbound(given) {
1343
- if (given && typeof given === "object") {
1344
- const list = (v, re) => (Array.isArray(v) ? v.filter((x) => typeof x === "string" && re.test(x)).slice(0, 50) : []);
1345
- consent = { hosts: list(given.hosts, /^[a-z0-9.-]{1,253}$/i).map((h) => h.toLowerCase()), stores: list(given.stores, /^[a-f0-9]{16}$/) };
1346
- await liftWith(given.limits === true);
1347
- }
1348
- try { writeFileSync(outboundFile, JSON.stringify({ pass: await keeping.outbound(consent) }), { mode: 0o600 }); } catch { /* every write of a trial stays held */ }
1349
- }
1350
-
1351
- // A yes given or taken back in the browser restarts an app this command started with its guards
1352
- // raised or restored; an app raised by the terminal's own yes keeps that.
1353
- async function liftWith(yes) {
1354
- if (yes === limitsYes) return;
1355
- limitsYes = yes;
1356
- if (!launched || !child || launched.byTerminal) return;
1357
- const lifted = sessionLifts(yes, envFiles, files.map((f) => join(root, f)));
1358
- if (JSON.stringify(lifted) === JSON.stringify(launched.lifted ?? {})) return;
1359
- launched = { ...launched, lifted };
1360
- if (app) app.lifted = Object.keys(lifted);
1361
- 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");
1362
- }
1087
+ const reachedNow = () => (capture?.connections() ?? []).map((c) => `${c.host}:${c.port}`).slice(0, 40);
1363
1088
 
1364
1089
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
1365
1090
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
@@ -1384,11 +1109,10 @@ async function appLife(firstStart) {
1384
1109
  machineOf = () => (app ? {
1385
1110
  cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1386
1111
  hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
1387
- changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
1388
- copies: Object.keys(keeping.envNow()).length ? "made" : "none",
1112
+ changed: [...(app.lifted ?? [])],
1389
1113
  } : null);
1390
1114
  become("up", { ...upAt(), app: launched ? child?.pid ?? null : null });
1391
- say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
1115
+ say(`your app is answering on port ${app.port}${app.cmd ? `, started with: ${shownCommand(app.cmd, mask)}` : ""}`);
1392
1116
  if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
1393
1117
  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`);
1394
1118
  for (const line of connectedText(machineOf())) say(line);
@@ -1396,16 +1120,7 @@ async function appLife(firstStart) {
1396
1120
  let downSince = 0;
1397
1121
  let toldDown = false;
1398
1122
  let appTold = true;
1399
- // The stores and services its settings name, brought up once the app's own port is known; a
1400
- // store copied only now is one the app was not started on.
1401
- if (launched) {
1402
- try {
1403
- const got = await keeping.services(app.port).catch(() => ({}));
1404
- if (got.copied && child) await restartApp(COPIED);
1405
- if (got.changed) appTold = false;
1406
- } finally { release(); }
1407
- }
1408
- // Only once the app runs on the copies made for this session.
1123
+ let reachedTold = reachedNow().join(",");
1409
1124
  void reachLoop();
1410
1125
  forgetTold = () => { appTold = false; };
1411
1126
  void restartOnSave().catch((e) => say(`could not watch your files for a save: ${e?.message ?? e}`));
@@ -1423,9 +1138,9 @@ async function appLife(firstStart) {
1423
1138
  const now = upAt();
1424
1139
  if (JSON.stringify(now.receipts) !== JSON.stringify(became.fields.receipts)) { const { receipts: _was, ...fields } = became.fields; become("up", { ...fields, ...now }); }
1425
1140
  }
1426
- const reach = await keeping.watch(app.port).catch(() => ({}));
1427
- try { if (reach.copied && launched && child) await restartApp(COPIED); } finally { release(); }
1428
- if (reach.changed) appTold = false;
1141
+ // A server the app reached for the first time is said on the card, where the person's yes is asked.
1142
+ const seen = reachedNow().join(",");
1143
+ if (seen !== reachedTold) { reachedTold = seen; appTold = false; }
1429
1144
  if (!up || !appTold) { up = true; appTold = Boolean((await announce().catch(() => null))?.ok); }
1430
1145
  continue;
1431
1146
  }
@@ -1447,7 +1162,7 @@ async function appLife(firstStart) {
1447
1162
  // load. An app it only attached to is not its to watch, so nothing is said either way.
1448
1163
  if (!toldDown) {
1449
1164
  // Started again below, except an app given by --port, which is picked up when it answers again.
1450
- if (!launched && flag("--port")) failedWith(`your app stopped answering on port ${app.port}; it is picked up again when it answers`, lastSaid);
1165
+ if (!launched && wantedPort) failedWith(`your app stopped answering on port ${app.port}; it is picked up again when it answers`, lastSaid);
1451
1166
  else become("starting");
1452
1167
  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);
1453
1168
  }
@@ -1457,7 +1172,7 @@ async function appLife(firstStart) {
1457
1172
  // terminal, or one left behind by a terminal that was killed) is gone now, and this command
1458
1173
  // knows how it starts. Only an app given by --port is left to you, and picked up when it returns.
1459
1174
  if (launched && !appGone && !crashed && downFor < 25_000) continue;
1460
- if (!launched && flag("--port")) continue;
1175
+ if (!launched && wantedPort) continue;
1461
1176
  await restartOnce(async () => {
1462
1177
  if (child?.pid) await stopTree(child.pid);
1463
1178
  say(downLine(state, { port: app.port, downMs: downFor, crashed }));
@@ -1485,14 +1200,6 @@ async function appLife(firstStart) {
1485
1200
  }
1486
1201
  void appLife(firstStart).catch((e) => quit(String(e?.message ?? e)));
1487
1202
 
1488
- // The page Cortad's test requests come from: one the env names on the app's port, else one on this
1489
- // machine (a local frontend in front of this API). A hosted address such as a Supabase URL is no page
1490
- // of the app's, so an app whose env names none is asked with no Origin, as a server would ask it.
1491
- const LOCAL_ORIGIN = /^https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/;
1492
- function ownOrigin(port) {
1493
- const local = envOrigins(envFiles).filter((o) => LOCAL_ORIGIN.test(o));
1494
- return local.find((o) => new URL(o).port === String(port)) ?? local[0] ?? null;
1495
- }
1496
1203
  // Counted with the run's own requests, so a restart drains them, and sent only through the open gate.
1497
1204
  async function countedFetch(url, init) {
1498
1205
  while (gate) await gate;
@@ -1503,9 +1210,9 @@ async function countedFetch(url, init) {
1503
1210
  return new Response(body.byteLength ? body : null, { status: res.status, statusText: res.statusText, headers: res.headers });
1504
1211
  } finally { answering -= 1; }
1505
1212
  }
1506
- // Cortad's own test request to each endpoint the read lists, sent from here as soon as status lists
1507
- // them (lib/auto-reach.mjs), and the agent's reach the same way. Status is asked every 5 seconds
1508
- // while the code is read, then every minute; an ask from the agent is taken within a second.
1213
+ // The agent's `reach`: a request Cortad builds from what it read, sent from here to the app on its
1214
+ // port, only when the agent asks for it (lib/auto-reach.mjs). Nothing is sent to the app on Cortad's
1215
+ // own account: the one request that proves an endpoint is the one the person or their agent sends.
1509
1216
  const autoReach = makeAutoReach({
1510
1217
  status: async () => {
1511
1218
  const token = readToken(project);
@@ -1513,11 +1220,10 @@ const autoReach = makeAutoReach({
1513
1220
  return res?.ok ? res.data : null;
1514
1221
  },
1515
1222
  runner: () => (became?.state === "up" && !restarting ? { state: "up", ...became.fields } : null),
1516
- // One test request per endpoint is what the person's own agent sends by hand, so it does not wait on
1517
- // the yes a run's writes into an uncopied store need; it waits only while a copy is being made.
1518
- waiting: async () => (gate ? "Your app's data is being copied for this session." : null),
1519
- origin: () => (app ? ownOrigin(app.port) : null),
1520
- signIn: async (m) => { const role = typeof m.as === "string" ? m.as : identities?.roles()[0]; return role ? identities.headerFor(role) : null; },
1223
+ waiting: async () => (gate ? "Your app is being started again." : null),
1224
+ origin: () => null,
1225
+ // Sent as the person's own request was, when one was seen: the sign-in it carried, kept here.
1226
+ signIn: async () => { const theirs = capture?.headers(CAPTURED); const name = theirs && Object.keys(theirs).find((k) => /^(?:authorization|cookie)$/i.test(k)); return name ? { name: name.toLowerCase(), value: theirs[name] } : null; },
1521
1227
  stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
1522
1228
  asked: () => existsSync(join(homeOf(project), ASK)),
1523
1229
  report: async (answers) => {
@@ -1531,15 +1237,14 @@ const autoReach = makeAutoReach({
1531
1237
  say,
1532
1238
  fetchImpl: countedFetch,
1533
1239
  });
1534
- // The connection the server last heard sends its test requests itself; a reattach is a new one.
1240
+ // The connection the server last heard what the agent's requests met; a reattach is a new one.
1535
1241
  let reachedBox = "";
1536
1242
  async function reachLoop() {
1537
- for (let dueAt = 0; !closing; await new Promise((r) => setTimeout(r, 1000))) {
1243
+ for (; !closing; await new Promise((r) => setTimeout(r, 1000))) {
1538
1244
  const ask = takeAsk(homeOf(project));
1539
- if (!ask && Date.now() < dueAt) continue;
1245
+ if (!ask) continue;
1540
1246
  const out = await autoReach(ask).catch(() => null);
1541
- if (ask) { try { answerAsk(homeOf(project), ask.id, out?.text ?? "Nothing was sent.\nnext: status"); } catch { /* the agent's wait runs out */ } }
1542
- dueAt = Date.now() + (out?.wait ?? 30_000);
1247
+ try { answerAsk(homeOf(project), ask.id, out?.text ?? "Nothing was sent.\nnext: status"); } catch { /* the agent's wait runs out */ }
1543
1248
  }
1544
1249
  }
1545
1250
 
@@ -1567,6 +1272,32 @@ process.on("SIGUSR2", () => {
1567
1272
  void restartApp();
1568
1273
  });
1569
1274
 
1275
+ // A job's result is work already done on this machine: sent until the server has it, through a dropped
1276
+ // network, for as long as the server can still be waiting on it (the bus's five-minute call). Sent once
1277
+ // and dropped on a failure, a reply the app gave during a thirty-second outage was lost and the run
1278
+ // waited out its turn. Any answer below 500 ends it: 404 is a server that no longer holds the job.
1279
+ async function deliver(id, out) {
1280
+ for (let wait = 1000, until = Date.now() + 300_000; Date.now() < until && !closing; wait = Math.min(wait * 2, 15_000)) {
1281
+ const res = await call("POST", `/local/${box}/jobs/${id}`, out).catch(() => null);
1282
+ if (res && res.status < 500) return;
1283
+ await new Promise((r) => setTimeout(r, wait / 2 + Math.random() * (wait / 2)));
1284
+ }
1285
+ }
1286
+
1287
+ // Asked again through a server that is still starting, a network that dropped or a rate limit every
1288
+ // terminal meets at once after a deploy, for as long as a restart can take: only the server saying it
1289
+ // does not know this terminal (404) ends the session. Ended on the first slow answer, a deploy that
1290
+ // restarted the server ended every session it held.
1291
+ async function reattach() {
1292
+ for (let wait = 2000, until = Date.now() + 300_000; ; wait = Math.min(wait * 2, 30_000)) {
1293
+ const timeoutMs = Math.max(5_000, Math.min(120_000, until - Date.now()));
1294
+ const back = await call("POST", "/local/reattach", { box, name: basename(root), project }, { timeoutMs }).catch(() => null);
1295
+ if (back && (back.ok || back.status === 404)) return back;
1296
+ if (Date.now() >= until || closing) return back;
1297
+ await new Promise((r) => setTimeout(r, wait / 2 + Math.random() * (wait / 2)));
1298
+ }
1299
+ }
1300
+
1570
1301
  let quiet = 0;
1571
1302
  // Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
1572
1303
  // drops, the lid closes): the server sends again whatever is not named, and a job seen twice is
@@ -1581,7 +1312,8 @@ for (;;) {
1581
1312
  if (res.status === 404) {
1582
1313
  // The server restarted and forgot this terminal. It is the same terminal: it proves so with the
1583
1314
  // key it was given, its box is opened again, and the app it is holding is announced again.
1584
- const back = await call("POST", "/local/reattach", { box, name: basename(root), project }).catch(() => null);
1315
+ const back = await reattach();
1316
+ if (closing) break;
1585
1317
  if (!back?.ok) { say("this session ended on the server. Run the command again for a new one."); await close(1); }
1586
1318
  if (app?.port) await announce().catch(() => {});
1587
1319
  forgetTold?.();
@@ -1596,6 +1328,6 @@ for (;;) {
1596
1328
  if (seenJobs.has(job.id)) continue;
1597
1329
  seenJobs.add(job.id);
1598
1330
  if (seenJobs.size > 2000) seenJobs.delete(seenJobs.values().next().value);
1599
- verb(job).catch((e) => ({ error: String(e.message) })).then((out) => call("POST", `/local/${box}/jobs/${job.id}`, out).catch(() => {}));
1331
+ verb(job).catch((e) => ({ error: String(e.message) })).then((out) => deliver(job.id, out));
1600
1332
  }
1601
1333
  }