@openparachute/hub 0.7.3-rc.6 → 0.7.3-rc.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.6",
3
+ "version": "0.7.3-rc.7",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -219,9 +219,11 @@ describe("GET /api/modules", () => {
219
219
  // yet. Post-2026-06-09 (modular-UI architecture, P2) discovery is driven
220
220
  // by the UNION of the bootstrap registries (KNOWN_MODULES ∪
221
221
  // FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
222
- // surfaces — core (vault/scribe/surface) in the headline tier, the rest
223
- // (agent/runner/notes) as `experimental` — so the agent-not-installed
224
- // class (running but invisible) can't recur.
222
+ // surfaces — core (vault/scribe/surface) in the headline tier, agent as
223
+ // `experimental`, and notes/runner as `deprecated` (2026-06-25, still
224
+ // resolvable but not offered for fresh installs) so the agent-not-installed
225
+ // class (running but invisible) can't recur while deprecated modules stop
226
+ // being pushed on a fresh box.
225
227
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
226
228
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
227
229
  db: h.db,
@@ -233,8 +235,9 @@ describe("GET /api/modules", () => {
233
235
  const body = (await res.json()) as {
234
236
  modules: Array<{
235
237
  short: string;
236
- focus: "core" | "experimental";
238
+ focus: "core" | "experimental" | "deprecated";
237
239
  available: boolean;
240
+ available_to_install: boolean;
238
241
  installed: boolean;
239
242
  latest_version: string | null;
240
243
  }>;
@@ -242,12 +245,14 @@ describe("GET /api/modules", () => {
242
245
  };
243
246
  const shorts = body.modules.map((m) => m.short);
244
247
  // The core tier leads, in the recommended install order (vault → scribe),
245
- // ahead of every experimental module.
248
+ // ahead of every experimental module, which lead the deprecated ones.
246
249
  expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
247
250
  expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
248
- expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("runner"));
249
- // Every known module is discoverable — vault/scribe/surface (core) +
250
- // agent/runner/notes (experimental).
251
+ // agent (experimental) sorts ahead of notes/runner (deprecated).
252
+ expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("runner"));
253
+ expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
254
+ // Every known module is discoverable — vault/scribe/surface (core),
255
+ // agent (experimental), notes/runner (deprecated).
251
256
  for (const s of ["vault", "scribe", "surface", "agent", "runner", "notes"]) {
252
257
  expect(shorts).toContain(s);
253
258
  }
@@ -257,15 +262,65 @@ describe("GET /api/modules", () => {
257
262
  expect(byShort.get("scribe")?.focus).toBe("core");
258
263
  expect(byShort.get("surface")?.focus).toBe("core");
259
264
  expect(byShort.get("agent")?.focus).toBe("experimental");
260
- expect(byShort.get("runner")?.focus).toBe("experimental");
261
- expect(byShort.get("notes")?.focus).toBe("experimental");
265
+ expect(byShort.get("runner")?.focus).toBe("deprecated");
266
+ expect(byShort.get("notes")?.focus).toBe("deprecated");
267
+ // `available` stays true for every known module (re-installable), but the
268
+ // fresh-install OFFER (`available_to_install`) drops the deprecated tier —
269
+ // notes/runner aren't pushed on a fresh box; agent (experimental) still is.
262
270
  expect(body.modules.every((m) => m.available)).toBe(true);
271
+ expect(byShort.get("vault")?.available_to_install).toBe(true);
272
+ expect(byShort.get("scribe")?.available_to_install).toBe(true);
273
+ expect(byShort.get("surface")?.available_to_install).toBe(true);
274
+ expect(byShort.get("agent")?.available_to_install).toBe(true);
275
+ expect(byShort.get("runner")?.available_to_install).toBe(false);
276
+ expect(byShort.get("notes")?.available_to_install).toBe(false);
263
277
  expect(body.modules.every((m) => !m.installed)).toBe(true);
264
278
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
265
279
  // Supervisor wasn't injected → flag reflects that.
266
280
  expect(body.supervisor_available).toBe(false);
267
281
  });
268
282
 
283
+ test("an installed deprecated module (runner) still surfaces for management but is not offered for fresh install (2026-06-25)", async () => {
284
+ // A legacy operator with runner on disk: the row must remain visible
285
+ // (installed: true) + manageable, in the `deprecated` tier — but
286
+ // `available_to_install` is false so the SPA's install catalog won't push
287
+ // it. Mirrors the notes-daemon back-compat posture.
288
+ writeManifest(h.manifestPath, [
289
+ {
290
+ name: "parachute-runner",
291
+ port: 1945,
292
+ paths: ["/runner", "/.parachute"],
293
+ health: "/runner/healthz",
294
+ version: "0.2.0",
295
+ },
296
+ ]);
297
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
298
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
299
+ db: h.db,
300
+ issuer: ISSUER,
301
+ manifestPath: h.manifestPath,
302
+ fetchLatestVersion: async () => null,
303
+ });
304
+ const body = (await res.json()) as {
305
+ modules: Array<{
306
+ short: string;
307
+ focus: "core" | "experimental" | "deprecated";
308
+ installed: boolean;
309
+ installed_version: string | null;
310
+ available: boolean;
311
+ available_to_install: boolean;
312
+ }>;
313
+ };
314
+ const runner = body.modules.find((m) => m.short === "runner");
315
+ expect(runner).toBeDefined();
316
+ expect(runner?.installed).toBe(true);
317
+ expect(runner?.installed_version).toBe("0.2.0");
318
+ expect(runner?.focus).toBe("deprecated");
319
+ // Still hub-installable (re-install path) but NOT offered fresh.
320
+ expect(runner?.available).toBe(true);
321
+ expect(runner?.available_to_install).toBe(false);
322
+ });
323
+
269
324
  test("scribe row carries package + display props from KNOWN_MODULES", async () => {
270
325
  // Spot-check the wire shape resolves scribe-specific fields
271
326
  // (package, displayName, tagline) from KNOWN_MODULES rather than a
@@ -277,11 +277,13 @@ describe("validateModuleManifest", () => {
277
277
  // --- 2026-06-09 modular-UI architecture P1 fields: focus / configUiUrl /
278
278
  // adminCapabilities / events / actions. All optional + additive. ---
279
279
 
280
- test("focus accepts core / experimental and rejects anything else", () => {
280
+ test("focus accepts core / experimental / deprecated and rejects anything else", () => {
281
281
  expect(validateModuleManifest({ ...VALID, focus: "core" }, "x").focus).toBe("core");
282
282
  expect(validateModuleManifest({ ...VALID, focus: "experimental" }, "x").focus).toBe(
283
283
  "experimental",
284
284
  );
285
+ // `deprecated` tier (2026-06-25) — a module can self-declare it.
286
+ expect(validateModuleManifest({ ...VALID, focus: "deprecated" }, "x").focus).toBe("deprecated");
285
287
  // Absent stays absent (hub falls back to its default map downstream).
286
288
  expect(validateModuleManifest(VALID, "x").focus).toBeUndefined();
287
289
  expect(() => validateModuleManifest({ ...VALID, focus: "headline" }, "x")).toThrow(/focus/);
@@ -53,14 +53,34 @@ describe("focusForShort", () => {
53
53
  expect(focusForShort("vault")).toBe("core");
54
54
  expect(focusForShort("scribe")).toBe("core");
55
55
  expect(focusForShort("surface")).toBe("core");
56
+ // agent stays a legit experimental preview — still offered on a fresh install.
56
57
  expect(focusForShort("agent")).toBe("experimental");
57
- expect(focusForShort("runner")).toBe("experimental");
58
- expect(focusForShort("notes")).toBe("experimental");
58
+ // notes (notes-daemon, deprecated 2026-05-22) + runner (per Aaron
59
+ // 2026-06-25, not for new installs) are `deprecated`: still resolvable +
60
+ // shown-if-installed, but NOT offered on a fresh setup.
61
+ expect(focusForShort("runner")).toBe("deprecated");
62
+ expect(focusForShort("notes")).toBe("deprecated");
59
63
  });
60
64
 
61
65
  test("unlisted shorts default to experimental", () => {
62
66
  expect(focusForShort("some-third-party-module")).toBe("experimental");
63
67
  });
68
+
69
+ test("a declared deprecated focus is honored over the default map", () => {
70
+ // A module can self-declare `deprecated` in its module.json.
71
+ expect(focusForShort("agent", "deprecated")).toBe("deprecated");
72
+ });
73
+
74
+ test("deprecated shorts stay resolvable (discoverable) — back-compat for existing installs", () => {
75
+ // The deprecated tier de-emphasizes + drops the fresh-install OFFER; it does
76
+ // NOT remove the short from the resolution surface, so an existing
77
+ // notes/runner install keeps routing + lifecycle.
78
+ const shorts = discoverableShorts();
79
+ expect(shorts).toContain("notes");
80
+ expect(shorts).toContain("runner");
81
+ expect(isKnownModuleShort("notes")).toBe(true);
82
+ expect(isKnownModuleShort("runner")).toBe(true);
83
+ });
64
84
  });
65
85
 
66
86
  describe("isKnownModuleShort", () => {
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { InstallOpts } from "../commands/install.ts";
6
- import { parseServicePicks, setup } from "../commands/setup.ts";
6
+ import { isOfferable, parseServicePicks, setup } from "../commands/setup.ts";
7
7
  import { upsertService } from "../services-manifest.ts";
8
8
 
9
9
  interface InstallCall {
@@ -110,6 +110,26 @@ describe("parseServicePicks", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("isOfferable (fresh-install OFFER, 2026-06-25)", () => {
114
+ test("offers an uninstalled core/experimental module", () => {
115
+ expect(isOfferable({ short: "vault", installed: false })).toBe(true);
116
+ expect(isOfferable({ short: "scribe", installed: false })).toBe(true);
117
+ expect(isOfferable({ short: "surface", installed: false })).toBe(true);
118
+ // agent stays a legit experimental preview — still offered.
119
+ expect(isOfferable({ short: "agent", installed: false })).toBe(true);
120
+ });
121
+
122
+ test("does NOT offer a deprecated module (notes / runner) on a fresh install", () => {
123
+ expect(isOfferable({ short: "notes", installed: false })).toBe(false);
124
+ expect(isOfferable({ short: "runner", installed: false })).toBe(false);
125
+ });
126
+
127
+ test("never offers an already-installed module regardless of tier", () => {
128
+ expect(isOfferable({ short: "vault", installed: true })).toBe(false);
129
+ expect(isOfferable({ short: "notes", installed: true })).toBe(false);
130
+ });
131
+ });
132
+
113
133
  describe("setup", () => {
114
134
  test("exits 0 with friendly note when every known service is installed", async () => {
115
135
  const h = makeHarness();
@@ -155,6 +175,96 @@ describe("setup", () => {
155
175
  }
156
176
  });
157
177
 
178
+ test("fresh box: the offered 'Available to install' list excludes deprecated notes/runner (2026-06-25)", async () => {
179
+ const h = makeHarness();
180
+ try {
181
+ // 'all' picks every OFFERED service. With a clean services.json the survey
182
+ // sees every known short; the offered filter must drop notes + runner
183
+ // (deprecated) while keeping vault/scribe/surface/agent. Only vault +
184
+ // scribe have pre-install follow-up prompts (vault name, scribe provider);
185
+ // surface + agent have none — so the scripted answers below are complete.
186
+ const availability = scriptedAvailability([
187
+ "all", // pick everything offered
188
+ "default", // vault name (vault is in the offered set)
189
+ "1", // scribe provider
190
+ ]);
191
+ const code = await setup({
192
+ manifestPath: h.manifestPath,
193
+ configDir: h.configDir,
194
+ log: (l) => h.logs.push(l),
195
+ availability,
196
+ installFn: async (short, opts) => {
197
+ h.calls.push({ short, opts });
198
+ return 0;
199
+ },
200
+ });
201
+ expect(code).toBe(0);
202
+ // The "Available to install" banner must NOT list notes / runner.
203
+ const joined = h.logs.join("\n");
204
+ const availableBlock = joined.slice(joined.indexOf("Available to install:"));
205
+ expect(availableBlock).not.toMatch(/\bnotes\b/);
206
+ expect(availableBlock).not.toMatch(/\brunner\b/);
207
+ // …and `install()` is never invoked for the deprecated shorts.
208
+ const installedShorts = h.calls.map((c) => c.short);
209
+ expect(installedShorts).not.toContain("notes");
210
+ expect(installedShorts).not.toContain("runner");
211
+ // The non-deprecated set is still offered + installed.
212
+ expect(installedShorts).toContain("vault");
213
+ expect(installedShorts).toContain("scribe");
214
+ expect(installedShorts).toContain("surface");
215
+ expect(installedShorts).toContain("agent");
216
+ } finally {
217
+ h.cleanup();
218
+ }
219
+ });
220
+
221
+ test("an already-installed deprecated module still shows in 'Already installed' + isn't re-offered (back-compat)", async () => {
222
+ const h = makeHarness();
223
+ try {
224
+ // Legacy operator with runner (deprecated) on disk. It must surface in the
225
+ // "Already installed" banner (so they know it's there + can manage it via
226
+ // `parachute <verb> runner`), and must NOT reappear in the fresh-install
227
+ // OFFER list.
228
+ upsertService(
229
+ {
230
+ name: "parachute-runner",
231
+ version: "0.2.0",
232
+ port: 1945,
233
+ paths: ["/runner"],
234
+ health: "/runner/healthz",
235
+ },
236
+ h.manifestPath,
237
+ );
238
+ const availability = scriptedAvailability([
239
+ "surface", // pick a still-offered module
240
+ ]);
241
+ const code = await setup({
242
+ manifestPath: h.manifestPath,
243
+ configDir: h.configDir,
244
+ log: (l) => h.logs.push(l),
245
+ availability,
246
+ installFn: async (short, opts) => {
247
+ h.calls.push({ short, opts });
248
+ return 0;
249
+ },
250
+ });
251
+ expect(code).toBe(0);
252
+ const joined = h.logs.join("\n");
253
+ // Banner lists runner as already installed…
254
+ const installedBlock = joined.slice(
255
+ joined.indexOf("Already installed:"),
256
+ joined.indexOf("Available to install:"),
257
+ );
258
+ expect(installedBlock).toMatch(/\brunner\b/);
259
+ // …but runner is NOT in the fresh-install offer.
260
+ const availableBlock = joined.slice(joined.indexOf("Available to install:"));
261
+ expect(availableBlock).not.toMatch(/\brunner\b/);
262
+ expect(h.calls.map((c) => c.short)).not.toContain("runner");
263
+ } finally {
264
+ h.cleanup();
265
+ }
266
+ });
267
+
158
268
  test("rejects non-TTY when there's work to offer", async () => {
159
269
  const h = makeHarness();
160
270
  try {
@@ -220,7 +330,9 @@ describe("setup", () => {
220
330
  const h = makeHarness();
221
331
  try {
222
332
  const availability = scriptedAvailability([
223
- "notes", // single pick — no follow-up prompts
333
+ // surfacea non-deprecated module with no follow-up prompts. (notes,
334
+ // the prior pick, is now `deprecated` → not in the offered set.)
335
+ "surface",
224
336
  ]);
225
337
  const code = await setup({
226
338
  manifestPath: h.manifestPath,
@@ -231,10 +343,10 @@ describe("setup", () => {
231
343
  h.calls.push({ short, opts });
232
344
  upsertService(
233
345
  {
234
- name: "parachute-notes",
346
+ name: "parachute-surface",
235
347
  version: "0.1.0",
236
- port: 1942,
237
- paths: ["/notes"],
348
+ port: 1946,
349
+ paths: ["/surface"],
238
350
  health: "/health",
239
351
  },
240
352
  opts.manifestPath ?? h.manifestPath,
@@ -257,7 +369,8 @@ describe("setup", () => {
257
369
  const h = makeHarness();
258
370
  try {
259
371
  const availability = scriptedAvailability([
260
- "vault, notes", // multi-select
372
+ // surface replaces the prior `notes` pick (now deprecated → not offered).
373
+ "vault, surface", // multi-select
261
374
  "default", // vault name
262
375
  ]);
263
376
  const code = await setup({
@@ -270,10 +383,10 @@ describe("setup", () => {
270
383
  if (short === "vault") return 7; // fail vault
271
384
  upsertService(
272
385
  {
273
- name: "parachute-notes",
386
+ name: "parachute-surface",
274
387
  version: "0.1.0",
275
- port: 1942,
276
- paths: ["/notes"],
388
+ port: 1946,
389
+ paths: ["/surface"],
277
390
  health: "/health",
278
391
  },
279
392
  opts.manifestPath ?? h.manifestPath,
@@ -282,7 +395,7 @@ describe("setup", () => {
282
395
  },
283
396
  });
284
397
  expect(code).toBe(7);
285
- expect(h.calls.map((c) => c.short)).toEqual(["vault", "notes"]);
398
+ expect(h.calls.map((c) => c.short)).toEqual(["vault", "surface"]);
286
399
  expect(h.logs.join("\n")).toMatch(/non-zero exit code/);
287
400
  } finally {
288
401
  h.cleanup();
@@ -295,7 +408,8 @@ describe("setup", () => {
295
408
  const availability = scriptedAvailability([
296
409
  "9", // out-of-range index — loop re-prompts
297
410
  "nope", // unknown name — loop re-prompts again
298
- "notes", // single pick, no follow-up prompts
411
+ // surface — a non-deprecated single pick with no follow-up prompts.
412
+ "surface",
299
413
  ]);
300
414
  const code = await setup({
301
415
  manifestPath: h.manifestPath,
@@ -306,10 +420,10 @@ describe("setup", () => {
306
420
  h.calls.push({ short, opts });
307
421
  upsertService(
308
422
  {
309
- name: "parachute-notes",
423
+ name: "parachute-surface",
310
424
  version: "0.1.0",
311
- port: 1942,
312
- paths: ["/notes"],
425
+ port: 1946,
426
+ paths: ["/surface"],
313
427
  health: "/health",
314
428
  },
315
429
  opts.manifestPath ?? h.manifestPath,
@@ -318,7 +432,7 @@ describe("setup", () => {
318
432
  },
319
433
  });
320
434
  expect(code).toBe(0);
321
- expect(h.calls.map((c) => c.short)).toEqual(["notes"]);
435
+ expect(h.calls.map((c) => c.short)).toEqual(["surface"]);
322
436
  expect(availability.remaining()).toBe(0);
323
437
  const joined = h.logs.join("\n");
324
438
  expect(joined).toMatch(/out-of-range/);
@@ -15,11 +15,21 @@
15
15
  * / `crashed` / `starting` / `restarting`) + pid. Absent when the
16
16
  * hub is in CLI mode (no supervisor injected through HubFetchDeps).
17
17
  *
18
- * `focus` ("core" | "experimental") comes from each module's `module.json`
19
- * when declared, else `focusForShort`'s default map. The SPA groups core first
20
- * + de-emphasizes experimental it NEVER hides a module. This is what makes a
21
- * running, self-registered module (channel) visible + installable; the old
22
- * `CURATED_MODULES = ["vault","scribe"]` whitelist made it invisible.
18
+ * `focus` ("core" | "experimental" | "deprecated") comes from each module's
19
+ * `module.json` when declared, else `focusForShort`'s default map. The SPA
20
+ * groups core first, de-emphasizes experimental, and de-emphasizes
21
+ * `deprecated` (notes-daemon / runner) further it NEVER hides an installed
22
+ * module (so an existing operator can still manage / uninstall a deprecated
23
+ * one). This is what makes a running, self-registered module (channel) visible
24
+ * + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
25
+ * it invisible.
26
+ *
27
+ * `available_to_install` is the fresh-install OFFER (2026-06-25): a module the
28
+ * hub will push as a new install. It's `available && focus !== "deprecated"`
29
+ * — so a `deprecated` module that is ALREADY installed still surfaces (via the
30
+ * `modules` union + `installed: true`) and is manageable, but a deprecated
31
+ * module is never offered as a fresh install. `agent` (`experimental`) stays
32
+ * offered.
23
33
  *
24
34
  * Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
25
35
  * and `/api/grants` — the admin SPA mints this scope via
@@ -199,14 +209,33 @@ interface ModuleWireShape {
199
209
  display_name: string;
200
210
  tagline: string;
201
211
  /**
202
- * Discovery tier (2026-06-09 modular-UI architecture). `core` modules render
203
- * in the headline group; `experimental` modules render in a de-emphasized
204
- * "Experimental" group below never hidden. Resolved from the module's
205
- * `module.json` `focus` when declared, else `focusForShort`'s default map
206
- * (vault/scribe/hub/surface core, channel/runner/others experimental).
212
+ * Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
213
+ * 2026-06-25). `core` modules render in the headline group; `experimental`
214
+ * modules render in a de-emphasized "Experimental" group; `deprecated`
215
+ * modules (notes-daemon / runner) render in a further-de-emphasized
216
+ * "Deprecated" group never hidden when installed. Resolved from the
217
+ * module's `module.json` `focus` when declared, else `focusForShort`'s
218
+ * default map (vault/scribe/hub/surface → core, notes/runner → deprecated,
219
+ * others → experimental).
207
220
  */
208
221
  focus: ModuleFocus;
222
+ /**
223
+ * True iff the hub knows how to install this module (`package`/`manifest`
224
+ * resolvable). Historically "in the curated install catalog". Still set for
225
+ * every known module; a purely third-party services.json row is false.
226
+ * NOTE: this is NOT the fresh-install OFFER — a `deprecated` module is still
227
+ * `available: true` (it's installable for back-compat / re-install) but is
228
+ * excluded from `available_to_install`.
229
+ */
209
230
  available: boolean;
231
+ /**
232
+ * The fresh-install OFFER (2026-06-25): whether the hub presents this module
233
+ * in the "available to install fresh" set. `available && focus !==
234
+ * "deprecated"`. The SPA's "Install a module" catalog filters on this so
235
+ * notes-daemon / runner aren't pushed on a fresh box; an already-installed
236
+ * deprecated module still surfaces (in the Installed section) for management.
237
+ */
238
+ available_to_install: boolean;
210
239
  installed: boolean;
211
240
  installed_version: string | null;
212
241
  latest_version: string | null;
@@ -562,8 +591,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
562
591
  // module's manifest-declared `focus` (when installed + declared) wins; else
563
592
  // the `focusForShort` default map. Sort: `core` group first (with the
564
593
  // CURATED_MODULES recommended-install order floated to the top of that
565
- // group), then `experimental` the SPA renders the two groups; `focus`
566
- // never hides a module.
594
+ // group), then `experimental`, then `deprecated` (notes / runner) last the
595
+ // SPA renders the groups; `focus` never hides an installed module.
567
596
  const recommendedOrder = new Map<string, number>(
568
597
  (CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
569
598
  );
@@ -585,6 +614,11 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
585
614
  // known modules; a purely third-party services.json row (no install
586
615
  // package) is not hub-installable → false.
587
616
  available: m !== undefined,
617
+ // Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
618
+ // deprecated short (notes / runner) stays `available` (re-installable
619
+ // for back-compat) but is dropped from the offer set so the SPA's
620
+ // "Install a module" catalog doesn't push it on a fresh box.
621
+ available_to_install: m !== undefined && focus !== "deprecated",
588
622
  installed: installed !== undefined,
589
623
  installed_version: installed?.version ?? null,
590
624
  latest_version: latestByShort.get(short) ?? null,
@@ -598,7 +632,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
598
632
  };
599
633
  return row;
600
634
  });
601
- const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : 1);
635
+ const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : f === "experimental" ? 1 : 2);
602
636
  rows.sort((a, b) => {
603
637
  if (a.focus !== b.focus) return focusRank(a.focus) - focusRank(b.focus);
604
638
  const ai = recommendedOrder.get(a.short) ?? Number.POSITIVE_INFINITY;
@@ -11,6 +11,7 @@ import {
11
11
  KNOWN_MODULES,
12
12
  type ServiceSpec,
13
13
  composeServiceSpec,
14
+ focusForShort,
14
15
  knownServices,
15
16
  } from "../service-spec.ts";
16
17
  import type { ServiceEntry } from "../services-manifest.ts";
@@ -108,10 +109,14 @@ function defaultAvailability(): InteractiveAvailability {
108
109
  }
109
110
 
110
111
  /**
111
- * Survey the eligible services. We include the four first-party shortnames
112
- * (vault / notes / scribe / agent + runner) but flag agent as exploratory
113
- * in the blurb so operators don't grab it by reflex. `installed` is true when
114
- * the service has a row in services.json.
112
+ * Survey ALL known first-party shortnames (vault / notes / scribe / agent /
113
+ * runner / surface) regardless of tier `installed` is true when the service
114
+ * has a row in services.json. The fresh-install OFFER is narrowed downstream
115
+ * by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
116
+ * notes / runner); agent (`experimental`) is flagged exploratory in its blurb
117
+ * but stays offered. Surveying everything keeps `installed` detection complete
118
+ * (the "already installed" banner still lists a deprecated module an operator
119
+ * has on disk).
115
120
  *
116
121
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
117
122
  * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
@@ -149,10 +154,30 @@ function surveyServices(manifestPath: string): ServiceChoice[] {
149
154
  });
150
155
  }
151
156
 
157
+ /**
158
+ * A surveyed service is OFFERED on a fresh setup iff it is not already
159
+ * installed AND its discovery tier is not `deprecated` (2026-06-25). The
160
+ * deprecated tier (notes-daemon, runner) stays resolvable + manageable for an
161
+ * existing install — it just isn't pushed on a fresh box. `agent`
162
+ * (`experimental`) is still offered. Exported so the setup tests can pin the
163
+ * exclusion directly.
164
+ */
165
+ export function isOfferable(choice: { short: string; installed: boolean }): boolean {
166
+ if (choice.installed) return false;
167
+ // No `declared` arg: the survey fires PRE-install, before any module.json is
168
+ // on disk to read a self-declared `focus` from — so the static default map is
169
+ // the only signal available + the right one here.
170
+ return focusForShort(choice.short) !== "deprecated";
171
+ }
172
+
152
173
  const BLURBS: Record<string, string> = {
153
174
  vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
175
+ surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
176
+ // `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
154
177
  app: "Parachute UI host — auto-installs Notes on first boot (recommended over notes-daemon)",
155
- notes: "Notes PWA web/mobile UI on top of vault (notes-daemon; superseded by `app`)",
178
+ // notes / runner are `deprecated` (not offered on a fresh setup) these
179
+ // blurbs only render if a legacy install surfaces them in the survey.
180
+ notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
156
181
  scribe: "audio transcription for dictation + recordings",
157
182
  runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
158
183
  agent:
@@ -306,7 +331,7 @@ export async function setup(opts: SetupOpts = {}): Promise<number> {
306
331
 
307
332
  const survey = surveyServices(manifestPath);
308
333
  const installed = survey.filter((s) => s.installed);
309
- const offered = survey.filter((s) => !s.installed);
334
+ const offered = survey.filter(isOfferable);
310
335
 
311
336
  if (installed.length > 0) {
312
337
  log("Already installed:");
@@ -66,16 +66,26 @@ export interface ConfigSchema {
66
66
  }
67
67
 
68
68
  /**
69
- * Discovery tier (2026-06-09 modular-UI architecture). `core` modules are the
70
- * product surface (vault / scribe / hub / surface); `experimental` modules
71
- * (channel / runner / others) render in a de-emphasized group on the Modules
72
- * screen. **Show all; never hide** `focus` only sorts + labels.
69
+ * Discovery tier (2026-06-09 modular-UI architecture). Three tiers today:
70
+ * - `core` — the product surface (vault / scribe / hub / surface).
71
+ * - `experimental` legit previews (agent) that render in a de-emphasized
72
+ * group but ARE offered on a fresh install.
73
+ * - `deprecated` — modules retained for back-compat (notes-daemon, runner)
74
+ * that stay resolvable + shown-if-installed (so an existing operator can
75
+ * still manage / uninstall them) but are NOT offered on a fresh setup
76
+ * (2026-06-25). Distinct from a fully retired module (`RETIRED_MODULES`),
77
+ * whose services.json row is GC'd on load.
78
+ *
79
+ * **Show all installed; never hide** — `focus` only sorts + labels for an
80
+ * installed/known module. The one behavioral lever it pulls is the fresh-install
81
+ * OFFER: `deprecated` shorts are dropped from the setup wizard + the admin SPA's
82
+ * "available to install" set.
73
83
  *
74
84
  * Absent in a `module.json` ⇒ the hub falls back to its default map (see
75
85
  * `service-spec.focusForShort`), which defaults unlisted modules to
76
86
  * `experimental`.
77
87
  */
78
- export type ModuleFocus = "core" | "experimental";
88
+ export type ModuleFocus = "core" | "experimental" | "deprecated";
79
89
 
80
90
  /**
81
91
  * An event a module EMITS — the left-hand side of a Connection (2026-06-09
@@ -324,8 +334,10 @@ export interface ModuleManifest {
324
334
  * Discovery tier (2026-06-09 modular-UI architecture). When a module
325
335
  * declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
326
336
  * falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
327
- * `core`, everything else → `experimental`). **Show all; never hide** —
328
- * `focus` only groups + de-emphasizes. Additive + back-compatible.
337
+ * `core`, notes/runner → `deprecated`, everything else → `experimental`).
338
+ * **Show all installed; never hide** — `focus` groups + de-emphasizes; the
339
+ * one behavioral lever is the fresh-install OFFER, which excludes
340
+ * `deprecated`. Additive + back-compatible.
329
341
  */
330
342
  readonly focus?: ModuleFocus;
331
343
  /**
@@ -711,12 +723,14 @@ function asCredentials(v: unknown, where: string): readonly ModuleCredential[] |
711
723
  });
712
724
  }
713
725
 
714
- const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
726
+ const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental", "deprecated"]);
715
727
 
716
728
  function asFocus(v: unknown, where: string): ModuleFocus | undefined {
717
729
  if (v === undefined) return undefined;
718
730
  if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
719
- throw new ModuleManifestError(`${where}: "focus" must be "core" | "experimental" if present`);
731
+ throw new ModuleManifestError(
732
+ `${where}: "focus" must be "core" | "experimental" | "deprecated" if present`,
733
+ );
720
734
  }
721
735
  return v as ModuleFocus;
722
736
  }