cortad 0.1.6 → 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,15 +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 };
349
438
  const took = await takeOver(wanted);
350
439
  if (took) return took;
351
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");
352
441
  return { port: wanted, cmd: null };
353
442
  }
354
443
  const plan = startPlan({ root, typed: flag("--start"), onPath });
355
- const cmd = plan?.cmd ?? await ask("How do you start your app? (for example: npm run dev) ");
356
- 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
+ }
357
454
  appDir = plan?.cwd ?? root;
358
455
  pinned = pinnedNode();
359
456
  if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
@@ -362,7 +459,7 @@ async function startApp() {
362
459
  launched = { cmd, lifted };
363
460
  step(`starting your app: ${cmd}`);
364
461
  const up = await launch(180_000);
365
- 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) };
366
463
  // Already running: a second start dies on the port the first one holds. The one that is running
367
464
  // is the app, so it is used as it stands rather than treated as a failure.
368
465
  if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)/i.test(up.tail)) {
@@ -408,9 +505,9 @@ async function takeOver(port) {
408
505
  launched = { cmd: plan.cmd, lifted };
409
506
  step(`starting your app: ${plan.cmd}`);
410
507
  const up = await launch(180_000);
411
- if (up.port) return { port: up.port, cmd: plan.cmd, lifted: names };
508
+ if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
412
509
  if (up.tail) console.error(up.tail);
413
- fail("your app did not come back after the restart. Start it yourself, then run this again with --port.");
510
+ return { port: null, said: up.tail, why: "your app did not come back after the restart." };
414
511
  }
415
512
  // The process listening on a port.
416
513
  async function listenerOn(port) {
@@ -448,7 +545,56 @@ function pinnedNode() {
448
545
  }
449
546
  let pinned = { major: null, bin: null };
450
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.
451
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) {
452
598
  const { cmd, lifted } = launched;
453
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 });
454
600
  const mine = child;
@@ -544,13 +690,13 @@ if (explain) {
544
690
  console.log([
545
691
  `cortad --explain (nothing is sent or started by this)`,
546
692
  ``,
547
- `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`,
548
694
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
549
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`,
550
- `env files read here only: to hide their values in replies, and to sign in a test account`,
551
- `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"}`,
552
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)`}`,
553
- `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`,
554
700
  `agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
555
701
  `agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
556
702
  ``,
@@ -577,6 +723,8 @@ const attach = await call("POST", "/local/attach", { code, name: basename(root),
577
723
  if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
578
724
  box = attach.data.box;
579
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 });
580
728
 
581
729
  const list = join(work, "files.txt");
582
730
  writeFileSync(list, files.join("\n") + "\n");
@@ -592,6 +740,15 @@ const treeDigest = (() => {
592
740
  }
593
741
  return h.digest("hex");
594
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
+ })();
595
752
  const archive = join(work, "tree.tgz");
596
753
  step("connecting");
597
754
  await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
@@ -602,7 +759,7 @@ const parts = Math.max(1, Math.ceil(bytes.length / PART));
602
759
  for (let off = 0; off < bytes.length; off += PART) {
603
760
  const last = off + PART >= bytes.length;
604
761
  step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
605
- 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 });
606
763
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
607
764
  if (last) resumed = put.data?.resumed === true;
608
765
  }
@@ -632,7 +789,7 @@ function sourceChanged() {
632
789
  }
633
790
  // `watching` says whether a message sent to this app can be seen arriving: only an app this command
634
791
  // started carries the hook, and only a runtime the hook exists for.
635
- 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)) });
636
793
 
637
794
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
638
795
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
@@ -641,8 +798,8 @@ async function appLife() {
641
798
  for (let first = true; ; first = false) {
642
799
  const got = await startApp();
643
800
  if (got.port) { app = got; break; }
644
- await call("POST", `/local/${box}/stopped`, { said: mask(got.said ?? "").slice(-2000) }).catch(() => {});
645
- 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.`);
646
803
  if (first) say("leave this open. Ctrl-C disconnects.");
647
804
  await sourceChanged();
648
805
  say("saw your change, starting your app again");
@@ -711,7 +868,7 @@ async function close(code = 0) {
711
868
  closing = true;
712
869
  await call("DELETE", `/local/${box}`).catch(() => {});
713
870
  if (child?.pid) await stopApp(child.pid);
714
- const pending = door.pending();
871
+ const pending = door?.pending() ?? 0;
715
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.`);
716
873
  await exec("rm", ["-rf", work]).catch(() => {});
717
874
  process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.6",
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"