cortad 0.2.2 → 0.3.0-rc.10
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 +11 -2
- package/lib/cli.mjs +90 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +32 -12
- package/lib/pyhook/sitecustomize.py +396 -11
- package/lib/read-text.mjs +91 -0
- package/lib/register.mjs +64 -30
- package/lib/replay.mjs +43 -5
- package/lib/spec.mjs +20 -0
- package/lib/start.mjs +10 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +268 -0
- package/lib/trace.cjs +303 -9
- package/lib/verbs.mjs +148 -103
- package/lib/words.mjs +37 -0
- package/local.mjs +58 -17
- package/package.json +6 -1
- package/skill/SKILL.md +75 -31
- package/skill/references/results.md +175 -34
package/lib/verbs.mjs
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath, sep } from "node:path";
|
|
3
|
+
import { SHOW, showText } from "./read-text.mjs";
|
|
4
|
+
import { fieldConnectText, fieldText, findingsText, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
|
|
5
|
+
|
|
6
|
+
// The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
|
|
7
|
+
// and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
|
|
8
|
+
// functions, so both faces say the same thing.
|
|
9
|
+
//
|
|
10
|
+
// Codex, Cursor CLI and Copilot CLI cut an MCP call at about 60 seconds. `run` and `verify` answer
|
|
11
|
+
// within a second; `run_status` holds for up to 45 seconds on the server and 58 in all.
|
|
12
|
+
|
|
13
|
+
const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person: sign in at cortad.com and run the command the connect screen shows, in this folder.";
|
|
14
|
+
const HOLD_S = 45;
|
|
15
|
+
// What the process that starts a run for an app that is still coming up allows it; lib/cli.mjs.
|
|
16
|
+
export const READY_MS = 240_000;
|
|
17
|
+
|
|
18
|
+
// `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
|
|
19
|
+
// startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
|
|
20
|
+
// the latest run at the time, so a newer run on the server outranks a stale file.
|
|
21
|
+
export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
22
|
+
const call = async (method, path, body, timeoutMs = 30_000) => {
|
|
9
23
|
const key = typeof token === "function" ? token() : token;
|
|
10
24
|
if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
|
|
11
25
|
let res;
|
|
@@ -14,10 +28,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
14
28
|
method,
|
|
15
29
|
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
16
30
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
17
|
-
signal: AbortSignal.timeout(
|
|
31
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
18
32
|
});
|
|
19
33
|
} catch (err) {
|
|
20
|
-
return { status: 0, ok: false, data: { error: `
|
|
34
|
+
return { status: 0, ok: false, data: { error: `Could not reach Cortad: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}.` } };
|
|
21
35
|
}
|
|
22
36
|
const text = await res.text();
|
|
23
37
|
let data = null;
|
|
@@ -25,37 +39,108 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
25
39
|
return { status: res.status, ok: res.ok, data };
|
|
26
40
|
};
|
|
27
41
|
const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
|
|
42
|
+
const iso = () => new Date(now()).toISOString();
|
|
43
|
+
|
|
44
|
+
// With show, one section of the read in full, paged; a name that is not a section is the plain status.
|
|
45
|
+
const status = async ({ show, page } = {}) => {
|
|
46
|
+
const section = SHOW.includes(show) ? show : null;
|
|
47
|
+
const res = await call("GET", `/mcp/status${section ? `?show=${section}` : ""}`);
|
|
48
|
+
if (!res.ok) return failed(res);
|
|
49
|
+
return { text: section ? showText(res.data, section, page) : statusText(res.data), data: res.data };
|
|
50
|
+
};
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
// Asks the server for the run and keeps its id here, so run_status finds it without one.
|
|
53
|
+
const post = async (args, kind) => {
|
|
54
|
+
const res = await call("POST", "/mcp/run", args);
|
|
55
|
+
if (res.status === 202 || (res.ok && res.data?.joined)) {
|
|
56
|
+
pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
|
|
57
|
+
return { text: startedText(res.data, kind, args.findingId), data: res.data };
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
60
|
+
if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
|
|
61
|
+
return failed(res);
|
|
32
62
|
};
|
|
33
63
|
|
|
34
|
-
//
|
|
35
|
-
// is
|
|
64
|
+
// An app that is up gets the run now. One that is not is started in the background, and the run
|
|
65
|
+
// is posted from there once it answers.
|
|
36
66
|
const start = async (args, kind) => {
|
|
37
67
|
const before = await call("GET", "/mcp/status");
|
|
38
68
|
if (!before.ok) return failed(before);
|
|
39
|
-
if (before.data.app?.state
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
if (before.data.app?.state === "ready-to-test") return post(args, kind);
|
|
70
|
+
pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
|
|
71
|
+
const began = await startApp(args, kind);
|
|
72
|
+
if (!began.ok) {
|
|
73
|
+
pending.write({ kind, startedAt: iso(), error: began.why });
|
|
74
|
+
return { text: began.why, isError: true };
|
|
42
75
|
}
|
|
43
|
-
|
|
44
|
-
if (res.status === 202 || (res.ok && res.data?.joined)) return { text: startedText(res.data, kind), data: res.data };
|
|
45
|
-
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
46
|
-
return failed(res);
|
|
76
|
+
return { text: STARTING, data: { pending: true } };
|
|
47
77
|
};
|
|
48
78
|
const run = () => start({}, "run");
|
|
49
|
-
const verify = ({ findingId, jobId }) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
|
|
79
|
+
const verify = ({ findingId, jobId } = {}) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
|
|
80
|
+
|
|
81
|
+
// With no id: the run this machine is still starting, held here until it has an id, or else the
|
|
82
|
+
// latest run on the server.
|
|
83
|
+
const resolve = async (until) => {
|
|
84
|
+
const latest = await call("GET", "/mcp/status");
|
|
85
|
+
if (!latest.ok) return failed(latest);
|
|
86
|
+
const newest = latest.data.run?.jobId ?? null;
|
|
87
|
+
let p = pending.read();
|
|
88
|
+
if (!p || p.jobId || (p.after ?? null) !== newest) return newest ? { id: newest } : { text: "No run yet.\nnext: status" };
|
|
89
|
+
const stale = () => now() - Date.parse(p.startedAt) > READY_MS + 30_000;
|
|
90
|
+
while (!p.jobId && !p.error && !stale() && now() < until) {
|
|
91
|
+
await sleep(1000);
|
|
92
|
+
p = pending.read() ?? p;
|
|
93
|
+
}
|
|
94
|
+
if (p.jobId) return { id: p.jobId };
|
|
95
|
+
if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
|
|
96
|
+
if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
|
|
97
|
+
return { text: waitingText(p, now(), READY_MS) };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const runStatus = async ({ jobId } = {}) => {
|
|
101
|
+
const began = now();
|
|
102
|
+
let id = jobId && jobId !== "pending" ? String(jobId) : null;
|
|
103
|
+
if (!id) {
|
|
104
|
+
const found = await resolve(began + HOLD_S * 1000);
|
|
105
|
+
if (!found.id) return found;
|
|
106
|
+
id = found.id;
|
|
107
|
+
}
|
|
108
|
+
const hold = Math.max(0, HOLD_S - Math.ceil((now() - began) / 1000));
|
|
109
|
+
const res = await call("GET", `/mcp/run/${encodeURIComponent(id)}${hold ? `?wait=${hold}` : ""}`, undefined, (hold + 13) * 1000);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const out = failed(res);
|
|
112
|
+
return { ...out, text: `${out.text}\nnext: run_status ${id}` };
|
|
113
|
+
}
|
|
114
|
+
return { text: runText(res.data), data: res.data };
|
|
115
|
+
};
|
|
50
116
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
117
|
+
// Every server page of a run's findings, so the pages the agent reads are cut by size here.
|
|
118
|
+
const gather = async (jobId) => {
|
|
119
|
+
const path = (page, id) => {
|
|
120
|
+
const q = new URLSearchParams({ ...(id ? { jobId: id } : {}), ...(page > 1 ? { page: String(page) } : {}) }).toString();
|
|
121
|
+
return `/mcp/findings${q ? `?${q}` : ""}`;
|
|
122
|
+
};
|
|
123
|
+
const first = await call("GET", path(1, jobId));
|
|
124
|
+
if (!first.ok) return first;
|
|
125
|
+
const d = first.data;
|
|
126
|
+
for (let page = 2; page <= Math.min(d.pages ?? 1, 20); page += 1) {
|
|
127
|
+
const more = await call("GET", path(page, d.runId ?? jobId));
|
|
128
|
+
if (!more.ok) return more;
|
|
129
|
+
d.findings = [...(d.findings ?? []), ...(more.data.findings ?? [])];
|
|
130
|
+
// Every page carries the whole byLine; merged by line with the ids joined, or the first
|
|
131
|
+
// page's groups print twice.
|
|
132
|
+
for (const g of more.data.byLine ?? []) {
|
|
133
|
+
const held = (d.byLine ?? []).find((h) => h.path === g.path && h.line === g.line);
|
|
134
|
+
if (held) held.findingIds = [...new Set([...held.findingIds, ...g.findingIds])];
|
|
135
|
+
else d.byLine = [...(d.byLine ?? []), g];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return first;
|
|
54
139
|
};
|
|
55
140
|
|
|
56
|
-
const findings = async ({ jobId } = {}) => {
|
|
57
|
-
const res = await
|
|
58
|
-
return res.ok ? { text: findingsText(res.data), data: res.data } : failed(res);
|
|
141
|
+
const findings = async ({ jobId, page } = {}) => {
|
|
142
|
+
const res = await gather(jobId);
|
|
143
|
+
return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
|
|
59
144
|
};
|
|
60
145
|
|
|
61
146
|
const dispute = async ({ findingId, why, question, jobId }) => {
|
|
@@ -65,9 +150,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
65
150
|
|
|
66
151
|
const fieldConnect = async () => {
|
|
67
152
|
const res = await call("POST", "/mcp/field/connect");
|
|
68
|
-
|
|
69
|
-
const d = res.data;
|
|
70
|
-
return { text: [d.connected ? "Production is connected." : "Production is not connected yet.", ...d.steps.map((s, i) => `${i + 1}. ${s}`)].join("\n"), data: d };
|
|
153
|
+
return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
|
|
71
154
|
};
|
|
72
155
|
|
|
73
156
|
const field = async ({ days } = {}) => {
|
|
@@ -75,78 +158,40 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
75
158
|
return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
|
|
76
159
|
};
|
|
77
160
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function startedText(d, kind) {
|
|
97
|
-
if (d.joined) return `A ${d.kind ?? kind} is already in flight: ${d.jobId}. Poll run_status every 30 seconds. Watch it: ${d.url}`;
|
|
98
|
-
return `${kind === "verify" ? "Verify" : "Run"} started: ${d.jobId}. Poll run_status every 30 seconds and stay quiet unless the count moved. Watch it: ${d.url}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function refusedText(d) {
|
|
102
|
-
const plans = (d.plans ?? []).map((p) => `${p.name} ($${p.monthlyUsd}/month)`).join(" or ");
|
|
103
|
-
if (d.refused === "plan") return `The first run was free. Another needs ${plans || "a plan"}: ${d.checkout}\nShow this link to the person in one sentence and wait for them.`;
|
|
104
|
-
return `${d.why} More on ${plans || "a bigger plan"}: ${d.checkout}\nShow this link to the person and wait.`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function runText(d) {
|
|
108
|
-
const head = `${d.kind} ${d.jobId} ${d.status}: ${d.of ? `played ${d.played} of ${d.of}.` : "starting, nothing played yet."}`;
|
|
109
|
-
const tail = [];
|
|
110
|
-
if (d.finished && typeof d.score === "number") tail.push(`Score ${d.score} of 100.`);
|
|
111
|
-
if (d.finished && typeof d.findings === "number") tail.push(d.findings ? `${plural(d.findings, "finding")}; call findings.` : "No findings stood out.");
|
|
112
|
-
if (d.stopped) tail.push(`Stopped after ${d.stopped.after} of ${d.of}: ${d.stopped.why}${d.stopped.side === "theirs" ? " (their side)" : d.stopped.side === "ours" ? " (Cortad's side)" : ""}. ${d.stopped.fix}`);
|
|
113
|
-
for (const f of d.faults ?? []) tail.push(`${f.what} ${f.fix}`);
|
|
114
|
-
if (d.error) tail.push(`Error: ${d.error}`);
|
|
115
|
-
if (d.verify) tail.push(verifyText(d.verify));
|
|
116
|
-
if (!d.finished) tail.push("Poll again in 30 seconds.");
|
|
117
|
-
return [head, ...tail, d.url].join(" ");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function verifyText(v) {
|
|
121
|
-
const move = v.visible;
|
|
122
|
-
const line = move
|
|
123
|
-
? `${v.file}:${v.line} · held ${move.before.k} of ${move.before.n} before, ${move.after.k} of ${move.after.n} after · move ${move.point} (${move.low} to ${move.high}) · ${move.moved}${move.insideNoise ? ", inside the noise" : ""}.`
|
|
124
|
-
: "";
|
|
125
|
-
const held = v.holdout ? ` Held-out situations: ${v.holdout.moved}.` : " Held-out situations: no pair.";
|
|
126
|
-
return `Verify of ${v.findingId}: ${line}${held}${v.overfit ? " OVERFIT: the visible cases moved and the held-out ones did not." : ""} ${v.said}`;
|
|
127
|
-
}
|
|
161
|
+
// `status --changed`: the files Cortad cites that changed since the latest run started, with the
|
|
162
|
+
// findings at them. Empty when there is nothing to say, or nothing could be asked.
|
|
163
|
+
const changed = async () => {
|
|
164
|
+
const [s, f] = await Promise.all([call("GET", "/mcp/status"), gather()]);
|
|
165
|
+
const r = s.ok ? s.data?.run : null;
|
|
166
|
+
if (!r) return { text: "" };
|
|
167
|
+
const kept = pending.read();
|
|
168
|
+
const since = r.startedAt ?? (kept?.jobId === r.jobId ? kept.startedAt : null);
|
|
169
|
+
const lines = f.ok ? linesOf(f.data) : [];
|
|
170
|
+
const read = s.data.read ?? {};
|
|
171
|
+
const paths = [
|
|
172
|
+
...lines.map((l) => l.path),
|
|
173
|
+
...(read.rules?.examples ?? []).map((e) => e.path),
|
|
174
|
+
...(read.machine?.misses ?? []).map((m) => m.path),
|
|
175
|
+
...(Array.isArray(read.rules?.files) ? read.rules.files : []),
|
|
176
|
+
].filter((p) => typeof p === "string" && p);
|
|
177
|
+
return { text: changedLine({ root, since, runId: r.jobId, paths, lines }) };
|
|
178
|
+
};
|
|
128
179
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const rows = d.findings.map((f, i) => [
|
|
132
|
-
`${i + 1}. ${f.id} · ${f.asks}`,
|
|
133
|
-
` held ${f.rate.k} of ${f.rate.n} (${pct(f.rate.k, f.rate.n)}, interval ${pct(Math.round(f.rate.lo * f.rate.n), f.rate.n)} to ${pct(Math.round(f.rate.hi * f.rate.n), f.rate.n)}) · ${f.file}:${f.line} · ${f.where}`,
|
|
134
|
-
...f.quotes.slice(0, 1).map((q) => ` reply ${q.reply}: "${q.quote}" (p=${q.p.toFixed(2)})`),
|
|
135
|
-
` replay: ${plural(f.replay.trials, "trial")} · verify ${f.id}`,
|
|
136
|
-
].join("\n"));
|
|
137
|
-
return [`Run ${d.runId}. ${d.read} ${d.heldBack}`, ...rows, `Fix one finding at a time, in the file it names, then verify it. ${d.url}`].join("\n");
|
|
180
|
+
// `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
|
|
181
|
+
return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field, post, changed };
|
|
138
182
|
}
|
|
139
183
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
184
|
+
// Only paths inside the repository are looked at; a path from the server never reaches outside it.
|
|
185
|
+
export function changedLine({ root, since, runId, paths, lines }) {
|
|
186
|
+
const t = Date.parse(since ?? "");
|
|
187
|
+
if (!Number.isFinite(t)) return "";
|
|
188
|
+
const base = realpathSync(root);
|
|
189
|
+
const changedFiles = [...new Set(paths)].filter((rel) => {
|
|
190
|
+
const abs = resolvePath(base, rel);
|
|
191
|
+
if (!abs.startsWith(base + sep)) return false;
|
|
192
|
+
try { return statSync(abs).mtimeMs > t; } catch { return false; }
|
|
193
|
+
});
|
|
194
|
+
if (!changedFiles.length) return "";
|
|
195
|
+
const at = (rel) => lines.filter((l) => l.path === rel).flatMap((l) => l.findingIds);
|
|
196
|
+
return `Changed since run ${runId}: ${changedFiles.map((rel) => (at(rel).length ? `${rel} (${at(rel).join(", ")})` : rel)).join(", ")}.`;
|
|
152
197
|
}
|
package/lib/words.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The small pieces every rendering shares: numbers with their units, file and line, whose side a
|
|
2
|
+
// stop is on, and the pages a long answer is cut into.
|
|
3
|
+
|
|
4
|
+
// Copilot cuts a result past about 10 KB; a page stays under this.
|
|
5
|
+
export const PAGE_CHARS = 9000;
|
|
6
|
+
export const SIDE = { theirs: "on the app's side", ours: "on Cortad's side" };
|
|
7
|
+
|
|
8
|
+
export const has = (v) => v !== undefined && v !== null;
|
|
9
|
+
export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
|
|
10
|
+
export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
|
|
11
|
+
export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
|
|
12
|
+
export const at = (path, line) => `${path}${has(line) ? `:${line}` : ""}`;
|
|
13
|
+
export const clip = (s, max) => (String(s).length > max ? `${String(s).slice(0, max - 3)}...` : String(s));
|
|
14
|
+
|
|
15
|
+
// One page of many, with the call that reads the next one: `next(2)` gives "findings with page 2".
|
|
16
|
+
export function pageOf(pages, page, next) {
|
|
17
|
+
const n = Math.min(Math.max(1, Math.floor(Number(page)) || 1), pages.length);
|
|
18
|
+
const tail = pages.length === 1 ? "" : n < pages.length ? `\npage ${n} of ${pages.length}, call ${next(n + 1)}` : `\npage ${n} of ${pages.length}`;
|
|
19
|
+
return `${pages[n - 1]}${tail}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Lines packed into pages under PAGE_CHARS; every page after the first opens with `continued`.
|
|
23
|
+
export function pack(head, lines, continued, budget = PAGE_CHARS - 80) {
|
|
24
|
+
const pages = [];
|
|
25
|
+
let page = head;
|
|
26
|
+
let filled = false;
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (filled && page.length + line.length + 1 > budget) {
|
|
29
|
+
pages.push(page);
|
|
30
|
+
page = continued;
|
|
31
|
+
filled = false;
|
|
32
|
+
}
|
|
33
|
+
page += `\n${line}`;
|
|
34
|
+
filled = true;
|
|
35
|
+
}
|
|
36
|
+
return [...pages, page];
|
|
37
|
+
}
|
package/local.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { promisify } from "node:util";
|
|
|
23
23
|
import { COMMANDS, main as face } from "./lib/cli.mjs";
|
|
24
24
|
import { clearRunner, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
|
|
25
25
|
import { registerAll } from "./lib/register.mjs";
|
|
26
|
+
import { finished } from "./lib/text.mjs";
|
|
26
27
|
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
27
28
|
import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
|
|
28
29
|
import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
@@ -50,7 +51,13 @@ const viaToken = argv.includes("--token");
|
|
|
50
51
|
// What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
|
|
51
52
|
// this package before anything here runs, and the first thing we printed used to be after the whole
|
|
52
53
|
// upload: a minute or more of a cursor sitting still, which reads as nothing happening.
|
|
53
|
-
|
|
54
|
+
// Without a terminal (a coding agent's shell) each new step is its own line, so the agent reading
|
|
55
|
+
// the output sees the folder being looked at and the upload counting up, not a two-minute blank.
|
|
56
|
+
let lastStep = "";
|
|
57
|
+
const step = (line) => {
|
|
58
|
+
if (process.stdout.isTTY) { process.stdout.write(`\rcortad ${line}\x1b[K`); return; }
|
|
59
|
+
if (line !== lastStep) { lastStep = line; say(line); }
|
|
60
|
+
};
|
|
54
61
|
const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
|
|
55
62
|
const stepDone = (line) => { clearStep(); say(line); };
|
|
56
63
|
if (!explain && !viaToken && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain | npx cortad status | run | findings | verify <id>");
|
|
@@ -100,7 +107,7 @@ function walk(dir, depth, out, envs, total) {
|
|
|
100
107
|
try { size = statSync(full).size; } catch { continue; }
|
|
101
108
|
if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
|
|
102
109
|
if (size > MAX_DATA && DATA_FILE.test(e.name) && !MANIFEST_FILE.test(e.name)) continue;
|
|
103
|
-
if (!shareable(relative(root, full))) continue;
|
|
110
|
+
if (!shareable(relative(root, full), false)) continue;
|
|
104
111
|
out.push(relative(root, full));
|
|
105
112
|
total += size;
|
|
106
113
|
}
|
|
@@ -121,12 +128,14 @@ function gitListed() {
|
|
|
121
128
|
} catch (e) { fail(`could not list this repository's files with git: ${e.message}`); }
|
|
122
129
|
}
|
|
123
130
|
// One rule for every file that could leave: the upload, and a read the engine asks for later.
|
|
124
|
-
function shareable(rel) {
|
|
131
|
+
function shareable(rel, askGit = true) {
|
|
125
132
|
const parts = rel.split(sep);
|
|
126
133
|
if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
|
|
127
|
-
if (listed === null) return true;
|
|
128
|
-
|
|
129
|
-
//
|
|
134
|
+
if (listed === null || listed.has(rel)) return true;
|
|
135
|
+
// Off the list git gave a moment ago: ignored, or inside a nested checkout. The walk takes git's
|
|
136
|
+
// word for it; asking per file was one process each, two minutes on a repository with a worktree
|
|
137
|
+
// inside it. A read asked later, of a file made after the list was taken (an agent edit), asks.
|
|
138
|
+
if (!askGit) return false;
|
|
130
139
|
try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
|
|
131
140
|
}
|
|
132
141
|
let listed = null;
|
|
@@ -182,6 +191,15 @@ const ENV_READ = /(?:process\.env(?:\.|\[\s*['"])|os\.(?:environ\.get|getenv)\(\
|
|
|
182
191
|
const lifted = (name) => (/(TOKEN_BUDGET|TOKENS)/.test(name) ? "1000000000" : "1000000");
|
|
183
192
|
const liftable = (name) => THROUGHPUT.test(name) && !GUARDED.test(name) && !SWITCH.test(name);
|
|
184
193
|
function liftedLimits(envFiles, sources = []) {
|
|
194
|
+
// Only a limit the code reads is raised: a line in .env that nothing reads is not a limit, and
|
|
195
|
+
// naming it as one raised was the tell an engineer caught first.
|
|
196
|
+
const read = new Set();
|
|
197
|
+
for (const file of sources) {
|
|
198
|
+
if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
|
|
199
|
+
let text = "";
|
|
200
|
+
try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
|
|
201
|
+
for (const m of text.matchAll(ENV_READ)) if (liftable(m[1])) read.add(m[1]);
|
|
202
|
+
}
|
|
185
203
|
const out = {};
|
|
186
204
|
for (const file of envFiles) {
|
|
187
205
|
let text = "";
|
|
@@ -191,15 +209,10 @@ function liftedLimits(envFiles, sources = []) {
|
|
|
191
209
|
if (!m) continue;
|
|
192
210
|
const [, name, raw] = m;
|
|
193
211
|
const value = raw.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
194
|
-
if (liftable(name) && /^\d+$/.test(value)) out[name] = lifted(name);
|
|
212
|
+
if (liftable(name) && /^\d+$/.test(value) && (!sources.length || read.has(name))) out[name] = lifted(name);
|
|
195
213
|
}
|
|
196
214
|
}
|
|
197
|
-
for (const
|
|
198
|
-
if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
|
|
199
|
-
let text = "";
|
|
200
|
-
try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
|
|
201
|
-
for (const m of text.matchAll(ENV_READ)) if (liftable(m[1]) && !(m[1] in out)) out[m[1]] = lifted(m[1]);
|
|
202
|
-
}
|
|
215
|
+
for (const name of read) if (!(name in out)) out[name] = lifted(name);
|
|
203
216
|
return out;
|
|
204
217
|
}
|
|
205
218
|
|
|
@@ -380,7 +393,8 @@ async function verb(job) {
|
|
|
380
393
|
keep(res);
|
|
381
394
|
if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
|
|
382
395
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
383
|
-
|
|
396
|
+
// An OpenAPI document can run to megabytes; the caller asks for it whole.
|
|
397
|
+
const LIMIT = Number.isInteger(b.limit) && b.limit > 0 ? Math.min(b.limit, 8 * 1024 * 1024) : 262_144;
|
|
384
398
|
// A session cookie your app sets is its business: it is dropped here, and every other header masked.
|
|
385
399
|
const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
|
|
386
400
|
return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
|
|
@@ -421,7 +435,10 @@ async function verb(job) {
|
|
|
421
435
|
case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
|
|
422
436
|
// What your app spent on its providers, from the hook inside it. An app this command did not
|
|
423
437
|
// start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
|
|
424
|
-
|
|
438
|
+
// Masked like every other reply: a tool's answer can carry a value from their env files.
|
|
439
|
+
case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
|
|
440
|
+
// Every route your app holds, read by the hook off the app itself, masked like the meter.
|
|
441
|
+
case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
|
|
425
442
|
// A world is ended from this terminal, never from the cloud.
|
|
426
443
|
case "destroy": return { ok: true };
|
|
427
444
|
default: return { ok: true };
|
|
@@ -668,8 +685,28 @@ async function installOnce(said) {
|
|
|
668
685
|
return true;
|
|
669
686
|
}
|
|
670
687
|
|
|
688
|
+
// The port their start command names, moved when something else already holds it: two apps on one
|
|
689
|
+
// laptop both said --port 8000, and the second attached to the first's server as its own.
|
|
690
|
+
// Ports this program tried and lost: two connects on one machine can pick the same free port in
|
|
691
|
+
// the same second, and the one that binds second sees "address already in use".
|
|
692
|
+
const lost = new Set();
|
|
693
|
+
async function freePortAbove(port) {
|
|
694
|
+
for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
async function freed(cmd, lifted) {
|
|
698
|
+
const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
|
|
699
|
+
const named = m ? Number(m[2]) : Number(lifted.PORT) || 0;
|
|
700
|
+
if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
|
|
701
|
+
const port = await freePortAbove(named);
|
|
702
|
+
if (!port) return { cmd, lifted };
|
|
703
|
+
say(`port ${named} is taken on this machine, so your app starts on ${port}`);
|
|
704
|
+
// The same port their env file names, moved the same way.
|
|
705
|
+
return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
|
|
706
|
+
}
|
|
707
|
+
|
|
671
708
|
async function start(waitMs) {
|
|
672
|
-
const { cmd, lifted } = launched;
|
|
709
|
+
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
673
710
|
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
674
711
|
const mine = child;
|
|
675
712
|
let seen = "";
|
|
@@ -689,6 +726,10 @@ async function start(waitMs) {
|
|
|
689
726
|
// that holds the port is theirs and already running, which startApp turns into attaching to it.
|
|
690
727
|
if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
|
|
691
728
|
await stopApp(mine.pid);
|
|
729
|
+
// The port this start named was taken between the check and the bind: the next free one, at
|
|
730
|
+
// most three times, before the failure is theirs to read.
|
|
731
|
+
const named = /(?:--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd)?.[1] ?? lifted.PORT;
|
|
732
|
+
if (/EADDRINUSE|address already in use/i.test(seen) && named && lost.size < 3) { lost.add(Number(named)); return start(waitMs); }
|
|
692
733
|
return { port: null, exited: 1, tail: tail() };
|
|
693
734
|
}
|
|
694
735
|
// The port is what the app's own process group listens on. Never a guess: a developer's
|
|
@@ -977,7 +1018,7 @@ if (argv.includes("--until-idle") && stored) {
|
|
|
977
1018
|
let idleSince = Date.now();
|
|
978
1019
|
setInterval(async () => {
|
|
979
1020
|
const res = await call("GET", "/mcp/status", undefined, { headers: { authorization: `Bearer ${stored}` } }).catch(() => null);
|
|
980
|
-
if (res?.ok && res.data?.run && !res.data.run
|
|
1021
|
+
if (res?.ok && res.data?.run && !finished(res.data.run)) idleSince = Date.now();
|
|
981
1022
|
else if (Date.now() - idleSince > IDLE_MS) { say("no run for ten minutes, leaving"); await close(0); }
|
|
982
1023
|
}, 60_000).unref();
|
|
983
1024
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-rc.10",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -17,14 +17,19 @@
|
|
|
17
17
|
"lib/mcp.mjs",
|
|
18
18
|
"lib/mint.mjs",
|
|
19
19
|
"lib/pyhook/sitecustomize.py",
|
|
20
|
+
"lib/read-text.mjs",
|
|
20
21
|
"lib/register.mjs",
|
|
21
22
|
"lib/replay.mjs",
|
|
22
23
|
"lib/sample.mjs",
|
|
23
24
|
"lib/service.mjs",
|
|
25
|
+
"lib/spec.mjs",
|
|
24
26
|
"lib/start.mjs",
|
|
27
|
+
"lib/stick.mjs",
|
|
25
28
|
"lib/switches.mjs",
|
|
29
|
+
"lib/text.mjs",
|
|
26
30
|
"lib/trace.cjs",
|
|
27
31
|
"lib/verbs.mjs",
|
|
32
|
+
"lib/words.mjs",
|
|
28
33
|
"local.mjs",
|
|
29
34
|
"skill/SKILL.md",
|
|
30
35
|
"skill/references/results.md"
|