@astrofoundry/pi-astro 0.34.0 → 0.35.0

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/README.md CHANGED
@@ -80,7 +80,7 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
80
80
 
81
81
  **Bundled subagents** (call them as `astro.<name>`):
82
82
  - General-purpose: `code-reviewer`, `google-tech-lead`, `spec-writer`, `tester-api`, `tester-ui`, `ui-architect`, `ui-design-system`, `ui-frontend-developer`
83
- - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first; sanitized host checks, Mealie and Paperless dumps, and the documented manager upgrade with an Upgrade button on the daily report), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository, public route checks), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health), `media` (trendarr: trending titles, Radarr and Sonarr adds, SABnzbd pause)
83
+ - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first; sanitized host checks, Mealie and Paperless dumps, and the documented manager upgrade with an Upgrade button on the daily report), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository, public route checks), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Europa health), `media` (trendarr: trending titles, Radarr and Sonarr adds, SABnzbd pause)
84
84
  - Release notes: `release-reviewer` (no tools; judges whether an update could break how Cortex uses a tool, for the Cortex Tools Report)
85
85
  - Flourish pipeline: `flourish-input-vision` (image to spec), `flourish-input-narrative` (text to spec), `flourish-input-codebase` (HTML to spec), `flourish-architect` (spec to plan), `flourish-template-developer` (plan to SDK template, needs `@flourish/sdk`), `flourish-example-builder` (template to realistic example), `flourish-preview-runner` (multi-breakpoint preview + screenshots, needs `@flourish/sdk`), `flourish-reviewer` (audit)
86
86
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: inference
3
- description: Operates the AI serving hosts through the inference tool, covering llama.cpp embeddings and reranking on Nexus, the Hermes agent gateways on LXC 101 including upstream-release review and upgrades, and the Europa health check. Use for inference health, restarts, logs, and Hermes upgrades.
3
+ description: Operates the AI serving hosts through the inference tool, covering llama.cpp embeddings and reranking on Nexus and the Europa health check. Use for inference health, restarts, and logs.
4
4
  tools: read, inference, subagent
5
5
  skills: inference
6
6
  systemPromptMode: replace
@@ -10,12 +10,11 @@ maxSubagentDepth: 1
10
10
  model: openai-codex/gpt-6-astra
11
11
  thinking: xhigh
12
12
  ---
13
- You are the inference specialist. The `inference` tool is your only way to reach Nexus, Hermes, and Pulsar. You have no shell and no file access; if a task needs anything else, say so and stop.
13
+ You are the inference specialist. The `inference` tool is your only way to reach Nexus and the Europa health endpoint. You have no shell and no file access; if a task needs anything else, say so and stop.
14
14
 
15
15
  Rules:
16
16
 
17
- - Health first: `nexus-status`, `hermes-status`, `hermes-api-health`, `europa-health`. Europa is off most of the time by design; report "off, expected" rather than a fault.
17
+ - Health first: `nexus-status`, `europa-health`. Europa is off most of the time by design; report "off, expected" rather than a fault.
18
18
  - Restarts only when the task asks for them or a service is down; check status again afterwards.
19
- - A Hermes upgrade is a procedure, never a single call: `hermes-version`, `hermes-releases`, read every release note from the installed version to the target, then decide. If any note names a manual step, a configuration migration outside `hermes config migrate`, a breaking change, or a Python or dependency requirement change, stop and report it; do not upgrade. Otherwise `hermes-snapshots`, `hermes-upgrade --confirm`, then `hermes-version`, `hermes-status`, `hermes-api-health`, and report the snapshot name and the rollback command the wrapper printed.
20
- - Never print log lines that contain tokens, keys, or Discord message content; summarise them.
19
+ - Never print log lines that contain tokens or keys; summarise them.
21
20
  - Delegate through `subagent` only to another specialist (`astro.proxmox`, `astro.backup`, `astro.arcane`, `astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`) and only for that specialist's own area.
@@ -7,6 +7,11 @@ import { addRestarts, astrogateActivity, herdrActivity, needsPrompt, readRestart
7
7
  import {
8
8
  allId,
9
9
  arcaneServerVersion,
10
+ bwRelease,
11
+ gcloudRelease,
12
+ installedComponent,
13
+ jqRelease,
14
+ pythonBuild,
10
15
  brewHasOutdated,
11
16
  buildLines,
12
17
  type Collected,
@@ -80,10 +85,23 @@ const COLLECTED: Collected = {
80
85
  piLatest: "1.3.0",
81
86
  piAstroInstalled: "0.33.0",
82
87
  piAstroLatest: "0.33.0",
83
- installer: { node: { current: "24.22.0", previous: null }, wrappers: "0.32.0", arcaneCli: "2.16.1", trendarr: "0.6.0" },
88
+ installer: {
89
+ node: { current: "24.22.0", previous: null },
90
+ wrappers: "0.32.0",
91
+ arcaneCli: "2.16.1",
92
+ trendarr: "0.6.0",
93
+ python: { current: "3.14.8+20261009", previous: null },
94
+ gcloud: { current: "589.0.0", previous: null },
95
+ bw: { current: "2026.10.0", previous: null },
96
+ jq: { current: null, previous: null },
97
+ },
84
98
  arcaneServer: "2.17.0",
85
99
  trendarrLatest: "0.6.0",
86
100
  nodeLts: "24.22.0",
101
+ pythonLatest: "3.14.8+20261013",
102
+ gcloudLatest: "590.0.0",
103
+ bwLatest: "2026.11.0",
104
+ jqLatest: "1.8.2",
87
105
  warnings: [],
88
106
  };
89
107
 
@@ -113,6 +131,34 @@ describe("versions", () => {
113
131
  });
114
132
  });
115
133
 
134
+ describe("root-owned tool releases", () => {
135
+ it("reads the newest Python build of the installed minor, Bitwarden CLI, jq, and gcloud versions", () => {
136
+ const asset = (v: string) => ({ name: `cpython-${v}+20261013-aarch64-apple-darwin-install_only_stripped.tar.gz` });
137
+ const python = { tag_name: "20261013", assets: [asset("3.14.8"), asset("3.15.0"), asset("3.14.10"), { name: "cpython-3.14.9+20261013-x86_64-apple-darwin-install_only_stripped.tar.gz" }, { name: "SHA256SUMS" }] };
138
+ expect(pythonBuild(python, "3.14")).toBe("3.14.10+20261013");
139
+ expect(pythonBuild(python, "3.13")).toBeNull();
140
+ expect(pythonBuild({ tag_name: "latest", assets: [] }, "3.14")).toBeNull();
141
+ expect(bwRelease([{ tag_name: "desktop-v2026.11.0" }, { tag_name: "cli-v2026.11.1", prerelease: true }, { tag_name: "cli-v2026.10.0" }, { tag_name: "cli-v2026.9.1" }])).toBe("2026.10.0");
142
+ expect(bwRelease({})).toBeNull();
143
+ expect(jqRelease({ tag_name: "jq-1.8.2" })).toBe("1.8.2");
144
+ expect(jqRelease({ tag_name: "1.8.2" })).toBeNull();
145
+ expect(gcloudRelease({ version: "589.0.0", revision: 20261009074343 })).toBe("589.0.0");
146
+ expect(gcloudRelease({ version: "latest" })).toBeNull();
147
+ });
148
+
149
+ it("reads each component from the installer status", () => {
150
+ const status = { node: { current: "24.22.0", previous: null }, wrappers: "0.34.1", arcaneCli: "2.16.1", trendarr: null, python: { current: "3.14.8+20261013" }, jq: { current: null } };
151
+ expect(installedComponent(status, "node")).toBe("24.22.0");
152
+ expect(installedComponent(status, "wrappers")).toBe("0.34.1");
153
+ expect(installedComponent(status, "arcane-cli")).toBe("2.16.1");
154
+ expect(installedComponent(status, "trendarr")).toBeNull();
155
+ expect(installedComponent(status, "python")).toBe("3.14.8+20261013");
156
+ expect(installedComponent(status, "jq")).toBeNull();
157
+ expect(installedComponent(status, "gcloud")).toBeNull();
158
+ expect(installedComponent(null, "bw")).toBeNull();
159
+ });
160
+ });
161
+
116
162
  describe("report lines", () => {
117
163
  it("lists our tools first, then requested formulae and casks; dependencies and pinned packages stay out", () => {
118
164
  const lines = buildLines(COLLECTED);
@@ -120,17 +166,20 @@ describe("report lines", () => {
120
166
  "pi 0.87.1->1.3.0",
121
167
  "spc:wrappers 0.32.0->0.33.0",
122
168
  "spc:arcane-cli 2.16.1->2.17.0",
169
+ "spc:python 3.14.8+20261009->3.14.8+20261013",
170
+ "spc:gcloud 589.0.0->590.0.0",
171
+ "spc:bw 2026.10.0->2026.11.0",
123
172
  "brew:bitwarden-cli 2026.8.0->2026.9.0",
124
173
  "brew:gh 2.97.0->2.101.0",
125
174
  "brew:herdr 0.9.0->0.9.1",
126
175
  "brew:pnpm 11.22.0->12.6.0",
127
176
  "cask:codex 0.152.1->0.156.1",
128
177
  "cask:temurin@17 17.0.20,8->17.0.20.1,1",
129
- "cask:gcloud-cli 585.0.0->586.0.0",
130
178
  ]);
131
179
  expect(lines[0].major).toBe(true);
132
- expect(lines.find((l) => l.key === "cask:gcloud-cli")?.action).toEqual({ kind: "cask", token: "gcloud-cli", greedy: true });
133
- expect(lines.find((l) => l.key === "cask:codex")?.action).toEqual({ kind: "cask", token: "codex", greedy: false });
180
+ expect(lines.find((l) => l.key === "spc:gcloud")?.major).toBe(false);
181
+ expect(lines.find((l) => l.key === "spc:python")?.action).toEqual({ kind: "installer", component: "python" });
182
+ expect(lines.find((l) => l.key === "cask:codex")?.action).toEqual({ kind: "cask", token: "codex" });
134
183
  expect(lines.every((l) => l.state === "offered")).toBe(true);
135
184
  expect(brewHasOutdated(OUTDATED)).toBe(true);
136
185
  expect(brewHasOutdated({ formulae: [OUTDATED.formulae[6]], casks: [] })).toBe(false);
@@ -166,12 +215,26 @@ describe("collection", () => {
166
215
  const dir = mkdtempSync(join(tmpdir(), "tools-pkg-"));
167
216
  try {
168
217
  writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "0.33.0" }));
169
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify([{ version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar"] }]))) as unknown as typeof fetch;
218
+ const bodies: Record<string, unknown> = {
219
+ "https://nodejs.org/dist/index.json": [{ version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar"] }],
220
+ "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest": { tag_name: "20261013", assets: [{ name: "cpython-3.14.8+20261013-aarch64-apple-darwin-install_only_stripped.tar.gz" }] },
221
+ "https://dl.google.com/dl/cloudsdk/channels/rapid/components-2.json": { version: "590.0.0" },
222
+ "https://api.github.com/repos/bitwarden/clients/releases?per_page=30": [{ tag_name: "cli-v2026.11.0" }],
223
+ };
224
+ const fetched: string[] = [];
225
+ const fetchImpl = vi.fn(async (url: string | URL) => {
226
+ fetched.push(String(url));
227
+ const body = bodies[String(url)];
228
+ return body === undefined ? new Response("{}", { status: 404 }) : new Response(JSON.stringify(body));
229
+ }) as unknown as typeof fetch;
170
230
  const c = await collectTools({ exec, fetchImpl, piAstroPackage: join(dir, "package.json"), arcaneServer: async () => "2.16.1" });
171
231
  expect(calls[0]).toBe("brew update");
172
232
  expect(c.warnings.join(" | ")).toMatch(/brew update failed, versions may be stale: Error: network down/);
173
233
  expect(c.warnings.join(" | ")).toMatch(/npm view @astrofoundry\/trendarr failed/);
174
234
  expect(c).toMatchObject({ piInstalled: "0.87.1", piLatest: "1.3.0", piAstroInstalled: "0.33.0", nodeLts: "24.22.0", arcaneServer: "2.16.1", trendarrLatest: null });
235
+ expect(c).toMatchObject({ pythonLatest: "3.14.8+20261013", gcloudLatest: "590.0.0", bwLatest: "2026.11.0", jqLatest: null });
236
+ // jq is not installed by the installer yet, so its releases are not even asked for.
237
+ expect(fetched.some((u) => u.includes("jqlang"))).toBe(false);
175
238
  } finally {
176
239
  rmSync(dir, { recursive: true, force: true });
177
240
  }
@@ -237,8 +300,8 @@ describe("buttons and commands", () => {
237
300
 
238
301
  it("runs one fixed command per kind, reads the version back, and knows what to roll back and restart", () => {
239
302
  expect(updateCommand({ kind: "brew", name: "monadeo/tap/astrogate" }, "0.0.11")).toEqual({ file: "/opt/homebrew/bin/brew", args: ["upgrade", "monadeo/tap/astrogate"] });
240
- expect(updateCommand({ kind: "cask", token: "gcloud-cli", greedy: true }, "586.0.0").args).toEqual(["upgrade", "--cask", "--greedy", "gcloud-cli"]);
241
- expect(updateCommand({ kind: "cask", token: "codex", greedy: false }, "0.156.1").args).toEqual(["upgrade", "--cask", "codex"]);
303
+ expect(updateCommand({ kind: "cask", token: "codex" }, "0.156.1").args).toEqual(["upgrade", "--cask", "codex"]);
304
+ expect(updateCommand({ kind: "installer", component: "python" }, "3.14.8+20261013").args).toEqual(["-n", "/usr/local/sbin/specialist-install", "python", "3.14.8+20261013"]);
242
305
  expect(updateCommand({ kind: "pi" }, "1.3.0")).toEqual({ file: "/opt/homebrew/bin/pi", args: ["update", "--self"] });
243
306
  expect(updateCommand({ kind: "pi-astro" }, "0.33.0").args).toEqual(["update", "npm:@astrofoundry/pi-astro"]);
244
307
  expect(updateCommand({ kind: "installer", component: "arcane-cli" }, "2.17.0")).toEqual({ file: "/usr/bin/sudo", args: ["-n", "/usr/local/sbin/specialist-install", "arcane-cli", "2.17.0"] });
@@ -249,6 +312,8 @@ describe("buttons and commands", () => {
249
312
  expect(verifiedVersion({ kind: "brew", name: "node" }, "node 26.7.0 26.9.0\n", "26.9.0")).toBe(true);
250
313
  expect(verifiedVersion({ kind: "brew", name: "gh" }, "gh 2.97.0\n", "2.101.0")).toBe(false);
251
314
  expect(verifiedVersion({ kind: "installer", component: "node" }, JSON.stringify({ node: { current: "24.23.0" } }), "24.23.0")).toBe(true);
315
+ expect(verifiedVersion({ kind: "installer", component: "gcloud" }, JSON.stringify({ gcloud: { current: "590.0.0", previous: "589.0.0" } }), "590.0.0")).toBe(true);
316
+ expect(verifiedVersion({ kind: "installer", component: "arcane-cli" }, JSON.stringify({ arcaneCli: "2.17.0" }), "2.17.0")).toBe(true);
252
317
  expect(verifiedVersion({ kind: "pi" }, "not json", "1.3.0")).toBe(false);
253
318
  expect(servicesFor({ kind: "pi" })).toEqual(["bridge"]);
254
319
  expect(servicesFor({ kind: "brew", name: "node" })).toEqual(["astrogate", "bridge"]);
@@ -262,17 +327,17 @@ describe("release-notes review", () => {
262
327
  const release = (tag: string, body: string, prerelease = false) => ({ tag_name: tag, name: tag, body, published_at: "2026-10-15T00:00:00Z", html_url: `https://github.com/x/releases/${tag}`, draft: false, prerelease });
263
328
 
264
329
  it("reads only the tool's own releases in a shared repository, between the two versions", async () => {
265
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.9.0", "bw: new list flag"), release("desktop-v2026.9.0", "desktop only"), release("cli-v2026.8.0", "old")]))) as unknown as typeof fetch;
266
- const line = buildLines(COLLECTED).find((l) => l.key === "brew:bitwarden-cli") as ToolLine;
330
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.11.0", "bw: new list flag"), release("desktop-v2026.11.0", "desktop only"), release("cli-v2026.10.0", "old")]))) as unknown as typeof fetch;
331
+ const line = buildLines(COLLECTED).find((l) => l.key === "spc:bw") as ToolLine;
267
332
  const notes = await notesFor(line, fetchImpl);
268
- expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.9.0"]);
333
+ expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.11.0"]);
269
334
  const prompt = reviewPrompt(line, notes.notes ?? [], []);
270
335
  expect(prompt).toMatch(/How Cortex uses it: specialist-cli provision runs bw/);
271
- expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.9\.0 \(2026-10-15\)\nbw: new list flag\nRELEASE NOTES>>>/);
336
+ expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.11\.0 \(2026-10-15\)\nbw: new list flag\nRELEASE NOTES>>>/);
272
337
  });
273
338
 
274
339
  it("says why there is nothing to review", async () => {
275
- const gcloud = buildLines(COLLECTED).find((l) => l.key === "cask:gcloud-cli") as ToolLine;
340
+ const gcloud = buildLines(COLLECTED).find((l) => l.key === "spc:gcloud") as ToolLine;
276
341
  expect(await notesFor(gcloud, fetch)).toEqual({ notes: null, reason: "no release notes to review here; see https://cloud.google.com/sdk/docs/release-notes" });
277
342
  const trendarr: ToolLine = { key: "spc:trendarr", label: "trendarr", installed: "0.6.0", latest: "0.7.0", major: false, action: { kind: "installer", component: "trendarr" }, state: "offered" };
278
343
  const notFound = vi.fn(async () => new Response("{}", { status: 404 })) as unknown as typeof fetch;
@@ -17,14 +17,11 @@ const PI_ASTRO = "@astrofoundry/pi-astro";
17
17
  const TRENDARR = "@astrofoundry/trendarr";
18
18
  /** Homebrew's own search path plus the system one; Homebrew and npm scripts start with `#!/usr/bin/env node`. */
19
19
  export const TOOL_PATH = "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
20
- /** Casks that update themselves, which `brew outdated` skips unless greedy, but which a specialist depends on. */
21
- const TRACKED_SELF_UPDATING = ["gcloud-cli"];
22
-
23
- export type Component = "node" | "wrappers" | "arcane-cli" | "trendarr";
20
+ export type Component = "node" | "wrappers" | "arcane-cli" | "trendarr" | "python" | "gcloud" | "bw" | "jq";
24
21
 
25
22
  export type UpdateAction =
26
23
  | { kind: "brew"; name: string }
27
- | { kind: "cask"; token: string; greedy: boolean }
24
+ | { kind: "cask"; token: string }
28
25
  | { kind: "pi" }
29
26
  | { kind: "pi-astro" }
30
27
  | { kind: "installer"; component: Component };
@@ -121,6 +118,12 @@ export interface Collected {
121
118
  arcaneServer: string | null;
122
119
  trendarrLatest: string | null;
123
120
  nodeLts: string | null;
121
+ /** python-build-standalone's newest build of the installed minor, as `x.y.z+YYYYMMDD`. */
122
+ pythonLatest: string | null;
123
+ /** The version of Google's rapid channel. */
124
+ gcloudLatest: string | null;
125
+ bwLatest: string | null;
126
+ jqLatest: string | null;
124
127
  warnings: string[];
125
128
  }
126
129
 
@@ -130,13 +133,6 @@ interface BrewInfoFormula {
130
133
  installed?: { installed_on_request?: unknown }[];
131
134
  }
132
135
 
133
- interface BrewInfoCask {
134
- token?: unknown;
135
- installed?: unknown;
136
- version?: unknown;
137
- auto_updates?: unknown;
138
- }
139
-
140
136
  interface OutdatedEntry {
141
137
  name?: unknown;
142
138
  installed_versions?: unknown;
@@ -173,10 +169,30 @@ function str(v: unknown): string | null {
173
169
  return typeof v === "string" && v.length > 0 ? v : null;
174
170
  }
175
171
 
176
- const COMPONENT_LABEL: Record<Component, string> = { node: "specialist Node", wrappers: "specialist wrappers", "arcane-cli": "arcane-cli", trendarr: "trendarr" };
172
+ const COMPONENT_LABEL: Record<Component, string> = {
173
+ node: "specialist Node",
174
+ wrappers: "specialist wrappers",
175
+ "arcane-cli": "arcane-cli",
176
+ trendarr: "trendarr",
177
+ python: "specialist Python",
178
+ gcloud: "specialist gcloud",
179
+ bw: "specialist bw",
180
+ jq: "specialist jq",
181
+ };
182
+
183
+ /** gcloud raises its first version number with every release, so a change there says nothing. */
184
+ const NO_MAJOR_FLAG: readonly string[] = ["spc:gcloud"];
177
185
 
178
186
  function line(key: string, label: string, installed: string, latest: string, action: UpdateAction): ToolLine {
179
- return { key, label, installed, latest, major: majorChange(installed, latest), action, state: "offered" };
187
+ return { key, label, installed, latest, major: !NO_MAJOR_FLAG.includes(key) && majorChange(installed, latest), action, state: "offered" };
188
+ }
189
+
190
+ /** What `specialist-install status` reports for one component. */
191
+ export function installedComponent(status: unknown, component: Component): string | null {
192
+ const s = (status ?? {}) as Record<string, unknown>;
193
+ const field = component === "arcane-cli" ? s.arcaneCli : s[component];
194
+ const value = typeof field === "object" && field !== null ? (field as { current?: unknown }).current : field;
195
+ return str(value);
180
196
  }
181
197
 
182
198
  /** Only real updates: the offered version must be newer than the installed one. */
@@ -189,12 +205,19 @@ export function buildLines(c: Collected): ToolLine[] {
189
205
  const lines: ToolLine[] = [];
190
206
  if (newer(c.piInstalled, c.piLatest)) lines.push(line("pi", "Pi", c.piInstalled as string, c.piLatest, { kind: "pi" }));
191
207
  if (newer(c.piAstroInstalled, c.piAstroLatest)) lines.push(line("pi-astro", "pi-astro", c.piAstroInstalled as string, c.piAstroLatest, { kind: "pi-astro" }));
192
- const status = (c.installer ?? {}) as { node?: { current?: unknown }; wrappers?: unknown; arcaneCli?: unknown; trendarr?: unknown };
193
- const installedOf: Record<Component, string | null> = { node: str(status.node?.current), wrappers: str(status.wrappers), "arcane-cli": str(status.arcaneCli), trendarr: str(status.trendarr) };
194
208
  // The wrappers follow the pi-astro release Pi has installed; arcane-cli follows the Arcane server.
195
- const targetOf: Record<Component, string | null> = { node: c.nodeLts, wrappers: c.piAstroInstalled, "arcane-cli": c.arcaneServer, trendarr: c.trendarrLatest };
196
- for (const component of ["wrappers", "arcane-cli", "trendarr", "node"] as const) {
197
- const installed = installedOf[component];
209
+ const targetOf: Record<Component, string | null> = {
210
+ node: c.nodeLts,
211
+ wrappers: c.piAstroInstalled,
212
+ "arcane-cli": c.arcaneServer,
213
+ trendarr: c.trendarrLatest,
214
+ python: c.pythonLatest,
215
+ gcloud: c.gcloudLatest,
216
+ bw: c.bwLatest,
217
+ jq: c.jqLatest,
218
+ };
219
+ for (const component of ["wrappers", "arcane-cli", "trendarr", "node", "python", "gcloud", "bw", "jq"] as const) {
220
+ const installed = installedComponent(c.installer, component);
198
221
  const target = targetOf[component];
199
222
  if (newer(installed, target)) lines.push(line(`spc:${component}`, COMPONENT_LABEL[component], installed as string, target, { kind: "installer", component }));
200
223
  }
@@ -207,14 +230,7 @@ export function buildLines(c: Collected): ToolLine[] {
207
230
  const casks = outdatedEntries(c.brewOutdated, "casks");
208
231
  for (const k of casks.sort((a, b) => a.name.localeCompare(b.name))) {
209
232
  if (k.pinned) continue;
210
- lines.push(line(`cask:${k.name}`, k.name, k.installed, k.latest, { kind: "cask", token: k.name, greedy: false }));
211
- }
212
- const infoCasks = (c.brewInfo as { casks?: BrewInfoCask[] } | null)?.casks;
213
- for (const k of Array.isArray(infoCasks) ? infoCasks : []) {
214
- if (typeof k.token !== "string" || !TRACKED_SELF_UPDATING.includes(k.token) || casks.some((o) => o.name === k.token)) continue;
215
- const installed = str(k.installed);
216
- const latest = str(k.version);
217
- if (newer(installed, latest)) lines.push(line(`cask:${k.token}`, k.token, installed as string, latest, { kind: "cask", token: k.token, greedy: true }));
233
+ lines.push(line(`cask:${k.name}`, k.name, k.installed, k.latest, { kind: "cask", token: k.name }));
218
234
  }
219
235
  return lines;
220
236
  }
@@ -240,6 +256,42 @@ export function arcaneServerVersion(json: unknown): string | null {
240
256
  return typeof v === "string" && /^v?\d+(\.\d+)+$/.test(v) ? v.replace(/^v/, "") : null;
241
257
  }
242
258
 
259
+ /** The newest `cpython-<minor>.N+<build>` install-only build for macOS arm64 in a python-build-standalone release. */
260
+ export function pythonBuild(release: unknown, minor: string): string | null {
261
+ const r = release as { tag_name?: unknown; assets?: { name?: unknown }[] } | null;
262
+ if (!r || typeof r.tag_name !== "string" || !/^\d{8}$/.test(r.tag_name) || !Array.isArray(r.assets)) return null;
263
+ const prefix = `cpython-${minor}.`;
264
+ const suffix = `+${r.tag_name}-aarch64-apple-darwin-install_only_stripped.tar.gz`;
265
+ const versions = r.assets
266
+ .map((a) => (typeof a.name === "string" && a.name.startsWith(prefix) && a.name.endsWith(suffix) ? a.name.slice("cpython-".length, -suffix.length) : null))
267
+ .filter((v): v is string => v !== null && /^\d+\.\d+\.\d+$/.test(v))
268
+ .sort((a, b) => compareLoose(b, a));
269
+ return versions[0] ? `${versions[0]}+${r.tag_name}` : null;
270
+ }
271
+
272
+ /** The newest Bitwarden CLI release, from a page of bitwarden/clients releases (tags `cli-vX.Y.Z`). */
273
+ export function bwRelease(releases: unknown): string | null {
274
+ if (!Array.isArray(releases)) return null;
275
+ const versions = (releases as { tag_name?: unknown; prerelease?: unknown; draft?: unknown }[])
276
+ .filter((r) => typeof r.tag_name === "string" && r.prerelease !== true && r.draft !== true)
277
+ .map((r) => /^cli-v(\d+\.\d+\.\d+)$/.exec(r.tag_name as string)?.[1] ?? null)
278
+ .filter((v): v is string => v !== null)
279
+ .sort((a, b) => compareLoose(b, a));
280
+ return versions[0] ?? null;
281
+ }
282
+
283
+ /** `jq-X.Y.Z` of jqlang/jq's latest release. */
284
+ export function jqRelease(release: unknown): string | null {
285
+ const tag = (release as { tag_name?: unknown } | null)?.tag_name;
286
+ return typeof tag === "string" ? (/^jq-(\d+\.\d+\.\d+)$/.exec(tag)?.[1] ?? null) : null;
287
+ }
288
+
289
+ /** `version` of Google's components snapshot. */
290
+ export function gcloudRelease(snapshot: unknown): string | null {
291
+ const v = (snapshot as { version?: unknown } | null)?.version;
292
+ return typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v) ? v : null;
293
+ }
294
+
243
295
  export function npmGlobalVersion(json: unknown, pkg: string): string | null {
244
296
  const v = (json as { dependencies?: Record<string, { version?: unknown }> } | null)?.dependencies?.[pkg]?.version;
245
297
  return typeof v === "string" ? v : null;
@@ -323,6 +375,25 @@ export async function collectTools(options: CollectOptions): Promise<Collected>
323
375
  } catch (err) {
324
376
  warnings.push(`nodejs.org index failed: ${err instanceof Error ? err.message : String(err)}`);
325
377
  }
378
+ const fetchJson = async (url: string, what: string): Promise<unknown> => {
379
+ try {
380
+ const res = await options.fetchImpl(url, { headers: { accept: "application/json", "user-agent": "pi-astro-tools-report" }, signal: AbortSignal.timeout(30_000) });
381
+ if (!res.ok) {
382
+ warnings.push(`${what} answered ${res.status}`);
383
+ return null;
384
+ }
385
+ return await res.json();
386
+ } catch (err) {
387
+ warnings.push(`${what} failed: ${err instanceof Error ? err.message : String(err)}`);
388
+ return null;
389
+ }
390
+ };
391
+ // Only for tools the installer manages; the first install of each is a manual step.
392
+ const python = installedComponent(installer, "python");
393
+ const pythonLatest = python ? pythonBuild(await fetchJson("https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest", "python-build-standalone"), python.split(".").slice(0, 2).join(".")) : null;
394
+ const gcloudLatest = installedComponent(installer, "gcloud") ? gcloudRelease(await fetchJson("https://dl.google.com/dl/cloudsdk/channels/rapid/components-2.json", "the gcloud release snapshot")) : null;
395
+ const bwLatest = installedComponent(installer, "bw") ? bwRelease(await fetchJson("https://api.github.com/repos/bitwarden/clients/releases?per_page=30", "bitwarden/clients releases")) : null;
396
+ const jqLatest = installedComponent(installer, "jq") ? jqRelease(await fetchJson("https://api.github.com/repos/jqlang/jq/releases/latest", "jqlang/jq releases")) : null;
326
397
  let arcaneServer: string | null = null;
327
398
  try {
328
399
  arcaneServer = await options.arcaneServer();
@@ -341,6 +412,10 @@ export async function collectTools(options: CollectOptions): Promise<Collected>
341
412
  arcaneServer,
342
413
  trendarrLatest: await npmLatest(exec, TRENDARR, warnings),
343
414
  nodeLts,
415
+ pythonLatest,
416
+ gcloudLatest,
417
+ bwLatest,
418
+ jqLatest,
344
419
  warnings,
345
420
  };
346
421
  }
@@ -380,11 +455,11 @@ export const REVIEW_SOURCES: Record<string, ReviewSource> = {
380
455
  repo: "astrofoundry/trendarr",
381
456
  usage: "The media wrapper runs trendarr trending, search, add-movies, add-series and sabnzbd status|pause|resume with --json and parses that JSON; it writes ~/.trendarr/config.json for each call.",
382
457
  },
383
- "cask:gcloud-cli": {
458
+ "spc:gcloud": {
384
459
  notesUrl: "https://cloud.google.com/sdk/docs/release-notes",
385
- usage: "The edge specialist runs gcloud auth activate-service-account --key-file and gcloud compute start-iap-tunnel <instance> 22 --local-host-port, with CLOUDSDK_CONFIG and CLOUDSDK_PYTHON set.",
460
+ usage: "The edge specialist runs gcloud auth activate-service-account --key-file and gcloud compute start-iap-tunnel <instance> 22 --local-host-port, with CLOUDSDK_CONFIG and CLOUDSDK_PYTHON set; gcloud runs on the specialist Python from python-build-standalone.",
386
461
  },
387
- "brew:bitwarden-cli": {
462
+ "spc:bw": {
388
463
  repo: "bitwarden/clients",
389
464
  tagPrefix: "cli-",
390
465
  usage: "specialist-cli provision runs bw status, config server, login --apikey, unlock --passwordfile --raw, sync, list collections, list items --collectionid, and lock, parsing the JSON with jq.",
@@ -464,7 +539,7 @@ export function updateCommand(action: UpdateAction, version: string): Command {
464
539
  case "brew":
465
540
  return { file: BREW, args: ["upgrade", action.name] };
466
541
  case "cask":
467
- return { file: BREW, args: ["upgrade", "--cask", ...(action.greedy ? ["--greedy"] : []), action.token] };
542
+ return { file: BREW, args: ["upgrade", "--cask", action.token] };
468
543
  case "pi":
469
544
  return { file: PI, args: ["update", "--self"] };
470
545
  case "pi-astro":
@@ -507,11 +582,7 @@ export function verifiedVersion(action: UpdateAction, stdout: string, version: s
507
582
  return false;
508
583
  }
509
584
  if (action.kind === "pi") return npmGlobalVersion(json, PI_PACKAGE) === version;
510
- if (action.kind === "installer") {
511
- const s = json as { node?: { current?: unknown }; wrappers?: unknown; arcaneCli?: unknown; trendarr?: unknown };
512
- const now = action.component === "node" ? s.node?.current : action.component === "wrappers" ? s.wrappers : action.component === "arcane-cli" ? s.arcaneCli : s.trendarr;
513
- return now === version;
514
- }
585
+ if (action.kind === "installer") return installedComponent(json, action.component) === version;
515
586
  return false;
516
587
  }
517
588
 
@@ -534,7 +605,7 @@ const SECONDARY = 2;
534
605
  const SUCCESS = 3;
535
606
  const DANGER = 4;
536
607
  const CUSTOM_ID_MAX = 100;
537
- const KEY = /^(pi|pi-astro|spc:(?:node|wrappers|arcane-cli|trendarr)|(?:brew|cask):[A-Za-z0-9@._/+-]{1,60})$/;
608
+ const KEY = /^(pi|pi-astro|spc:(?:node|wrappers|arcane-cli|trendarr|python|gcloud|bw|jq)|(?:brew|cask):[A-Za-z0-9@._/+-]{1,60})$/;
538
609
  const VERSION = /^[A-Za-z0-9._,+-]{1,40}$/;
539
610
 
540
611
  export function updateId(line: ToolLine): string {
@@ -31,7 +31,7 @@ describe("approvals", () => {
31
31
  expect(isRisky("proxmox", ["set", "107", "memory=768"])).toBe(true);
32
32
  expect(isRisky("proxmox", ["shutdown", "107", "--confirm"])).toBe(true);
33
33
  expect(isRisky("security", ["decisions"])).toBe(false);
34
- expect(isRisky("inference", ["hermes-restart", "api"])).toBe(true);
34
+ expect(isRisky("inference", ["nexus-restart", "embed"])).toBe(true);
35
35
  expect(isRisky("backup", ["restic-snapshots"])).toBe(false);
36
36
  expect(isRisky("backup", ["vzdump-run", "107"])).toBe(true);
37
37
  expect(isRisky("media", ["add-movies", "tt1160419"])).toBe(false);
@@ -37,7 +37,7 @@ export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
37
37
  edge: [/^vps\s+deploy-nginx\b/],
38
38
  backup: [/^(vzdump-run|host-backup-run|arcane-backup-run|gcs-run|restic-check-run|restic-restore|restore-clean)\b/],
39
39
  proxmox: [/^(set|snapshot-create|start)\b/],
40
- inference: [/^(nexus-restart|nexus-start|hermes-restart)\b/],
40
+ inference: [/^(nexus-restart|nexus-start)\b/],
41
41
  // media has no entry: trendarr cannot delete anything, and adds and queue toggles run on the caller's request.
42
42
  };
43
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -13,7 +13,7 @@ Fetches `https://auth.37pla.net/.well-known/openid-configuration` through the VP
13
13
 
14
14
  ## `route-probe [name...]`
15
15
 
16
- HTTP status of the public routes of services on the Arcane VM, from Cortex through public DNS, the way users reach them: no credentials, no redirect followed. Without names it checks all of them: `supabase-auth-settings` (expects 401: it wants an API key), `supabase-jwks`, `supabase-openid`, `supabase-oauth-discovery`, `supabase-studio` (expects a redirect to the Pomerium sign-in), `grimoire-public`, `grimoire-public-health`, `mealie-public` (200). Each row has `httpCode`, `expected`, `ok`, `seconds`. A status says the route answered; it does not prove the application works or that a browser login succeeds. A failing route with a passing `probe` points at that service's route or container; ask `astro.identity` (Pomerium route) or `astro.arcane` (container) for their side.
16
+ HTTP status of the public routes of services on the Arcane VM, from Cortex through public DNS, the way users reach them: no credentials, no redirect followed. Without names it checks all of them: `supabase-auth-settings` (expects 401: it wants an API key), `supabase-jwks`, `supabase-openid`, `supabase-oauth-discovery` (expects 404 while the Supabase Auth OAuth server is off), `supabase-studio` (expects a redirect to the Pomerium sign-in), `grimoire-public`, `grimoire-public-health` (200), `mealie-public` (expects a redirect to the Pomerium sign-in; checked 2026-10-19). Supabase Auth serves the OAuth discovery document only when its OAuth server is enabled; if it is turned on, change that expectation to 200. Each row has `httpCode`, `expected`, `ok`, `seconds`. A status says the route answered; it does not prove the application works or that a browser login succeeds. A failing route with a passing `probe` points at that service's route or container; ask `astro.identity` (Pomerium route) or `astro.arcane` (container) for their side.
17
17
 
18
18
  ## `vps <command>`
19
19
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: inference
3
- description: How the inference specialist operates llama.cpp on Nexus, Hermes on LXC 101, and the Europa health check through the inference tool.
3
+ description: How the inference specialist operates llama.cpp on Nexus and the Europa health check through the inference tool.
4
4
  ---
5
5
 
6
6
  # Inference specialist
7
7
 
8
- Three hosts serve AI workloads. Nexus (Mac Studio, `10.0.40.38`, user `nexus`) runs two llama.cpp LaunchAgents: `com.nexus.llama-embed` (Qwen3-Embedding-8B, port 8081, `/v1/embeddings`) and `com.nexus.llama-rerank` (Qwen3-Reranker-4B, port 8082, `/v1/rerank`); Pomerium publishes them as `embed.37pla.net` and `rerank.37pla.net`. Hermes (LXC 101, `10.0.40.31`, user `hermes`) runs two systemd user services: `hermes-gateway.service` (Discord) and `hermes-gateway-api.service` (OpenAI-compatible API on 8650, published as `openai.37pla.net`). Europa (`10.0.60.5`, Windows, RTX 5090) runs an on-demand embedding server on 8081 and is off most of the time by design. `["--help"]` lists every command.
8
+ Two hosts serve AI workloads. Nexus (Mac Studio, `10.0.40.38`, user `nexus`) runs two llama.cpp LaunchAgents: `com.nexus.llama-embed` (Qwen3-Embedding-8B, port 8081, `/v1/embeddings`) and `com.nexus.llama-rerank` (Qwen3-Reranker-4B, port 8082, `/v1/rerank`); Pomerium publishes them as `embed.37pla.net` and `rerank.37pla.net`. Europa (`10.0.60.5`, Windows, RTX 5090) runs an on-demand embedding server on 8081 and is off most of the time by design. `["--help"]` lists every command.
9
9
 
10
10
  ## Nexus
11
11
 
@@ -20,35 +20,11 @@ Three hosts serve AI workloads. Nexus (Mac Studio, `10.0.40.38`, user `nexus`) r
20
20
 
21
21
  A model load after restart takes up to a minute; `health=503` during that time is loading, not failure.
22
22
 
23
- ## Hermes
24
-
25
- | Command | Effect |
26
- |---|---|
27
- | `hermes-status` | both user services |
28
- | `hermes-journal <since>` | user journal of both services |
29
- | `hermes-errors` | `hermes logs errors` |
30
- | `hermes-restart <gateway|api>` | restart one service |
31
- | `hermes-api-health` | `/health` (expect 200) and `/v1/models` (expect 401: the endpoint is up and requires the backend key) |
32
- | `hermes-version` | installed version |
33
- | `hermes-guest-status`, `hermes-snapshots` | LXC 101 state and snapshots (Pulsar) |
34
- | `hermes-releases [n]` | upstream releases of `NousResearch/hermes-agent`, notes cut at 6000 characters |
35
- | `hermes-release <tag>` | one release with its complete notes |
36
- | `hermes-upgrade --confirm` | Pulsar wrapper: snapshot `preupgrade<timestamp>`, `hermes update`, `hermes config migrate`, restart both gateways, wait for a Discord connection and API 401, keep the two newest snapshots |
37
-
38
- ## Hermes upgrade procedure
39
-
40
- 1. `hermes-version` and `hermes-releases 10`.
41
- 2. Read every release from the installed version up to the target; use `hermes-release <tag>` for any note the list cut off. Stop and report when a note mentions a manual step, a configuration change beyond `hermes config migrate`, a breaking change, a new Python or system dependency, or a required intermediate version. Quote the sentence.
42
- 3. `hermes-status` and `hermes-api-health` must be healthy before the upgrade; `hermes-snapshots` shows the existing snapshots.
43
- 4. `hermes-upgrade --confirm` (up to 20 minutes). The wrapper prints the rollback command when a step fails; repeat it verbatim in your report and do not roll back yourself.
44
- 5. `hermes-version`, `hermes-status`, `hermes-api-health`. Report old and new version, the snapshot name, and the health results.
45
-
46
23
  ## Europa
47
24
 
48
25
  `europa-health` returns `ok: false` with `httpCode: null` when Europa is off. That is the normal state; report it as expected unless the task says Europa should be running. Starting or stopping Europa is an operator task.
49
26
 
50
27
  ## When to stop and report
51
28
 
52
- - A release note requires a manual step or the installed version is more than one release behind with breaking changes in between.
53
29
  - A Nexus service stays at `health=503` or `000` for more than two minutes after a restart: report the last log lines (without any key material).
54
- - The task asks for model, port, or configuration changes: those are edits to the tracked plists in the homelab repository (`04-nexus-macstudio/llama.cpp/launchagents/`) or to Hermes profiles, both operator tasks.
30
+ - The task asks for model, port, or configuration changes: those are edits to the tracked plists in the homelab repository (`04-nexus-macstudio/llama.cpp/launchagents/`), an operator task.
@@ -5,7 +5,7 @@ description: How the proxmox specialist operates Proxmox VE on Pulsar through th
5
5
 
6
6
  # Proxmox specialist
7
7
 
8
- Pulsar (`10.0.10.37`, Proxmox VE 9 on Debian 13, node name `pulsar`) runs every guest: VM 100 `arcane` (Docker platform), LXC 101 `hermes`, LXC 102 `backup` (GCS pusher), VM 103 `identity` (Zitadel), VM 104 `dmz` (Pomerium), LXC 105 `tailscale`, VM 106 `observability`, LXC 107 `technitium2`, LXC 108 `radius`. Storage `local-lvm` holds the disks, `unas-backup` the vzdump archives. The `proxmox` tool talks to the API with a token whose role allows audit everywhere and, on guests, power, snapshots, and CPU/memory/option/network settings; nothing else. `["--help"]` lists the commands.
8
+ Pulsar (`10.0.10.37`, Proxmox VE 9 on Debian 13, node name `pulsar`) runs every guest: VM 100 `arcane` (Docker platform), LXC 101 `hermes` (being retired by the operator; no specialist operates it), LXC 102 `backup` (GCS pusher), VM 103 `identity` (Zitadel), VM 104 `dmz` (Pomerium), LXC 105 `tailscale`, VM 106 `observability`, LXC 107 `technitium2`, LXC 108 `radius`. Storage `local-lvm` holds the disks, `unas-backup` the vzdump archives. The `proxmox` tool talks to the API with a token whose role allows audit everywhere and, on guests, power, snapshots, and CPU/memory/option/network settings; nothing else. `["--help"]` lists the commands.
9
9
 
10
10
  ## API commands
11
11
 
@@ -27,7 +27,7 @@ Power actions and snapshot changes return `{ upid, exitstatus }`; `OK` means don
27
27
  ```json
28
28
  { "args": ["guests"] }
29
29
  { "args": ["guest", "106"] }
30
- { "args": ["snapshot-create", "101", "pre-change", "before Hermes config edit"] }
30
+ { "args": ["snapshot-create", "107", "pre-change", "before Technitium config edit"] }
31
31
  { "args": ["shutdown", "107", "--confirm"] }
32
32
  { "args": ["set", "107", "memory=768"] }
33
33
  ```
@@ -48,10 +48,10 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
48
48
  | Wazuh agent | Host | Specialist that operates it |
49
49
  |---|---|---|
50
50
  | `frontdoor-1337` | Frontdoor VPS, Debian 13 | `astro.edge` |
51
- | `arcane` | VM 102, Docker host | `astro.arcane` for containers |
51
+ | `arcane` | VM 100, Docker host | `astro.arcane` for containers |
52
52
  | `identity` | VM 103, ZITADEL | `astro.identity` |
53
53
  | `dmz` | VM 104, Pomerium and nginx | `astro.identity` |
54
- | `hermes` | LXC 109, Hermes | `astro.inference` |
54
+ | `hermes` | LXC 101, Hermes (being retired) | none; the operator |
55
55
  | `NEXUS` | Mac Studio | `astro.inference` |
56
56
  | `CORTEX`, `tailscale` | Mac mini, LXC 105 | none; the operator |
57
57