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/lib/door.mjs +5 -2
- package/lib/identities.mjs +15 -3
- package/lib/mint.mjs +18 -2
- package/lib/pyhook/sitecustomize.py +211 -16
- package/lib/replay.mjs +72 -6
- package/lib/sample.mjs +218 -0
- package/lib/start.mjs +69 -14
- package/lib/trace.cjs +184 -12
- package/local.mjs +179 -22
- package/package.json +2 -1
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)))
|
|
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
|
-
|
|
356
|
-
|
|
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:
|
|
508
|
+
if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
|
|
412
509
|
if (up.tail) console.error(up.tail);
|
|
413
|
-
|
|
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}
|
|
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
|
|
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?.
|
|
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
|
|
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
|
|
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.
|
|
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"
|