@makerstufferupper/flypi 0.1.4 → 0.1.6

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.
@@ -0,0 +1 @@
1
+ .revealed.svelte-19l53a6{color:var(--ink)}.memory-body.svelte-3w3u32{color:var(--ink);flex-basis:100%}.memory-row.svelte-3w3u32{align-items:flex-start}.memory-why.svelte-3w3u32,.memory-history.svelte-3w3u32,.memory-uses.svelte-3w3u32{gap:.25rem;width:100%;margin:.3rem 0 0;padding:0;list-style:none;display:grid}.memory-why.svelte-3w3u32 li:where(.svelte-3w3u32),.memory-history.svelte-3w3u32 li:where(.svelte-3w3u32){flex-wrap:wrap;align-items:center;gap:.4rem;display:flex}.search.svelte-3w3u32{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.2rem .3rem}.preimage.svelte-3w3u32 summary:where(.svelte-3w3u32){color:var(--muted);cursor:pointer;font-size:11px}.preimage.svelte-3w3u32 pre:where(.svelte-3w3u32){max-height:12rem}.wiki-page.svelte-3w3u32 h4:where(.svelte-3w3u32){margin:.8rem 0 .2rem;font-size:13px}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#fbfbfd;--panel:#fff;--line:#e3e4e9;--ink:#17181d;--muted:#6b6f7a;--accent:#2f6f4f;--error:#a3262a;--surface:#f4f5f7;--code:#f6f7f9;--queue:#f2f7f4}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:#13151a;--panel:#1b1e25;--line:#2c303a;--ink:#e6e8ee;--muted:#98a0ae;--accent:#6cb892;--error:#e08084;--surface:#232730;--code:#1f232b;--queue:#1d2b24}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0}.layout{grid-template-columns:var(--sidebar-w,15rem) .5rem minmax(0, 1fr) .5rem var(--activity-w,22rem);gap:.25rem;height:100vh;padding:.75rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating{text-align:center;height:100vh;color:var(--muted);place-content:center;gap:.3rem;padding:1rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating h2{color:var(--ink);margin:0 0 .3rem;font-size:1.1rem}.updating p{margin:0}.sidebar,.chat,.activity-panel,.settings-view{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:.75rem;overflow:auto}.gutter{cursor:col-resize;touch-action:none;background:var(--line);border-radius:3px}.gutter:hover,.gutter:focus-visible{background:var(--accent)}.activity-panel{flex-direction:column;display:flex}.sidebar h1{margin:0 0 .5rem;font-size:1rem}.sidebar .port{color:var(--muted);font-weight:400}.sidebar .update{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.3rem;margin:0 0 .5rem;font-size:.8rem;display:flex}.panel-head{flex-wrap:wrap;align-items:center;gap:.35rem;margin-bottom:.5rem;display:flex}.panel-head h1{flex:1;margin:0}.panel-actions{gap:.35rem;margin-bottom:.5rem;display:flex}.icon{flex:none;padding:.2rem .35rem}.panel-head .logo{border-radius:6px;flex:none;width:2.5rem;height:2.5rem}.icon svg{width:1.05rem;height:1.05rem;display:block}.field{color:var(--muted);margin-bottom:.5rem;font-size:12px;display:block}.field select{width:100%;margin-top:.15rem}.sessions{margin:.5rem 0;padding:0;list-style:none}.sessions li{align-items:center;gap:.15rem;display:flex}.sessions .session,.sessions input.name{flex:1;min-width:0}.sessions .run-dot{background:var(--accent,#3b82f6);border-radius:50%;flex:none;width:.45rem;height:.45rem;animation:1.4s ease-in-out infinite run-pulse}@media (prefers-reduced-motion:reduce){.sessions .run-dot{animation:none}}@keyframes run-pulse{0%,to{opacity:1}50%{opacity:.3}}.sessions .unread-dot{background:var(--accent,#3b82f6);border-radius:50%;flex:none;width:.45rem;height:.45rem}.sessions li.unread .session{font-weight:700}.sessions .remove{opacity:.6}.sessions .remove:hover{opacity:1}.sessions .session{text-align:left;white-space:nowrap;text-overflow:ellipsis;background:0 0;border:0;border-radius:6px;width:100%;overflow:hidden}.sessions .session:hover{background:var(--surface)}.sessions .session.current{background:var(--queue)}.sessions input.name{background:var(--queue);width:100%;color:inherit;border:0;border-radius:6px;font-weight:600}.sessions input.name:hover{background:var(--queue)}button,select,input,textarea{font:inherit;color:inherit}button{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.25rem .5rem}button:hover{border-color:var(--accent)}input:not([type=checkbox]),select,textarea{border:1px solid var(--line);background:var(--surface);border-radius:6px;padding:.3rem .4rem}textarea{width:100%}.chat{flex-direction:column;display:flex}.messages{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.msg{border-bottom:1px solid var(--line);padding:.4rem 0}.msg .role{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-size:11px;display:block}.msg[data-role=user] .role{color:var(--accent)}.usage{color:var(--muted);float:right;font-size:11px}.msg .fork{opacity:.45;margin-top:.2rem}.msg:hover .fork{opacity:.8}.msg .fork:hover{opacity:1}.markdown pre,.tool-output,.thinking pre,.system-prompt,.orphan-result,.memory-block,.cache-usage,.cfg-log{background:var(--code);border:1px solid var(--line);white-space:pre-wrap;border-radius:6px;padding:.4rem;font-family:ui-monospace,monospace;font-size:12.5px;overflow:auto}.markdown pre{margin:.35rem 0}.markdown code{font-family:ui-monospace,monospace}details.thinking summary{cursor:pointer;color:var(--muted);font-size:12px}.tool-card{border:1px solid var(--line);border-radius:6px;margin:.35rem 0;padding:.35rem .5rem}.tool-card[data-state=running]{border-color:var(--accent)}.tool-card[data-state=error]{border-color:var(--error)}.tool-card header{align-items:baseline;gap:.5rem;display:flex}.tool-name{font-weight:600}.tool-args{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px;overflow:hidden}.tool-state{color:var(--muted);font-size:11px}.queue{margin:.35rem 0 0;padding:0;list-style:none}.queued{border-left:3px solid var(--accent);background:var(--queue);padding:.25rem .4rem;font-size:12.5px}.extension-output{margin:.35rem 0 0;padding:0;list-style:none}.extension-note{border-left:3px solid var(--accent);background:var(--queue);white-space:pre-wrap;padding:.25rem .4rem;font-size:12.5px}.extension-note[data-level=warning]{border-left-color:var(--muted)}.extension-note[data-level=error]{border-left-color:var(--error)}.extension-dialog{border:1px solid var(--line);background:var(--surface);border-radius:8px;margin:.35rem 0 0;padding:.4rem .5rem}.extension-dialog .ask{white-space:pre-wrap;margin:0 0 .35rem;font-size:13px}.answers{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.answer-input{align-items:center;gap:.35rem;display:flex}.commands{flex-wrap:wrap;gap:.3rem;margin:.35rem 0 0;padding:0;list-style:none;display:flex}.command{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.15rem .4rem;font-size:12px}.command .desc{color:var(--muted);margin-left:.3rem}.attachments{flex-wrap:wrap;gap:.35rem;margin:0 0 .35rem;padding:0;list-style:none;display:flex}.attachment{border:1px solid var(--line);background:var(--surface);border-radius:6px;align-items:center;gap:.35rem;padding:.2rem .35rem;font-size:12px;display:flex}.attachment .thumb{object-fit:cover;border-radius:4px;flex:none;width:1.5rem;height:1.5rem}.attachment .name{text-overflow:ellipsis;white-space:nowrap;max-width:14rem;overflow:hidden}.attachment .remove{color:var(--muted);cursor:pointer;background:0 0;border:0;padding:0 .15rem}.attachment .remove:hover{color:var(--error)}.attachment-image{border:1px solid var(--line);border-radius:6px;max-width:min(100%,32rem);max-height:24rem;margin:.35rem 0;display:block}.error{color:var(--error)}.muted{color:var(--muted)}.notice{color:var(--muted);border-left:2px solid var(--line);padding:.15rem 0 .15rem .5rem}.picker{gap:.5rem;margin-top:.5rem;display:flex}.picker .field{flex:1;margin-bottom:0}.composer{border-top:1px solid var(--line);padding-top:.5rem}.controls{flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.35rem;display:flex}.controls .stop{border-color:var(--error);color:var(--error)}.offline{color:var(--muted);font-size:12px}.metrics{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.6rem;margin-left:auto;font-size:11.5px;display:flex}.metrics b{color:var(--ink);font-variant-numeric:tabular-nums;font-weight:600}.gauge{flex:none;width:1.15rem;height:1.15rem;position:relative}.gauge svg{width:100%;height:100%;position:absolute;inset:0;transform:rotate(-90deg)}.gauge circle{fill:none;stroke-width:3px}.gauge .track{stroke:var(--line)}.gauge .fill{stroke:var(--accent);stroke-linecap:round;transition:stroke-dasharray .3s}.activity-panel h2{text-transform:uppercase;color:var(--muted);margin:0 0 .5rem;font-size:12px}.activity-panel h2 .count{font-weight:400}.activity-panel footer{border-top:1px solid var(--line);padding-top:.5rem}.activity{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.activity li{border-bottom:1px solid var(--line);padding:.3rem 0}.activity li .tool-card{margin:0}.activity-empty{color:var(--muted);font-size:12px}.settings-view{grid-column:3/-1}.settings-view .panel-head h2{flex:1;margin:0;font-size:1rem}.settings-view .cfg-note{color:var(--muted);margin:0 0 .5rem;font-size:12px}.settings-view .cfg-note code{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-stack{gap:1rem;max-width:46rem;display:grid}.cfg-section{align-items:center;gap:.5rem;margin:.9rem 0 .25rem;display:flex}.cfg-section h3{flex:1;margin:0;font-size:13px}.cfg-list.cfg-plain{border:0}.cfg-list.cfg-plain li{border-bottom:0;padding-left:0}.cfg-list{border:1px solid var(--line);border-radius:6px;margin:0;padding:0;list-style:none}.cfg-list li{border-bottom:1px solid var(--line);flex-wrap:wrap;align-items:center;gap:.4rem;padding:.3rem .4rem;display:flex}.cfg-list li:last-child{border-bottom:0}.cfg-list code{min-width:8rem;color:var(--muted);overflow-wrap:anywhere;flex:1;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-list .cfg-name{color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:0;font-weight:600}.cfg-list .field{flex:none;margin:0}.cfg-list .field select{width:auto;margin-top:0}.cfg-list .icon{opacity:.6;flex:none}.cfg-list .icon:hover{opacity:1}.cfg-list .icon:disabled{opacity:.2;cursor:default}.cfg-list .remove{color:var(--error)}.cfg-default{color:var(--accent);white-space:nowrap;align-items:center;gap:.25rem;font-size:11px;font-weight:600;display:flex}.cfg-default select{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.1rem .2rem;font-weight:400}.cfg-list .cfg-off,.cfg-list .cfg-none{color:var(--muted);font-size:11px}.cfg-list .cfg-state{color:var(--muted);white-space:nowrap;font-size:11px}.cfg-list .cfg-state.connected{color:var(--accent)}.cfg-list .cfg-state.failed,.cfg-list .cfg-state.needs-auth,.cfg-list .cfg-state-error{color:var(--error)}.cfg-log{max-height:12rem;margin:.4rem 0 0}.cfg-signin{border-top:1px solid var(--line);flex:1 0 100%;margin-top:.3rem;padding-top:.35rem}.cfg-signin p{overflow-wrap:anywhere;margin:.2rem 0;font-size:12px}.cfg-signin-form{flex-wrap:wrap;align-items:end;gap:.4rem;display:flex}.cfg-signin-form .field{flex:18rem;margin:0}.cfg-signin-form input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal{background:var(--panel);width:min(30rem,92vw);color:var(--ink);border:1px solid var(--line);border-radius:8px;max-height:78vh;margin:12vh auto auto;padding:.9rem;overflow:auto}.cfg-modal::backdrop{background:#0000008c}.cfg-modal .cfg-section{margin-top:0}.cfg-modal .field{margin-bottom:.35rem}.cfg-modal input,.cfg-modal select{width:100%;font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem}.cfg-modal input:disabled{color:var(--muted)}.cfg-inline{align-items:end;gap:.5rem;display:flex}.cfg-inline .field:first-child{flex:1}.cfg-install{align-items:end;gap:.5rem;margin-bottom:.4rem;display:flex}.cfg-install .field{flex:18rem;margin:0}.cfg-install input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal textarea,.cfg-raw{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);resize:vertical;border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-modal details{margin:.45rem 0}.cfg-modal summary{color:var(--accent);cursor:pointer;font-size:12px}.cfg-modal details .cfg-note{margin:.35rem 0}.cfg-modal .error{font-size:12px}.cfg-modal .cfg-note{color:var(--muted);margin:0 0 .6rem;font-size:12px}.cfg-enabled{white-space:nowrap;align-items:center;gap:.25rem;display:flex}.cfg-enabled input{margin:0;width:auto!important}.cfg-actions{gap:.4rem;display:flex}.cfg-actions .remove{color:var(--error)}.linklike{color:var(--accent);font:inherit;background:0 0;border:0;padding:0;text-decoration:underline}.cfg-errors{max-width:46rem;margin:0 0 .5rem;padding:0;font-size:12px;list-style:none}
@@ -8,8 +8,8 @@
8
8
  one of the two files and there is no script in this document to do anything else. -->
9
9
  <link rel="icon" href="/flypi-logo-light.jpeg" media="(prefers-color-scheme: light)" />
10
10
  <link rel="icon" href="/flypi-logo-dark.jpeg" media="(prefers-color-scheme: dark)" />
11
- <script type="module" crossorigin src="/assets/index-Dv6ZykyY.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CBuNmrDT.css">
11
+ <script type="module" crossorigin src="/assets/index-0MqV7dOT.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BCcOueOd.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerstufferupper/flypi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Local multi-model agent tool with a browser UI and a personal knowledge wiki, built on the pi SDK.",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "probe:boundary": "tsx scripts/turn-boundary.ts",
30
30
  "start": "node scripts/start.mjs",
31
31
  "dev": "vite",
32
+ "dev:container": "node scripts/dev-container.mjs",
32
33
  "models:warm": "tsx scripts/warm-models.ts",
33
34
  "build": "vite build",
34
35
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
@@ -44,7 +45,8 @@
44
45
  "serve": "tsx scripts/serve.ts",
45
46
  "daemon": "tsx scripts/serve-daemon.ts",
46
47
  "flypi": "node scripts/flypi.mjs",
47
- "release": "node scripts/release.mjs"
48
+ "release": "node scripts/release.mjs",
49
+ "release:check": "node scripts/release-check.mjs"
48
50
  },
49
51
  "dependencies": {
50
52
  "@earendil-works/pi-coding-agent": "^1.4.0",
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The local dev container, rebuilt from the working tree (docs/release.md, "Deploying something that is
4
+ * *not* released"). One command for what used to be four.
5
+ *
6
+ * npm run dev:container
7
+ *
8
+ * build → pack → `docker build` with `docker/Dockerfile.local` and the tarball's directory as the build
9
+ * context (the repo's `.dockerignore` excludes `*.tgz`, so the repo is never the context) → replace the
10
+ * `flypi` container with one off the new image → wait for `/healthz`.
11
+ *
12
+ * It removes the `flypi` container and the `flypi:local-*` images older than the one it built. It never
13
+ * removes the `flypi-data` volume: that holds the memory models (~933 MB), and re-downloading them is the
14
+ * slowest part of the next start — which is also why the healthcheck gets a 15-minute start period.
15
+ *
16
+ * No gates here; `npm run release:check` is the rehearsal that runs them. This is the fast path, and it
17
+ * touches only the names above: never a published image, never a container this script did not build.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const NAME = "flypi";
25
+ const PORT = 8791;
26
+ const VOLUME = "flypi-data";
27
+
28
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
29
+ console.log(`flypi dev container — docs/release.md "Deploying something that is *not* released"
30
+
31
+ npm run dev:container build → pack → image → replace the ${NAME} container on 127.0.0.1:${PORT}
32
+
33
+ Removes: the ${NAME} container, and flypi:local-* images older than the one it just built.
34
+ Keeps: the ${VOLUME} volume — the memory models live there, and this script never deletes a volume.
35
+ Tags: flypi:local-<commit sha>, plus -dirty if the tree is not clean, because the package version
36
+ inside such an image collides with the published one.
37
+ Stages: build → pack → image → replace → serve → prune`);
38
+ process.exit(0);
39
+ }
40
+
41
+ const version = JSON.parse(readFileSync("package.json", "utf8")).version;
42
+ const die = (message) => {
43
+ console.error(`dev:container: ${message}`);
44
+ process.exit(1);
45
+ };
46
+ function git(args) {
47
+ const result = spawnSync("git", args, { encoding: "utf8" });
48
+ if (result.status !== 0) die(`git ${args.join(" ")}: ${result.stderr.trim()}`);
49
+ return result.stdout.trim();
50
+ }
51
+ /** One step. It stops at the first failure: a half-replaced dev container is worse than an old one. */
52
+ function run(command, args, { quiet = false } = {}) {
53
+ const result = spawnSync(command, args, { encoding: "utf8" });
54
+ if (result.status !== 0) {
55
+ die(`${command} ${args.join(" ")} exited ${result.status}${result.stderr ? `: ${result.stderr.trim().split("\n").slice(-3).join(" ")}` : ""}`);
56
+ }
57
+ if (!quiet && result.stdout) process.stdout.write(result.stdout);
58
+ return result;
59
+ }
60
+ const step = (what) => console.log(`\n${what}`);
61
+
62
+ const sha = git(["rev-parse", "--short", "HEAD"]);
63
+ const tag = `flypi:local-${sha}${git(["status", "--porcelain"]) ? "-dirty" : ""}`;
64
+ const packDir = mkdtempSync(join(tmpdir(), "flypi-dev-"));
65
+
66
+ try {
67
+ step("build — dist/ui, which the tarball ships");
68
+ run("npm", ["run", "build"]);
69
+
70
+ step("pack — the same artifact npm would publish, installed by the image");
71
+ run("npm", ["pack", "--pack-destination", packDir]);
72
+ const tgz = `makerstufferupper-flypi-${version}.tgz`;
73
+ copyFileSync("docker/Dockerfile.local", join(packDir, "Dockerfile"));
74
+
75
+ step(`image — ${tag} from ${tgz}`);
76
+ run("docker", ["build", "-q", "--build-arg", `FLYPI_TGZ=${tgz}`, "-t", tag, packDir]);
77
+
78
+ step(`replace — container ${NAME} on 127.0.0.1:${PORT}, volume ${VOLUME} kept`);
79
+ // `rm -f` on a name that is not there exits 1; that is the normal first run, not a failure.
80
+ spawnSync("docker", ["rm", "-f", NAME], { encoding: "utf8" });
81
+ run("docker", [
82
+ "run",
83
+ "-d",
84
+ "--name",
85
+ NAME,
86
+ "-p",
87
+ `127.0.0.1:${PORT}:${PORT}`,
88
+ "-v",
89
+ `${VOLUME}:/data`,
90
+ "-e",
91
+ "FLYPI_ROOT=/data",
92
+ // The image's own CMD downloads the memory models on a fresh state root, so "unhealthy" for the
93
+ // first quarter hour is the download, not a broken build.
94
+ "--health-cmd",
95
+ `node -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"`,
96
+ "--health-interval",
97
+ "30s",
98
+ "--health-timeout",
99
+ "5s",
100
+ "--health-start-period",
101
+ "900s",
102
+ "--health-retries",
103
+ "3",
104
+ tag,
105
+ ]);
106
+
107
+ step(`serve — waiting on http://127.0.0.1:${PORT}/healthz`);
108
+ const deadline = Date.now() + 180_000;
109
+ let body = null;
110
+ while (Date.now() < deadline && !body) {
111
+ try {
112
+ const response = await fetch(`http://127.0.0.1:${PORT}/healthz`);
113
+ if (response.ok) body = await response.json();
114
+ } catch {}
115
+ if (!body) await new Promise((resolve) => setTimeout(resolve, 2000));
116
+ }
117
+ if (!body) die(`${NAME} never answered /healthz — docker logs ${NAME}`);
118
+ console.log(` ok flypi ${body.version} · pi ${body.pi?.version ?? "?"} · ${tag}`);
119
+
120
+ step("prune — the flypi:local-* images this replaces");
121
+ const stale = run("docker", ["image", "ls", "flypi:local-*", "--format", "{{.Repository}}:{{.Tag}}"], { quiet: true }).stdout
122
+ .split("\n")
123
+ .map((image) => image.trim())
124
+ .filter((image) => image && image !== tag);
125
+ for (const image of stale) run("docker", ["rmi", image], { quiet: true });
126
+ console.log(stale.length ? ` removed ${stale.join(", ")}` : " nothing older to remove");
127
+
128
+ console.log(`\n${NAME} is on http://127.0.0.1:${PORT} — docker logs -f ${NAME} to watch it`);
129
+ } finally {
130
+ rmSync(packDir, { recursive: true, force: true });
131
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The release rehearsal (docs/release-checks.md). It runs the checks that have to pass before a version goes
4
+ * to the registry, and with `--published` the ones that prove what landed there is what was built.
5
+ *
6
+ * node scripts/release-check.mjs # gates → pack → npm-install → image
7
+ * node scripts/release-check.mjs --published # registry + pushed image + run the published image
8
+ * node scripts/release-check.mjs --skip-gates # the docker half only
9
+ * node scripts/release-check.mjs --keep # leave the containers and volumes for a look
10
+ *
11
+ * It never publishes, pushes an image, or touches a container it did not create: everything it makes is named
12
+ * `flypi-check-*` on ports 8792/8793, so the serving `flypi` container on 8791 and its `flypi-data` volume are
13
+ * out of reach. Exit code is the number of failed checks.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ const argv = process.argv.slice(2);
21
+ const has = (flag) => argv.includes(flag);
22
+ const valueOf = (flag) => {
23
+ const at = argv.indexOf(flag);
24
+ return at === -1 ? undefined : argv[at + 1];
25
+ };
26
+ const version = valueOf("--version") ?? JSON.parse(readFileSync("package.json", "utf8")).version;
27
+
28
+ if (has("--help") || has("-h")) {
29
+ console.log(`flypi release rehearsal — docs/release-checks.md
30
+
31
+ npm run release:check gates → pack → npm-install → image
32
+ npm run release:check -- --published the registry and the pushed image, after the publish
33
+ npm run release:check -- --skip-gates the docker half only
34
+ npm run release:check -- --keep leave the containers and volumes for a look
35
+ npm run release:check -- --version <v> check a version other than package.json's
36
+ npm run release:check -- --help this list (-h is the same)
37
+
38
+ It never publishes or pushes, and only touches containers named flypi-check-*.`);
39
+ process.exit(0);
40
+ }
41
+ const IMAGE = "makerstufferupper/flypi";
42
+ const TARBALL_URL = `https://registry.npmjs.org/@makerstufferupper/flypi/-/flypi-${version}.tgz`;
43
+
44
+ let failures = 0;
45
+ const ok = (name, detail) => console.log(` ok ${name}${detail ? ` — ${detail}` : ""}`);
46
+ const bad = (name, detail) => {
47
+ failures++;
48
+ console.log(` FAIL ${name}${detail ? ` — ${detail}` : ""}`);
49
+ };
50
+ /** One check: run something, say whether it worked, keep going either way. */
51
+ function check(name, command, args, options = {}) {
52
+ const result = spawnSync(command, args, { encoding: "utf8", ...options });
53
+ if (result.status === 0) {
54
+ ok(name, options.show && result.stdout ? result.stdout.trim().split("\n").at(-1) : undefined);
55
+ return result;
56
+ }
57
+ bad(name, `${command} ${args.join(" ")} exited ${result.status}${result.stderr ? `: ${result.stderr.trim().split("\n")[0]}` : ""}`);
58
+ return result;
59
+ }
60
+
61
+ const packDir = mkdtempSync(join(tmpdir(), "flypi-release-"));
62
+ const created = { containers: [], volumes: [], images: [] };
63
+
64
+ function docker(args, options = {}) {
65
+ return spawnSync("docker", args, { encoding: "utf8", ...options });
66
+ }
67
+ /** A container of our own, on a port and a volume nothing else is using. */
68
+ function freshContainer(name, image, port, volume, command) {
69
+ docker(["rm", "-f", name]);
70
+ docker(["volume", "rm", volume]);
71
+ created.containers.push(name);
72
+ created.volumes.push(volume);
73
+ const run = docker(["run", "-d", "--name", name, "-p", `127.0.0.1:${port}:${port}`, "-v", `${volume}:/data`, "-e", "FLYPI_ROOT=/data", image, ...command]);
74
+ return run.status === 0 ? name : (bad(`docker run ${image}`, run.stderr.trim().split("\n")[0]), null);
75
+ }
76
+ /** Poll /healthz — the first start of a fresh state root is not instant. */
77
+ async function health(port, seconds = 180) {
78
+ const deadline = Date.now() + seconds * 1000;
79
+ while (Date.now() < deadline) {
80
+ try {
81
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`);
82
+ if (response.ok) return await response.json();
83
+ } catch {}
84
+ await new Promise((resolve) => setTimeout(resolve, 2000));
85
+ }
86
+ return null;
87
+ }
88
+ /** The three things every serving flypi owes: its own version, the pi it loaded, and a page. */
89
+ async function assertServing(name, port, expectVersion) {
90
+ const body = await health(port);
91
+ if (!body) return bad(`${name}: /healthz never answered`, `port ${port}`);
92
+ body.version === expectVersion
93
+ ? ok(`${name}: /healthz reports flypi ${body.version}`)
94
+ : bad(`${name}: /healthz version`, `expected ${expectVersion}, got ${body.version}`);
95
+ body.pi?.version
96
+ ? ok(`${name}: it loaded pi ${body.pi.version}`, body.pi.pinned ? "pinned" : "not pinned")
97
+ : bad(`${name}: /healthz names no pi`, "Phase 8.1 — the loaded version must be reported");
98
+ const page = spawnSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `http://127.0.0.1:${port}/`], { encoding: "utf8" });
99
+ page.stdout === "200" ? ok(`${name}: GET / is 200`) : bad(`${name}: GET /`, `got ${page.stdout || "no answer"}`);
100
+ return body;
101
+ }
102
+ /**
103
+ * The agent has a bash tool in here, so the tools a turn reaches for have to be present *and* working: git over
104
+ * https, git-lfs, curl, wget. Slim has none of them, and a git-lfs binary without its filters registered still
105
+ * checks in an LFS file's contents rather than a pointer — so this tracks one and reads the blob back out.
106
+ */
107
+ function hasTools(name) {
108
+ const probe = docker([
109
+ "exec",
110
+ name,
111
+ "sh",
112
+ "-c",
113
+ "git --version >/dev/null && curl --version >/dev/null && wget --version >/dev/null" +
114
+ " && cd /tmp && rm -rf lfs-check && git init -q lfs-check && cd lfs-check && git lfs track '*.bin' >/dev/null" +
115
+ " && echo hello > a.bin && git -c user.email=flypi@example.com -c user.name=flypi add a.bin && git cat-file -p :a.bin",
116
+ ]);
117
+ /git-lfs\.github\.com\/spec\/v1/.test(probe.stdout ?? "")
118
+ ? ok(`${name}: it has git + git-lfs, curl and wget`)
119
+ : bad(`${name}: git + git-lfs, curl and wget`, `exit ${probe.status}: ${(probe.stderr || probe.stdout || "").trim().split("\n")[0]}`);
120
+ }
121
+ function cleanup() {
122
+ if (has("--keep")) return console.log(`\nkept: ${created.containers.join(", ")} · volumes ${created.volumes.join(", ")} · ${packDir}`);
123
+ for (const name of created.containers) docker(["rm", "-f", name]);
124
+ for (const volume of created.volumes) docker(["volume", "rm", volume]);
125
+ for (const image of created.images) docker(["rmi", image]);
126
+ rmSync(packDir, { recursive: true, force: true });
127
+ }
128
+
129
+ // ── stages ────────────────────────────────────────────────────────────────────────────────────────────────
130
+
131
+ function gates() {
132
+ console.log(`gates — the suite this version would be published with`);
133
+ check("tsc --noEmit", "npm", ["run", "typecheck"]);
134
+ check("npm test", "npm", ["test"]);
135
+ check("npm run smoke", "npm", ["run", "smoke"]);
136
+ check("npm run probe:pi", "npm", ["run", "probe:pi"]);
137
+ }
138
+
139
+ function pack() {
140
+ console.log(`\npack — the artifact, before anything installs it`);
141
+ check("npm run build (dist/ui)", "npm", ["run", "build"]);
142
+ check(`npm pack`, "npm", ["pack", "--pack-destination", packDir]);
143
+ const tgz = `makerstufferupper-flypi-${version}.tgz`;
144
+ const packed = spawnSync("tar", ["xzOf", join(packDir, tgz), "package/package.json"], { encoding: "utf8" });
145
+ if (packed.status !== 0) return bad(`read ${tgz}`, "npm pack produced nothing readable");
146
+ const pkg = JSON.parse(packed.stdout);
147
+ pkg.version === version ? ok(`tarball version ${pkg.version}`) : bad("tarball version", `package.json says ${pkg.version}`);
148
+ const self = Object.keys(pkg.dependencies).filter((d) => d.includes("makerstufferupper"));
149
+ self.length ? bad("no self-dependency", `${self.join(", ")} — flypi must not depend on flypi`) : ok("no self-dependency in dependencies");
150
+ const listed = spawnSync("tar", ["tzf", join(packDir, tgz)], { encoding: "utf8" }).stdout.split("\n");
151
+ for (const file of ["dist/ui/index.html", "bin/flypi.mjs", "src/daemon/pi.ts"]) {
152
+ listed.includes(`package/${file}`) ? ok(`ships ${file}`) : bad(`ships ${file}`, "missing from the tarball — check package.json `files`");
153
+ }
154
+ const stray = listed.filter((f) => /package\/(\.scratch|\.flypi|node_modules|coverage)\//.test(f));
155
+ stray.length ? bad("no stray files", stray.slice(0, 3).join(", ")) : ok("nothing stray (.scratch, .flypi, node_modules, coverage)");
156
+ return tgz;
157
+ }
158
+
159
+ async function npmInstall(tgz) {
160
+ console.log(`\nnpm-install — the tarball in a clean container, the way \`npm install -g\` does it`);
161
+ const name = "flypi-check-npm";
162
+ const container = freshContainer(name, "node:24-slim", 8792, "flypi-check-npm-data", ["sleep", "7200"]);
163
+ if (!container) return;
164
+ if (docker(["cp", join(packDir, tgz), `${name}:/tmp/flypi.tgz`]).status !== 0) return bad("docker cp the tarball");
165
+ check("npm i -g /tmp/flypi.tgz", "docker", ["exec", name, "npm", "i", "-g", "--no-audit", "--no-fund", "/tmp/flypi.tgz"]);
166
+ const installed = docker(["exec", name, "node", "-e", `console.log(require("/usr/local/lib/node_modules/@makerstufferupper/flypi/package.json").version)`]);
167
+ installed.stdout?.trim() === version ? ok(`installed package reports ${version}`) : bad("installed package version", installed.stdout?.trim() || installed.stderr?.trim());
168
+ docker(["exec", "-d", name, "flypi", "start", "--foreground", "--port", "8792", "--host", "0.0.0.0", "--embeddings", "none", "--judge", "none"]);
169
+ await assertServing(name, 8792, version);
170
+ }
171
+
172
+ async function image(tgz) {
173
+ console.log(`\nimage — the published image's runtime shape, built from the same tarball`);
174
+ copyFileSync("docker/Dockerfile.local", join(packDir, "Dockerfile"));
175
+ const tag = `flypi-check:${version}`;
176
+ const built = docker(["build", "-q", "--build-arg", `FLYPI_TGZ=${tgz}`, "-t", tag, packDir]);
177
+ if (built.status !== 0) return bad("docker build", built.stderr.trim().split("\n").slice(-3).join(" "));
178
+ created.images.push(tag);
179
+ ok(`docker build ${tag}`);
180
+ const name = "flypi-check-image";
181
+ // Keyword-only memory: the image's own CMD would fetch ~900 MB of models into a volume that is deleted after.
182
+ const container = freshContainer(name, tag, 8793, "flypi-check-image-data", ["flypi", "start", "--foreground", "--port", "8793", "--host", "0.0.0.0", "--embeddings", "none", "--judge", "none"]);
183
+ if (!container) return;
184
+ await assertServing(name, 8793, version);
185
+ // The container must refuse to move pi in one sentence, not a stack trace (docs/pi-upgrades.md 8.3).
186
+ const refused = docker(["exec", name, "flypi", "pi", "use", "0.0.0"]);
187
+ /std|not writable|not the npm global/.test(`${refused.stdout}${refused.stderr}`) && refused.status !== 0
188
+ ? ok("flypi pi use refuses in a container, in one sentence")
189
+ : bad("flypi pi use in a container", `exit ${refused.status}: ${(refused.stderr || refused.stdout).trim().split("\n")[0]}`);
190
+ const state = docker(["exec", name, "sh", "-c", "ls /data/.flypi/pi 2>&1"]);
191
+ /state/.test(state.stdout) ? bad("a refused switch leaves no pin behind", state.stdout.trim()) : ok("a refused switch left nothing behind");
192
+ hasTools(name);
193
+ }
194
+
195
+ async function published() {
196
+ console.log(`\npublished — that ${version} is really in the registry, and the image installs it`);
197
+ // The metadata and the tarball propagate at different speeds: `npm view` can answer minutes before the
198
+ // tarball URL stops 404-ing, which is what kills a pinned image build. Poll the URL, not the tag.
199
+ let code = "000";
200
+ for (let i = 0; i < 30 && code !== "200"; i++) {
201
+ code = spawnSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", TARBALL_URL], { encoding: "utf8" }).stdout;
202
+ if (code !== "200") await new Promise((resolve) => setTimeout(resolve, 10000));
203
+ }
204
+ code === "200" ? ok("the registry serves the tarball") : bad("the registry serves the tarball", `${code} after 5 min — ${TARBALL_URL}`);
205
+ const latest = spawnSync("npm", ["view", "@makerstufferupper/flypi", "version"], { encoding: "utf8" });
206
+ latest.stdout?.trim() === version ? ok(`npm's latest is ${version}`) : bad("npm's latest", `the registry answers ${latest.stdout?.trim() || "nothing"}`);
207
+
208
+ const inspect = docker(["buildx", "imagetools", "inspect", `${IMAGE}:${version}`]);
209
+ const platforms = ["linux/amd64", "linux/arm64"].filter((p) => inspect.stdout?.includes(p));
210
+ platforms.length === 2 ? ok("the pushed image carries both platforms") : bad("the pushed image's platforms", platforms.join(", ") || "the manifest was not readable");
211
+
212
+ const name = "flypi-check-published";
213
+ const container = freshContainer(name, `${IMAGE}:${version}`, 8793, "flypi-check-published-data", ["flypi", "start", "--foreground", "--port", "8793", "--host", "0.0.0.0", "--embeddings", "none", "--judge", "none"]);
214
+ if (!container) return;
215
+ await assertServing(name, 8793, version);
216
+ // A stale push is exactly what this stage exists to catch, and the tools come from the Dockerfile — so the
217
+ // image Docker Hub serves, not just the one built here, has to carry them.
218
+ hasTools(name);
219
+ }
220
+
221
+ // ── run ───────────────────────────────────────────────────────────────────────────────────────────────────
222
+
223
+ try {
224
+ if (has("--published")) {
225
+ await published();
226
+ } else {
227
+ if (!has("--skip-gates")) gates();
228
+ const tgz = pack();
229
+ if (tgz) {
230
+ await npmInstall(tgz);
231
+ await image(tgz);
232
+ }
233
+ }
234
+ } finally {
235
+ cleanup();
236
+ }
237
+
238
+ console.log(failures ? `\n${failures} check(s) failed` : `\nall checks passed for ${version}`);
239
+ process.exit(failures ? 1 : 0);
@@ -6,9 +6,15 @@
6
6
  * numbering and the client can dedupe on it. When the ring overwrites old events the gap is
7
7
  * reported rather than hidden: a client that asks for something evicted is told, not fed a
8
8
  * transcript that quietly starts later.
9
+ *
10
+ * `at` is stamped here too, for the same reason: this process is the only one that sees an event
11
+ * **when it happens**. A replay hands the same stamp back, so a client that attaches later still
12
+ * measures the response's real window instead of the burst it arrived in (src/shared/metrics.ts).
9
13
  */
10
14
  export interface BufferedEvent<T> {
11
15
  seq: number;
16
+ /** When this process saw the event, in epoch milliseconds. */
17
+ at: number;
12
18
  event: T;
13
19
  }
14
20
 
@@ -16,10 +22,13 @@ export class EventLog<T> {
16
22
  private readonly events: BufferedEvent<T>[] = [];
17
23
  private nextSeq = 1;
18
24
 
19
- constructor(private readonly capacity: number = 5000) {}
25
+ constructor(
26
+ private readonly capacity: number = 5000,
27
+ private readonly now: () => number = Date.now,
28
+ ) {}
20
29
 
21
30
  push(event: T): BufferedEvent<T> {
22
- const buffered: BufferedEvent<T> = { seq: this.nextSeq++, event };
31
+ const buffered: BufferedEvent<T> = { seq: this.nextSeq++, at: this.now(), event };
23
32
  this.events.push(buffered);
24
33
  if (this.events.length > this.capacity) this.events.shift();
25
34
  return buffered;
@@ -29,12 +38,6 @@ export class EventLog<T> {
29
38
  return this.nextSeq - 1;
30
39
  }
31
40
 
32
- /** Start over. Opening another session replaces the transcript a client is looking at. */
33
- reset(): void {
34
- this.events.length = 0;
35
- this.nextSeq = 1;
36
- }
37
-
38
41
  /** Highest seq already dropped from the ring (0 when nothing has been dropped). */
39
42
  get evictedThroughSeq(): number {
40
43
  return this.events[0] ? this.events[0].seq - 1 : this.lastSeq;
@@ -181,15 +181,24 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
181
181
  if (requestId) send({ type: "result", requestId, ...extra });
182
182
  };
183
183
 
184
- /** Start (or keep) this socket's subscription to a conversation; `afterSeq` replays what it missed. */
184
+ /**
185
+ * Start (or keep) this socket's subscription to a conversation; `afterSeq` replays what it missed.
186
+ *
187
+ * Subscriptions are additive: one socket may follow several conversations, because a page holds every
188
+ * session it has opened — the one on screen and any background turn still running. Nothing is detached
189
+ * here; a client that is finished with an idle conversation says so with `unsubscribe`, which is what
190
+ * lets `evictIdle` release it.
191
+ */
185
192
  const subscribe = (conversationId: string, afterSeq?: number) => {
186
193
  let subscription = subscriptions.get(conversationId);
187
194
  if (!subscription) {
188
195
  const state: Subscription = { lastSent: 0, detach: () => {}, detachUi: () => {} };
189
- state.detach = registry.attach(conversationId, (seq, event) => {
196
+ state.detach = registry.attach(conversationId, (seq, event, at) => {
190
197
  if (seq <= state.lastSent) return;
191
198
  state.lastSent = seq;
192
- send({ type: "event", conversationId, seq, event });
199
+ // `at` is the backend's own clock: it is what lets a client attaching late measure the
200
+ // response's real window rather than the burst its replay arrived in.
201
+ send({ type: "event", conversationId, seq, at, event });
193
202
  });
194
203
  // Extension output is not a pi event, so it has no seq to dedupe: the page sees each frame once,
195
204
  // live, and a dialog it was not connected for is simply missed (src/server/extension-ui.ts).
@@ -210,11 +219,22 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
210
219
  for (const entry of replay.events) {
211
220
  if (entry.seq <= subscription.lastSent) continue;
212
221
  subscription.lastSent = entry.seq;
213
- send({ type: "event", conversationId, seq: entry.seq, event: entry.event });
222
+ send({ type: "event", conversationId, seq: entry.seq, at: entry.at, event: entry.event });
214
223
  }
215
224
  };
216
225
 
217
- send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev", ...(options.pi ? { pi: options.pi() } : {}) });
226
+ /** Stop following one conversation. The pi session behind it is untouched: only this socket lets go. */
227
+ const unsubscribe = (conversationId: string) => {
228
+ const subscription = subscriptions.get(conversationId);
229
+ if (!subscription) return;
230
+ subscription.detach();
231
+ subscription.detachUi();
232
+ subscriptions.delete(conversationId);
233
+ };
234
+
235
+ // `now` is this process's clock, so a client on another machine can put its own ticking windows on
236
+ // the same timeline the events are stamped with instead of a skew's worth of milliseconds off.
237
+ send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev", now: Date.now(), ...(options.pi ? { pi: options.pi() } : {}) });
218
238
 
219
239
  const handle = async (raw: string): Promise<void> => {
220
240
  const parsed = parseClientCommand(raw);
@@ -222,10 +242,10 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
222
242
  const command = parsed.command;
223
243
  const requestId = command.requestId;
224
244
  const conversationId = "conversationId" in command ? command.conversationId : undefined;
225
- const onError = (error: Error) => {
245
+ const onError = (error: Error & { code?: string }) => {
226
246
  // What the page was told, in the dev log: an error frame alone leaves a stuck UI inexplicable.
227
247
  console.error(`flypi: ${command.type} failed: ${error.message}${conversationId ? ` (conversation ${conversationId})` : ""}`);
228
- fail(error.message, { requestId, conversationId });
248
+ fail(error.message, { ...(error.code ? { code: error.code } : {}), requestId, conversationId });
229
249
  };
230
250
 
231
251
  try {
@@ -239,6 +259,9 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
239
259
  case "resume":
240
260
  subscribe(command.conversationId, command.afterSeq);
241
261
  return;
262
+ case "unsubscribe":
263
+ unsubscribe(command.conversationId);
264
+ return reply(requestId, { conversationId });
242
265
  case "extensionReply":
243
266
  // The answer to an extension's question. Nothing waiting on that id means it timed out or
244
267
  // another tab answered it, and the page has to stop showing the dialog either way.
@@ -257,10 +280,13 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
257
280
  await registry.abort(command.conversationId);
258
281
  return reply(requestId, { conversationId });
259
282
  case "switchSession": {
260
- const switched = await registry.switchSession(command.conversationId, command.sessionPath);
283
+ const switched = await registry.switchSession(command.sessionPath);
261
284
  // A turn the last process left half-done is reported with the session it belongs to — the
262
285
  // client is about to render that chat, and nothing else tells it why the last answer stops short.
263
286
  const interrupted = options.interruptedRun?.(switched.session.sessionFile ?? "");
287
+ // Opened but not followed: the client is handed the conversation id and decides what to
288
+ // watch. Following it here would make every session ever clicked a live subscription, and
289
+ // an unattached idle conversation is what `evictIdle` exists to release.
264
290
  return reply(requestId, {
265
291
  conversationId: switched.id,
266
292
  sessionPath: switched.session.sessionFile,