cortad 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/local.mjs CHANGED
@@ -22,7 +22,9 @@ import { openDoor } from "./lib/door.mjs";
22
22
  import { lockHolds, makeLock } from "./lib/lock.mjs";
23
23
  import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
24
24
  import { CAPTURED, makeCapture } from "./lib/replay.mjs";
25
- import { startPlan } from "./lib/start.mjs";
25
+ import { sampleHere } from "./lib/sample.mjs";
26
+ import { listingUrl } from "./lib/listing.mjs";
27
+ import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
26
28
 
27
29
  const argv = process.argv.slice(2);
28
30
  const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
@@ -52,7 +54,12 @@ const api = `${origin.origin}/api`;
52
54
 
53
55
  const root = process.cwd();
54
56
  const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
55
- if (!MANIFEST.some((f) => existsSync(join(root, f)))) fail(`this folder has no package.json or pyproject.toml. Run it from your repository's root: ${root}`);
57
+ // A repository whose root carries no manifest of its own is still a repository: crewai-examples
58
+ // keeps one project per folder under crews/, and the whole of it was refused at the door. The root
59
+ // is accepted when a project lives below it.
60
+ if (!MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
61
+ 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}`);
62
+ }
56
63
 
57
64
  // ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
58
65
  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;
@@ -136,6 +143,8 @@ function envOrigins(envFiles) {
136
143
  for (const line of text.split("\n")) {
137
144
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
138
145
  if (!m || !/ORIGIN|URL|HOST|DOMAIN|FRONTEND|CLIENT|SITE|WEB/i.test(m[1])) continue;
146
+ // OPENAI_BASE_URL names where a key is sent, not a page a browser opens the app from.
147
+ if (/(?:BASE_URL|API_BASE|ENDPOINT)$/i.test(m[1]) && !/ORIGIN|FRONTEND|CLIENT|SITE|WEB|PUBLIC|APP/i.test(m[1])) continue;
139
148
  for (const v of m[2].replace(/^(['"])(.*)\1$/, "$2").split(",")) {
140
149
  try { const u = new URL(v.trim()); if (/^https?:$/.test(u.protocol)) out.add(u.origin); } catch { /* not an origin */ }
141
150
  }
@@ -175,6 +184,53 @@ function liftedLimits(envFiles, sources = []) {
175
184
  }
176
185
  return out;
177
186
  }
187
+
188
+ // Every name your env files set, with its value, read here and used only here. An example file is
189
+ // read first so a real one's value wins: a placeholder key asked for a model listing comes back
190
+ // refused, and that would read as your own key being refused.
191
+ function envValues(envFiles) {
192
+ const example = /\.(example|sample)$/;
193
+ const out = {};
194
+ for (const file of [...envFiles.filter((f) => example.test(f)), ...envFiles.filter((f) => !example.test(f))]) {
195
+ let text = "";
196
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
197
+ for (const line of text.split("\n")) {
198
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
199
+ if (m) out[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+ // Switch-shaped names and the word each one reads as. The name and the word travel, never the value.
205
+ const SWITCH_NAME = /(?:_ENABLED|_DISABLED|_MODE|_ON|_OFF|_FLAG)$|^(?:ENABLE|USE|DISABLE|SKIP)_/;
206
+ const switchStates = (values) => Object.fromEntries(Object.entries(values)
207
+ .filter(([name]) => SWITCH_NAME.test(name))
208
+ .map(([name, value]) => [name, value === "" ? "unset" : /^(?:1|true|yes|on)$/i.test(value) ? "on" : /^(?:0|false|no|off)$/i.test(value) ? "off" : "set"]));
209
+
210
+ // What your app can actually reach: each provider key your env files set is asked that provider
211
+ // for its own model listing, from this machine. Your key stays here; what goes back is the name it
212
+ // is set under, the host, what the host answered, the model ids, and which switches are on.
213
+ const MODEL_ID = /^[\w./:@-]{1,160}$/;
214
+ async function inventoryOf(listings) {
215
+ const values = envValues(envFiles);
216
+ const providers = await Promise.all(Object.entries(listings)
217
+ .filter(([name]) => values[name])
218
+ .map(async ([name, canonical]) => {
219
+ const url = listingUrl(name, canonical, values);
220
+ let host = "";
221
+ try { host = new URL(url).host; } catch { return null; }
222
+ try {
223
+ const res = await fetch(url, { headers: { authorization: `Bearer ${values[name]}` }, signal: AbortSignal.timeout(20_000) });
224
+ const body = res.ok ? await res.json().catch(() => null) : null;
225
+ const models = (Array.isArray(body?.data) ? body.data : [])
226
+ .map((m) => String(m?.id ?? "")).filter((id) => MODEL_ID.test(id)).slice(0, 1500);
227
+ return { env: name, host, status: res.status, models };
228
+ } catch {
229
+ return { env: name, host, status: 0, models: [] };
230
+ }
231
+ }));
232
+ return { providers: providers.filter(Boolean), flags: switchStates(values) };
233
+ }
178
234
  let secrets = [];
179
235
  let identities = null;
180
236
  let capture = null;
@@ -208,7 +264,8 @@ const work = join(tmpdir(), `cortad-${process.pid}`);
208
264
  mkdirSync(work, { recursive: true });
209
265
  const bootLog = join(work, "boot.log");
210
266
  writeFileSync(bootLog, "");
211
- const door = openDoor(root);
267
+ // Opened once the server says whose session this is.
268
+ let door = null;
212
269
  // Files nobody may read through this program: keys, and git's own internals.
213
270
  const SECRET_PATH = /(?:^|\/)(?:\.git|\.ssh|\.gnupg|\.aws|\.npmrc|\.netrc|id_(?:rsa|ed25519|ecdsa)[^/]*|[^/]*\.(?:pem|key|p12|pfx|jks|keystore))(?:\/|$)/;
214
271
  // The engine's paths, as this machine has them. Its scratch files live in this program's own
@@ -277,13 +334,39 @@ async function verb(job) {
277
334
  // working its plan, and by you. For those seconds nothing is listening. A turn that meets a
278
335
  // closed door is held until your app answers again and sent then, once, instead of being
279
336
  // counted against your app as a failure it never had.
337
+ // A door behind a guest session answers the first request with a redirect that mints the
338
+ // session and sends the browser back. A browser reaches it by loading a page first; the same
339
+ // request pushed down that chain arrives at a page route as a POST and reads as "wrong
340
+ // method", which is what your chat looked like from the outside. So the chain is walked the
341
+ // way a browser walks it, one GET with redirects followed, and the request is sent again with
342
+ // the session your app just handed out. The cookie is kept here and never leaves this machine.
343
+ const url = `http://127.0.0.1:${port}${path}`;
344
+ const yours = (to) => { try { const u = new URL(to, url); return u.hostname === "127.0.0.1" && u.port === String(port) ? u.href : null; } catch { return null; } };
345
+ let jar = "";
346
+ const sent = (at, over = {}) => fetch(at, {
347
+ ...init, ...over,
348
+ headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
349
+ signal: AbortSignal.timeout(170_000),
350
+ });
351
+ const keep = (res) => { const set = res.headers.getSetCookie?.() ?? []; if (set.length) jar = [jar, ...set.map((c) => c.split(";")[0])].filter(Boolean).join("; "); };
352
+ const sentBack = (res) => (res.status >= 300 && res.status < 400 ? yours(res.headers.get("location") ?? "") : null);
353
+ const warm = async () => {
354
+ let at = url;
355
+ for (let hop = 0; hop < 4 && at; hop++) {
356
+ const res = await sent(at, { method: "GET", body: undefined });
357
+ keep(res);
358
+ at = sentBack(res);
359
+ }
360
+ };
280
361
  const ask = async () => {
281
- const res = await fetch(`http://127.0.0.1:${port}${path}`, { ...init, signal: AbortSignal.timeout(170_000) });
362
+ let res = await sent(url);
363
+ keep(res);
364
+ if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
282
365
  const buf = Buffer.from(await res.arrayBuffer());
283
366
  const LIMIT = 262_144;
284
367
  // A session cookie your app sets is its business: it is dropped here, and every other header masked.
285
- const headers = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
286
- return { status: res.status, headers, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
368
+ const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
369
+ return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
287
370
  };
288
371
  try { return await ask(); }
289
372
  catch (e) {
@@ -316,8 +399,14 @@ async function verb(job) {
316
399
  case "restore": return door.restore(String(b.checkpoint ?? ""));
317
400
  case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
318
401
  case "restart": return restartApp();
402
+ case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
403
+ // Your own pages, read here rather than in a world's shell: that shell is sealed away from
404
+ // every env file and every host but this one, so inside it no store of yours has an address.
405
+ case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, envValues(envFiles), root)))) };
319
406
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
320
- case "usage": return { totals: [], rows: [] };
407
+ // What your app spent on its providers, from the hook inside it. An app this command did not
408
+ // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
409
+ case "usage": return capture?.usage() ?? {};
321
410
  // A world is ended from this terminal, never from the cloud.
322
411
  case "destroy": return { ok: true };
323
412
  default: return { ok: true };
@@ -345,13 +434,23 @@ let child = null;
345
434
  async function startApp() {
346
435
  const wanted = Number(flag("--port"));
347
436
  if (wanted) {
348
- if (!(await answers(wanted))) fail(`nothing is answering on port ${wanted}. Start your app first, then run this again.`);
437
+ if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
438
+ const took = await takeOver(wanted);
439
+ if (took) return took;
349
440
  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");
350
441
  return { port: wanted, cmd: null };
351
442
  }
352
443
  const plan = startPlan({ root, typed: flag("--start"), onPath });
353
- const cmd = plan?.cmd ?? await ask("How do you start your app? (for example: npm run dev) ");
354
- if (!cmd) fail("tell me how your app starts: --start \"npm run dev\", or --port 3000 if it is already running.");
444
+ // A package is not a broken app, and asking its owner how it starts is the wrong question.
445
+ const cmd = plan?.cmd ?? (plan?.noServer ? null : await ask("How do you start your app? (for example: npm run dev) "));
446
+ // Your code is already here and already being read: quitting now would throw away what you have
447
+ // paid for. This stays connected, the read finishes, and the browser says what is missing.
448
+ if (!cmd) {
449
+ return { port: null, said: "", noStart: true,
450
+ why: plan?.noServer
451
+ ? "this repository has no server to run; connect the app that serves it. If it does have one, run this again with --start \"how you start it\"."
452
+ : "your code is being read, but I could not work out how your app starts, so nothing is running to ask. Run the command again with --start \"how you start it\", or start your app yourself and run it again with --port <the port it answers on>." };
453
+ }
355
454
  appDir = plan?.cwd ?? root;
356
455
  pinned = pinnedNode();
357
456
  if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
@@ -360,13 +459,19 @@ async function startApp() {
360
459
  launched = { cmd, lifted };
361
460
  step(`starting your app: ${cmd}`);
362
461
  const up = await launch(180_000);
363
- if (up.port) return { port: up.port, cmd, lifted: Object.keys(lifted) };
462
+ if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
364
463
  // Already running: a second start dies on the port the first one holds. The one that is running
365
464
  // is the app, so it is used as it stands rather than treated as a failure.
366
465
  if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)/i.test(up.tail)) {
367
466
  const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
368
467
  for (const port of new Set(ports)) {
369
- if (await answers(port)) { launched = null; child = null; say(`your app is already running on port ${port}, so that one is used`); return { port, cmd: null }; }
468
+ if (await answers(port)) {
469
+ launched = null; child = null;
470
+ const took = await takeOver(port);
471
+ if (took) return took;
472
+ say(`your app is already running on port ${port}, so that one is used; its request limits stay as they are`);
473
+ return { port, cmd: null };
474
+ }
370
475
  }
371
476
  }
372
477
  if (up.tail) console.error(up.tail);
@@ -374,6 +479,55 @@ async function startApp() {
374
479
  return { port: null, said: up.tail, why: `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}; what it said is above.${wrongNode}` };
375
480
  }
376
481
 
482
+ // An app the person started keeps the limits it started with, and a run needs more than a person
483
+ // uses in a month. With their yes, the command stops that app and starts it the way it would have,
484
+ // limits raised for the session. Only in a terminal, only when the code reads a limit, and only
485
+ // when the command knows how to start the app.
486
+ async function takeOver(port) {
487
+ const lifted = liftedLimits(envFiles, files.map((f) => join(root, f)));
488
+ const names = Object.keys(lifted);
489
+ if (!names.length || !process.stdin.isTTY) return null;
490
+ const plan = startPlan({ root, typed: flag("--start"), onPath });
491
+ if (!plan?.cmd) return null;
492
+ const pid = await listenerOn(port);
493
+ if (!pid) return null;
494
+ const answer = await ask(`your app is running with its own request limits (${names.join(", ")}). Restart it with them raised for this session? [Y/n] `);
495
+ if (answer && !/^y(?:es)?$/i.test(answer)) return null;
496
+ const top = await supervisorOf(pid);
497
+ step(`stopping your app (pid ${top}) to start it with higher limits`);
498
+ await stopApp(top);
499
+ for (let i = 0; i < 50 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
500
+ if (await answers(port)) { say("your app did not stop, so it is used as it is"); return null; }
501
+ appDir = plan.cwd ?? root;
502
+ pinned = pinnedNode();
503
+ if (plan.within) say(`your app is in ${plan.within}, started there with: ${plan.cmd}`);
504
+ say(`higher request limits for this session: ${names.join(", ")}`);
505
+ launched = { cmd: plan.cmd, lifted };
506
+ step(`starting your app: ${plan.cmd}`);
507
+ const up = await launch(180_000);
508
+ if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
509
+ if (up.tail) console.error(up.tail);
510
+ return { port: null, said: up.tail, why: "your app did not come back after the restart." };
511
+ }
512
+ // The process listening on a port.
513
+ async function listenerOn(port) {
514
+ try { return Number((await exec("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])).stdout.trim().split("\n")[0]) || null; } catch { return null; }
515
+ }
516
+ // The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
517
+ // while the parent is a runner, never into the person's own shell or terminal.
518
+ 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/;
519
+ async function supervisorOf(pid) {
520
+ const rows = (await exec("ps", ["-axo", "pid=,ppid=,args="])).stdout.trim().split("\n").map((l) => l.trim());
521
+ const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { ppid: Number(m[2]), args: m[3] }] : [0, null]; }));
522
+ let top = pid;
523
+ for (let i = 0; i < 8; i++) {
524
+ const parent = table.get(table.get(top)?.ppid ?? 0);
525
+ if (!parent || !RUNNER.test(parent.args)) break;
526
+ top = table.get(top).ppid;
527
+ }
528
+ return top;
529
+ }
530
+
377
531
  // Your app, started the way you start it, and watched until one of its own ports answers.
378
532
  let launched = null;
379
533
  // The Node this project pins, when the shell that ran this command has another. Strapi refuses
@@ -391,7 +545,56 @@ function pinnedNode() {
391
545
  }
392
546
  let pinned = { major: null, bin: null };
393
547
 
548
+ // Their app could not start because its dependencies are not on this machine. That is an install
549
+ // nobody ran, not a broken app: it is installed once, with the manager the project locked, and the
550
+ // app is started again. Every way the app is started comes through here, so it is fixed in one place.
394
551
  async function launch(waitMs) {
552
+ const up = await start(waitMs);
553
+ // Only an app that stopped: one still running has not failed to start, whatever it printed.
554
+ if (up.port || up.exited === null) return up;
555
+ const ok = await installOnce(up.tail);
556
+ // An install that failed is the reason their app cannot start, and it goes where the app's own
557
+ // output goes: the terminal, and the screen that is waiting for the app.
558
+ const said = (tail) => (installSaid ? `${installSaid}\n${tail}` : tail);
559
+ if (!ok) return installSaid ? { ...up, tail: said(up.tail) } : up;
560
+ const back = await start(waitMs);
561
+ return back.port ? back : { ...back, tail: said(back.tail) };
562
+ }
563
+ let installed = false;
564
+ let installSaid = "";
565
+ async function installOnce(said) {
566
+ if (installed) return false;
567
+ const name = missingDependency(said);
568
+ const cmd = name && installPlan(appDir, onPath);
569
+ if (!cmd) return false;
570
+ installed = true;
571
+ stepDone(`your app needs ${name}, which is not installed here`);
572
+ const started = Date.now();
573
+ const secondsSoFar = () => Math.round((Date.now() - started) / 1000);
574
+ // A line that rewrites itself in a terminal, and every tenth second where there is none.
575
+ const tick = () => { const n = secondsSoFar(); const line = `installing your app's dependencies, ${n} seconds so far`; if (process.stdout.isTTY) step(line); else if (n % 10 === 0) say(line); };
576
+ tick();
577
+ const ticking = setInterval(tick, 1000);
578
+ const done = await new Promise((r) => {
579
+ const child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, CI: "1" }, stdio: ["ignore", "pipe", "pipe"] });
580
+ let tail = "";
581
+ const keep = (d) => { tail = (tail + d.toString()).slice(-4000); appendFileSync(bootLog, d.toString()); if (verbose) process.stdout.write(d.toString()); };
582
+ child.stdout.on("data", keep);
583
+ child.stderr.on("data", keep);
584
+ const timer = setTimeout(() => child.kill("SIGKILL"), 15 * 60_000);
585
+ child.on("close", (code) => { clearTimeout(timer); r({ ok: code === 0, tail }); });
586
+ child.on("error", (e) => { clearTimeout(timer); r({ ok: false, tail: String(e.message) }); });
587
+ });
588
+ clearInterval(ticking);
589
+ if (!done.ok) { installSaid = `installing your app's dependencies failed after ${secondsSoFar()} seconds:\n${done.tail.split("\n").filter(Boolean).slice(-12).join("\n")}`; lastSaid = done.tail; stepDone(`installing your app's dependencies failed after ${secondsSoFar()} seconds`); return false; }
590
+ stepDone(`installed your app's dependencies in ${secondsSoFar()} seconds`);
591
+ // A Python project that had no interpreter of its own has one now, and it is the one to start with.
592
+ const again = startPlan({ root, typed: flag("--start"), onPath });
593
+ if (again?.cmd && again.cmd !== launched.cmd) { launched = { ...launched, cmd: again.cmd }; say(`starting your app: ${again.cmd}`); }
594
+ return true;
595
+ }
596
+
597
+ async function start(waitMs) {
395
598
  const { cmd, lifted } = launched;
396
599
  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 });
397
600
  const mine = child;
@@ -487,13 +690,13 @@ if (explain) {
487
690
  console.log([
488
691
  `cortad --explain (nothing is sent or started by this)`,
489
692
  ``,
490
- `talks to ${origin.origin} and localhost (your app's port only)`,
693
+ `talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
491
694
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
492
695
  `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`,
493
- `env files read here only: to hide their values in replies, and to sign in a test account`,
494
- `would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan ? `${plan.cmd} (in ${rel(plan.cwd)})` : "asks you how your app starts"}`,
696
+ `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`,
697
+ `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"}`,
495
698
  `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)`}`,
496
- `loads into app lib/trace.cjs (Node) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
699
+ `loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
497
700
  `agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
498
701
  `agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
499
702
  ``,
@@ -520,6 +723,8 @@ const attach = await call("POST", "/local/attach", { code, name: basename(root),
520
723
  if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
521
724
  box = attach.data.box;
522
725
  key = attach.data.key;
726
+ // A server that names no journal gets one for this session alone: never another account's edits.
727
+ door = openDoor(root, { owner: typeof attach.data.journal === "string" && attach.data.journal ? attach.data.journal : box });
523
728
 
524
729
  const list = join(work, "files.txt");
525
730
  writeFileSync(list, files.join("\n") + "\n");
@@ -535,6 +740,15 @@ const treeDigest = (() => {
535
740
  }
536
741
  return h.digest("hex");
537
742
  })();
743
+ // The commit this tree is. Your .git is never uploaded -- nothing of your history leaves this
744
+ // machine -- so the one sha the report needs is read here and sent as forty characters. A folder
745
+ // that is not a checkout sends nothing, and the report says so rather than inventing one.
746
+ const head = (() => {
747
+ try {
748
+ const sha = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
749
+ return /^[a-f0-9]{40}$/.test(sha) ? sha : "";
750
+ } catch { return ""; }
751
+ })();
538
752
  const archive = join(work, "tree.tgz");
539
753
  step("connecting");
540
754
  await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
@@ -545,7 +759,7 @@ const parts = Math.max(1, Math.ceil(bytes.length / PART));
545
759
  for (let off = 0; off < bytes.length; off += PART) {
546
760
  const last = off + PART >= bytes.length;
547
761
  step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
548
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
762
+ const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
549
763
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
550
764
  if (last) resumed = put.data?.resumed === true;
551
765
  }
@@ -575,7 +789,7 @@ function sourceChanged() {
575
789
  }
576
790
  // `watching` says whether a message sent to this app can be seen arriving: only an app this command
577
791
  // started carries the hook, and only a runtime the hook exists for.
578
- const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.alive()) });
792
+ const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
579
793
 
580
794
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
581
795
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
@@ -584,8 +798,8 @@ async function appLife() {
584
798
  for (let first = true; ; first = false) {
585
799
  const got = await startApp();
586
800
  if (got.port) { app = got; break; }
587
- await call("POST", `/local/${box}/stopped`, { said: mask(got.said ?? "").slice(-2000) }).catch(() => {});
588
- say(`${got.why} Fix it and save: it is started again by itself.`);
801
+ await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
802
+ say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
589
803
  if (first) say("leave this open. Ctrl-C disconnects.");
590
804
  await sourceChanged();
591
805
  say("saw your change, starting your app again");
@@ -654,7 +868,7 @@ async function close(code = 0) {
654
868
  closing = true;
655
869
  await call("DELETE", `/local/${box}`).catch(() => {});
656
870
  if (child?.pid) await stopApp(child.pid);
657
- const pending = door.pending();
871
+ const pending = door?.pending() ?? 0;
658
872
  if (pending) say(`${pending} agent edit${pending === 1 ? " is" : "s are"} waiting for you to keep or undo. Run the command again to review ${pending === 1 ? "it" : "them"} in the browser.`);
659
873
  await exec("rm", ["-rf", work]).catch(() => {});
660
874
  process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -14,6 +14,7 @@
14
14
  "lib/mint.mjs",
15
15
  "lib/pyhook/sitecustomize.py",
16
16
  "lib/replay.mjs",
17
+ "lib/sample.mjs",
17
18
  "lib/start.mjs",
18
19
  "lib/trace.cjs",
19
20
  "local.mjs"