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.
- package/README.md +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- 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 {
|
|
25
|
-
import {
|
|
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 {
|
|
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 {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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) ||
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
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:
|
|
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) => {
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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")) {
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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 =
|
|
585
|
-
const raised = Object.keys(
|
|
586
|
-
const opened = Object.keys(
|
|
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(`
|
|
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.
|
|
595
|
-
//
|
|
596
|
-
// "Another next dev server is already running" is the same fact in a
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
757
|
-
//
|
|
758
|
-
const
|
|
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 =
|
|
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
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
|
821
|
-
//
|
|
822
|
-
if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running
|
|
823
|
-
await
|
|
824
|
-
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
//
|
|
944
|
-
// masked like everything else; the sign-in
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
|
1080
|
-
if (
|
|
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
|
|
1091
|
-
//
|
|
1092
|
-
// A watcher that has said its app crashed is not
|
|
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
|
|
1095
|
-
if (
|
|
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)
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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(
|
|
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
|
|
1121
|
-
if (
|
|
1122
|
-
|
|
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
|
|
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()
|
|
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; }
|