cortad 0.4.3 → 0.4.4

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/lib/cli.mjs CHANGED
@@ -9,10 +9,10 @@ import { markRun, putBack, writesDirOf } from "./writes.mjs";
9
9
  import { makeReport } from "./calls.mjs";
10
10
  import { serveMcp, TOOLS } from "./mcp.mjs";
11
11
  import { SHOW } from "./read-text.mjs";
12
- import { answeringText, notStartedText } from "./text.mjs";
12
+ import { answeringText, heldRunText, notStartedText } from "./text.mjs";
13
13
  import { cliSpec, npxName, VERSION } from "./spec.mjs";
14
14
  import { hookOutput, stick, unstick } from "./stick.mjs";
15
- import { makeVerbs, READY_MS } from "./verbs.mjs";
15
+ import { cappedAt, makeVerbs, READY_MS } from "./verbs.mjs";
16
16
 
17
17
  // `npx cortad mcp`, `npx cortad <verb>` and `npx cortad stick`: what a coding agent uses after the
18
18
  // first connect. None of them uploads or edits anything by itself. When a run needs the app up and
@@ -102,10 +102,26 @@ const jsonArgs = (raw) => {
102
102
  // old server while the connect command runs the new one. Each tool then runs as the version the
103
103
  // running connect is, and as this one only when that cannot be done.
104
104
  const RELEASE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
105
+ // The first cortad that holds a run to a number of conversations. An older one takes the number,
106
+ // plays every conversation the run chose and spends the person's whole key on it, so a run it cannot
107
+ // hold is refused here rather than handed to it (lib/verbs.mjs run).
108
+ const CAP_FROM = "0.4.4";
109
+ // In release order on the numbers, with a prerelease counting below the release it leads to: a
110
+ // 0.4.4 build from before this is not 0.4.4. A version this cannot read counts as older, never as
111
+ // new enough.
112
+ const olderThan = (version, floor) => {
113
+ const parts = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v); return m ? [+m[1], +m[2], +m[3], m[4] ? 0 : 1] : null; };
114
+ const [a, b] = [parts(version), parts(floor)];
115
+ if (!a) return true;
116
+ for (let i = 0; i < 4; i += 1) if (a[i] !== b[i]) return a[i] < b[i];
117
+ return false;
118
+ };
105
119
  export function asRunner(verbs, { runnerVersion, runAt: other, own = VERSION }) {
106
120
  return Object.fromEntries(TOOLS.map(({ name }) => [name, async (args) => {
107
121
  const version = runnerVersion();
108
122
  if (!version || version === own || !RELEASE.test(version)) return verbs[name](args);
123
+ const cap = name === "run" ? cappedAt(args?.conversations).conversations : undefined;
124
+ if (cap && olderThan(version, CAP_FROM)) return { text: heldRunText(version, cap, CAP_FROM), isError: true, code: "older_command" };
109
125
  return (await other(version, name, args)) ?? verbs[name](args);
110
126
  }]));
111
127
  }
@@ -172,14 +188,16 @@ export function argsOf(command, rest) {
172
188
  }
173
189
  }
174
190
 
175
- // What the waiter was asked for, from its own command line: a run, or a verify of one finding.
176
- function requestOf(raw) {
191
+ // What the waiter was asked for, from its own command line: a run, or a verify of one finding. An
192
+ // app that has to come up first is the ordinary first run, and this is the only place the run's own
193
+ // arguments survive to be posted, so a cap asked for here is carried and not quietly dropped.
194
+ export function requestOf(raw) {
177
195
  let value = {};
178
196
  try { value = JSON.parse(raw ?? "{}"); } catch { /* a run */ }
179
197
  const pick = (v) => (typeof v === "string" && v.length <= 200 ? v : undefined);
180
198
  const findingId = pick(value.args?.findingId);
181
199
  const jobId = pick(value.args?.jobId);
182
- return findingId ? { kind: "verify", args: { findingId, ...(jobId ? { jobId } : {}) } } : { kind: "run", args: {} };
200
+ return findingId ? { kind: "verify", args: { findingId, ...(jobId ? { jobId } : {}) } } : { kind: "run", args: cappedAt(value.args?.conversations) };
183
201
  }
184
202
 
185
203
  // Returns at once. The runner holds the app up: from the MCP it lives as long as the MCP does;
@@ -130,6 +130,10 @@ function reachLine(missing) {
130
130
  const ready = missing.filter((m) => m.request && !m.unsure && !m.excluded);
131
131
  return `Send each one message as a user would, signed in where your app needs it${ready.length ? `, or call reach to send the request Cortad read for ${ready.length === missing.length ? "them" : `${num(ready.length)} of them`}` : ""}.`;
132
132
  }
133
+ // A customer sent his one message, his app handed back a running turn, and this list went on naming
134
+ // his endpoint: he read it as his endpoint being broken. What Cortad is waiting for is said here, so
135
+ // the wait is the model's answer and not his question again.
136
+ const LATER_WAIT = "An endpoint that hands back a turn and answers afterwards stays on this list until Cortad has seen the answer its model wrote: the first reply alone does not take it off.";
133
137
 
134
138
  // `data`: what the command did with the app's stores and services, said right under the run it is
135
139
  // about, so what a run writes into for real is read before Run is pressed.
@@ -144,7 +148,9 @@ export function contactLines(c, data = [], receipts = []) {
144
148
  const head = alike ? `Endpoints that answered (${num(proven.length)}), each only Cortad's own test request so far: ${proven[0].why}` : `Endpoints that answered (${num(proven.length)}):`;
145
149
  const lines = proven.length ? [head, ...proven.flatMap((v) => doorLines(alike ? { ...v, standing: null } : v))] : [];
146
150
  const missing = unreached(c, receipts);
147
- if (missing.length) lines.push(`Endpoints found in your code that have not answered yet (${num(missing.length)}):`, ...missing.map(unreachedLine), ...[reachLine(missing)].filter(Boolean));
151
+ // Not "have not answered yet", which a customer read as his endpoint being broken: what is missing
152
+ // is an answer Cortad has seen.
153
+ if (missing.length) lines.push(`Endpoints found in your code that Cortad has seen no answer from yet (${num(missing.length)}):`, ...missing.map(unreachedLine), ...[reachLine(missing), LATER_WAIT].filter(Boolean));
148
154
  for (const line of [readyLine(c.ready), noRulesLine(c.ready)]) if (line) lines.push(line);
149
155
  // What holds Run is the server's next line (src/local/contact.ts heldLine), said once: printed here
150
156
  // as well, ulaim's hold on its Qdrant was read twice in one status.
package/lib/mcp.mjs CHANGED
@@ -30,8 +30,11 @@ export const TOOLS = [
30
30
  inputSchema: { type: "object", properties: {
31
31
  paths: { type: "array", items: { type: "string" }, description: "Only these endpoints, by path. Default: every endpoint status lists as failed or not yet answered." },
32
32
  }, additionalProperties: false } },
33
- { name: "run", title: "Start a run", inputSchema: none,
34
- description: "Starts a run of conversations against the app on this machine and answers within a second with the run id; after the first run it plays what the files saved since the last full run reach, plus new conversations written for the change, and its result leads with what moved. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
33
+ { name: "run", title: "Start a run",
34
+ description: "Starts a run of conversations against the app on this machine and answers within a second with the run id; with conversations it plays no more than that many, so a first look spends less on the person's own model key than a whole run does. After the first run it plays what the files saved since the last full run reach, plus new conversations written for the change, and its result leads with what moved; when the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan, capped or not; a spent plan answers with the numbers and a checkout link for the person.",
35
+ inputSchema: { type: "object", properties: {
36
+ conversations: { type: "integer", minimum: 1, description: "The most conversations this run may play, for example 10. Default: as many as the run would choose, which status prices before it starts." },
37
+ }, additionalProperties: false } },
35
38
  { name: "run_status", title: "Run progress", annotations: readOnly,
36
39
  description: "Where a run or verify stands: conversations played of the total, the findings, what the app's rules and users measured, and for a verify whether the failure is gone, shows less often, stayed or cannot be told yet. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
37
40
  inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
package/lib/proc.mjs CHANGED
@@ -79,7 +79,9 @@ export async function holderOf(port) {
79
79
  export async function stopTree(pid) {
80
80
  const family = await familyOf(pid).catch(() => [pid]);
81
81
  const signal = (sig) => { for (const p of family) { try { process.kill(p, sig); } catch { /* already gone */ } } try { process.kill(-pid, sig); } catch { /* no group left */ } };
82
- const alive = () => family.some((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
82
+ // The group counts as alive as well: once its leader has exited, a server the leader started is in
83
+ // the group and in no family read from the leader's pid, and asking alone never stopped it.
84
+ const alive = () => [...family, -pid].some((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
83
85
  signal("SIGTERM");
84
86
  for (let i = 0; i < 15 && alive(); i++) await new Promise((r) => setTimeout(r, 200));
85
87
  if (alive()) signal("SIGKILL");
@@ -97,6 +99,9 @@ export function spawnTied(cmd, { cwd, env }) {
97
99
  dog.stdin.unref?.();
98
100
  dog.stdin.on("error", () => { /* the watchdog is gone; the runner stops the app itself */ });
99
101
  // An app that ended while this process lives is this process's to clean up, not the watchdog's.
100
- app.once("exit", () => { try { dog.kill("SIGKILL"); } catch { /* gone */ } });
102
+ // Its group can hold more than it: a start command that hands the port to a child and exits leaves
103
+ // that child serving, which is a second copy of the app as soon as the next start comes up. It is
104
+ // stopped here, while the group still names it, and the watchdog goes once there is nothing left.
105
+ app.once("exit", () => { void stopTree(app.pid).finally(() => { try { dog.kill("SIGKILL"); } catch { /* gone */ } }); });
101
106
  return app;
102
107
  }
package/lib/read-text.mjs CHANGED
@@ -111,6 +111,13 @@ export function machineText(f) {
111
111
  // What the terminal says once the app answers: the one line the person needs.
112
112
  export const connectedText = (f) => machineText(f).slice(0, 1);
113
113
 
114
+ // The last line a connect prints, and the last line when the app never started. A customer read the
115
+ // failure as the ready signal: both opened on the same clause and sent him to a browser, and his
116
+ // whole session was a terminal and a coding agent, which never had one open. Each opens on the state
117
+ // it is reporting, and the one place that session can act on is the agent.
118
+ export const CONNECTED = "connected: Cortad is watching your app. Ask your coding agent for Cortad's status to see what a run would play; Ctrl-C disconnects.";
119
+ export const NOT_RUNNING = "your app is not running, so Cortad has nothing to test. What stopped it is the line above, from starting your app, not from Cortad.";
120
+
114
121
  export function showText(d, show, page = 1) {
115
122
  if (!d.read || (d.read.complete === false && show !== "reviews")) return "Cortad is still reading this repository.";
116
123
  const { head, lines, continued } = sectionOf(d.read, show);
package/lib/text.mjs CHANGED
@@ -308,6 +308,12 @@ export function startedText(d, kind, findingId, said = []) {
308
308
  return [`${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.`, ...said, d.note, `next: run_status ${d.jobId}`].filter(Boolean).join("\n");
309
309
  }
310
310
 
311
+ // A run held to a number of conversations, asked of a connect command older than the one that can
312
+ // hold it: that command would take the number, play every conversation and spend the whole of the
313
+ // person's own key, so the run is refused instead and the one thing to do about it is said.
314
+ export const heldRunText = (version, conversations, from) =>
315
+ `The cortad command holding your app up is ${version} and cannot hold a run to ${plural(conversations, "conversation")}; ${from} and newer can, so stop that command and run it again.\nNothing ran.`;
316
+
311
317
  export const restartedText = (paths) =>
312
318
  `Your app was started again so this run plays your change (${plural(paths.length, "file")} changed since it started: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", ..." : ""}).`;
313
319
 
package/lib/verbs.mjs CHANGED
@@ -28,6 +28,10 @@ const IDLE_MS = 6 * 3600_000;
28
28
  export const READY_MS = 240_000;
29
29
  // The most changed files a run is scoped by; past it the run replays everything.
30
30
  const MOST_CHANGED = 2000;
31
+ // The most conversations a run may play, as the person asked for it, on its way to the server: a
32
+ // whole number of at least one, or nothing at all. How many a run is allowed is the server's to
33
+ // decide and to refuse, so no ceiling is kept on this side to drift from the one it holds.
34
+ export const cappedAt = (n) => (Number.isSafeInteger(n) && n >= 1 ? { conversations: n } : {});
31
35
 
32
36
  // `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
33
37
  // startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
@@ -143,7 +147,10 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
143
147
  }
144
148
  return { text: here?.state === "up" ? upWaitingText(here, before.data.app) : STARTING, data: { pending: true } };
145
149
  };
146
- const run = () => start({}, "run");
150
+ // `conversations`: the most this run may play, asked for by the person who is paying for the
151
+ // replies. Whether a number is allowed is the server's to say (it holds the range), so only its
152
+ // shape is read here and the server's refusal speaks for the rest.
153
+ const run = ({ conversations } = {}) => start(cappedAt(conversations), "run");
147
154
  const verify = ({ findingId, jobId } = {}) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
148
155
 
149
156
  // With no id: the run this machine is still starting, held here until it has an id, or else the
package/local.mjs CHANGED
@@ -30,7 +30,7 @@ import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
30
30
  import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree } from "./lib/proc.mjs";
31
31
  import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, writeRunner } from "./lib/runner.mjs";
32
32
  import { registerAll } from "./lib/register.mjs";
33
- import { connectedText } from "./lib/read-text.mjs";
33
+ import { CONNECTED, connectedText, NOT_RUNNING } from "./lib/read-text.mjs";
34
34
  import { VERSION } from "./lib/spec.mjs";
35
35
  import { credentialShaped, maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
36
36
  import { finished, unseenText } from "./lib/text.mjs";
@@ -822,9 +822,14 @@ function restartOnce(work) {
822
822
  })();
823
823
  return restartDone;
824
824
  }
825
+ const SAVED_CODE = "starting your app again so it runs the code you saved, since it does not reload by itself";
826
+ // Your app read its env files when it started and nothing reloads them, so a value saved into one is
827
+ // a value it is not running on. A customer changed one and had to stop this command and run it again
828
+ // for every change; one of his apps was left running each time.
829
+ const SAVED_ENV = "starting your app again so it reads the value you saved in your env file, which your app reads only when it starts";
825
830
  // The same command again, for an app that does not reload on save. Only an app this program
826
831
  // started: one you started yourself is yours to restart. New requests wait at the gate meanwhile.
827
- function restartApp(why = "starting your app again so it runs the code you saved, since it does not reload by itself") {
832
+ function restartApp(why = SAVED_CODE) {
828
833
  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." });
829
834
  return restartOnce(async () => {
830
835
  hold();
@@ -840,13 +845,20 @@ function restartApp(why = "starting your app again so it runs the code you saved
840
845
  const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
841
846
  root, app: { startedAt: new Date(loaded.at).toISOString(), files: loaded.files }, skip: writtenPaths(writesDirOf(homeOf(project))),
842
847
  }).changed.length > 0;
848
+ // An env file of the app's own, saved since it started: compared by the time on the file, since what
849
+ // it holds is never read into this command's own record. An example file is nobody's settings.
850
+ const envSavedSinceStart = () => Boolean(loaded?.own && loaded.at) && envFiles.some((f) => {
851
+ try { return !/\.(example|sample)$/.test(f) && statSync(f).mtimeMs > loaded.at; } catch { return false; }
852
+ });
853
+ // Why the app has to start again now, or nothing when it is running what is on disk.
854
+ const whyRestart = () => (savedSinceStart() ? SAVED_CODE : envSavedSinceStart() ? SAVED_ENV : null);
843
855
  // Every save that changes what your app runs starts it again, when it does not reload by itself: an
844
856
  // agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
845
857
  // at again after each restart, for a save made while it was under way, and before the first wait too:
846
858
  // the app starts while the code is uploaded, and a save made before this watch began was otherwise never seen.
847
859
  async function restartOnSave() {
848
860
  while (!closing) {
849
- while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) await restartApp();
861
+ for (let why = whyRestart(); !closing && launched && child && !appGone && !loaded?.reloads && why; why = whyRestart()) await restartApp(why);
850
862
  await sourceChanged();
851
863
  }
852
864
  }
@@ -1108,9 +1120,10 @@ async function appLife(firstStart) {
1108
1120
  failedWith(got.why, got.said);
1109
1121
  await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
1110
1122
  say(got.why);
1111
- // Said with the same words the agent prompt waits for, so an agent holding the terminal reports
1112
- // the failure instead of waiting for a line that never comes.
1113
- if (first) say("your app did not start. Go back to the browser; it says what stopped it. Ctrl-C disconnects.");
1123
+ // An agent holding this terminal watches for one of these two lines (skill/SKILL.md), so the
1124
+ // failure carries a line of its own: it reports the failure instead of waiting for a line that
1125
+ // never comes, and no reader takes it for the connected one.
1126
+ if (first) say(NOT_RUNNING);
1114
1127
  await sourceChanged();
1115
1128
  become("starting");
1116
1129
  say("saw your change, starting your app again");
@@ -1128,7 +1141,7 @@ async function appLife(firstStart) {
1128
1141
  if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
1129
1142
  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`);
1130
1143
  for (const line of connectedText(machineOf())) say(line);
1131
- say("leave this open. Go back to the browser; Ctrl-C disconnects.");
1144
+ say(CONNECTED);
1132
1145
  let downSince = 0;
1133
1146
  let toldDown = false;
1134
1147
  let appTold = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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"
package/skill/SKILL.md CHANGED
@@ -38,7 +38,7 @@ Cortad does not guess how the app runs: you tell it, in one line, from the repos
38
38
  - A folder connected before reconnects with the key and the start command it kept. A first connect prints a link and four characters, good for ten minutes: give the person both as printed; they approve the link in their browser, signed in to Cortad.
39
39
  - When a result says no connect command is running, run the same line again in this folder.
40
40
 
41
- Once it prints "Go back to the browser", Cortad lists the AI endpoints it found in the code. Send each one message the way a user would: through the app's own page, or a request from this machine, signed in as a real test account where the app needs one (ask the person which account; Cortad makes none). That one request is what Cortad learns the endpoint from: its route, its body, its sign-in and where the answer comes back. Cortad sends nothing to the app before that, and nothing of its own before the person's yes on the card.
41
+ The output ends on one of two lines. "Cortad is watching your app" means it is connected, and Cortad lists the AI endpoints it found in the code. "your app is not running" means the start failed: give the person that line and the output above it, which is their app's own, and stop there. Once it is connected, send each endpoint one message the way a user would: through the app's own page, or a request from this machine, signed in as a real test account where the app needs one (ask the person which account; Cortad makes none). That one request is what Cortad learns the endpoint from: its route, its body, its sign-in and where the answer comes back. Cortad sends nothing to the app before that, and nothing of its own before the person's yes on the card.
42
42
 
43
43
  1. Call `status` and tell the person, in three or four short lines:
44
44
  - that their app is connected, or the one line that says why it did not start (what the app printed is the app's own output);
@@ -47,7 +47,7 @@ Once it prints "Go back to the browser", Cortad lists the AI endpoints it found
47
47
  - what happens next: they press Run on the card in the browser, or tell you to start it.
48
48
 
49
49
  The endpoints, the files and the audit's rows are yours to work from. They go to the person when they ask, or when you propose a fix.
50
- 2. An endpoint `status` lists as not answered yet is waiting for your one message to it. One it lists as `Needs a signed-in user` was refused: send it again signed in as a test account the person names, and call `status` again.
50
+ 2. An endpoint `status` lists as one Cortad has seen no answer from is waiting for your one message to it. One it lists as `Needs a signed-in user` was refused: send it again signed in as a test account the person names, and call `status` again. An endpoint that hands back a turn and answers afterwards stays on that list until Cortad has seen the answer its model wrote, so fetch the finished answer the way the app's own client does rather than sending the question again.
51
51
  3. A failure on the app's side that every conversation would repeat, such as a model provider refusing, a retrieval with no answer or a tool returning an error: say what you would change, change it when the person agrees, then send that endpoint one message again, or call `reach` to have Cortad send the request it read for it.
52
52
  4. The one question on the card in the browser is where test conversations will write: into the databases the app wrote into for your message, named there, with nothing copied. Say it to the person once, in one line. The first run starts when the person presses Run on the card, or asks you, and then you call `run`. Before you stop, start `npx -y {{cortad}} wait` as a background command (in Claude Code: Bash with run_in_background). It returns when the next run or verify ends, with what it found, and its ending wakes you. Then follow "After a run".
53
53
 
@@ -31,10 +31,11 @@ Endpoints that answered (1):
31
31
  Passages handed to the model: 3.
32
32
  Problem at apps/api/src/agent/system.ts:30: The reply carried "</student_profile>", markup from the prompt your app sent the model.
33
33
  Last message: "and the second question?".
34
- Endpoints found in your code that have not answered yet (2):
34
+ Endpoints found in your code that Cortad has seen no answer from yet (2):
35
35
  POST /api/homework/explain apps/api/src/routes/homework.ts:18 Cortad's test request was answered with HTTP 500.
36
36
  POST /api/progress/summary apps/api/src/routes/progress.ts:9 Needs a signed-in user.
37
37
  Send each one message as a user would, signed in where your app needs it, or call reach to send the request Cortad read for them.
38
+ An endpoint that hands back a turn and answers afterwards stays on this list until Cortad has seen the answer its model wrote: the first reply alone does not take it off.
38
39
  A run plays 36 simulated users in about 6 minutes. About $0.38 on your OpenAI key.
39
40
  A run starts when the person presses Run on the card in the browser or asks you for one; if they say they pressed it, call run rather than ask them again.
40
41
  ```