@openparachute/hub 0.7.7-rc.7 → 0.7.7-rc.9

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.
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Tiny static-file server for the @openparachute/notes PWA bundle.
4
+ * Tiny static-file server for a Parachute PWA bundle — originally written
5
+ * for @openparachute/notes, generalized in hub-parity P5 (2026-07-11) to
6
+ * also serve @openparachute/parachute-app (the super-surface front door,
7
+ * port 1944, mount `/app`) via the same shim. Any package matching this
8
+ * shape (a prebuilt SPA `dist/` with no server of its own) can reuse it.
5
9
  *
6
- * Notes is a SPA — no backend of its own. `parachute start notes` invokes
7
- * this shim with the installed `dist/` path so the PWA is served at a
8
- * known port and can be reverse-proxied by `parachute expose` alongside
9
- * the other services.
10
+ * A served bundle is a SPA — no backend of its own. `parachute start
11
+ * <svc>` invokes this shim with the installed `dist/` path so the PWA is
12
+ * served at a known port and can be reverse-proxied by `parachute expose`
13
+ * alongside the other services.
10
14
  *
11
15
  * Invoked as:
12
- * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>]
16
+ * bun <this-file> --port <n> [--dist <path>] [--mount <prefix>] [--package <npmName>]
13
17
  *
14
18
  * `--mount` (default `/notes`) is the path prefix the reverse proxy hands
15
19
  * us. We strip it before resolving against `dist/` so a request for
@@ -17,27 +21,43 @@
17
21
  * `{dist}/notes/sw.js`. Without the strip, the SW + .webmanifest both
18
22
  * SPA-fall-back to index.html with content-type text/html, and the PWA
19
23
  * install prompt never fires. Pass `--mount ""` (or `--mount /`) when the
20
- * bundle is served at the origin root.
24
+ * bundle is served at the origin root. THIS IS LOAD-BEARING for every
25
+ * package served by this shim, not just notes — keep it exactly as-is.
21
26
  *
22
- * If --dist is omitted, we resolve @openparachute/notes's dist directory
23
- * via Bun.resolveSync. If that fails (package not installed globally, or
27
+ * `--package` (default `@openparachute/notes`, back-compat) names the npm
28
+ * package whose `dist/` we resolve when `--dist` is omitted. Passed by
29
+ * FIRST_PARTY_FALLBACKS entries whose startCmd composes this shim for a
30
+ * package other than notes (e.g. `app`'s `--package @openparachute/parachute-app`).
31
+ *
32
+ * If --dist is omitted, we resolve the package's dist directory via
33
+ * Bun.resolveSync. If that fails (package not installed globally, or
24
34
  * package doesn't ship dist/), exit 1 with a clear error.
35
+ *
36
+ * `/health` (post-mount-strip) always answers 2xx — the doctor/status
37
+ * probe (`probeModuleHealth`) only cares about the status code, but we
38
+ * answer explicitly rather than relying on the SPA-shell catch-all so a
39
+ * missing/corrupt `dist/index.html` can't take the health check down with it.
25
40
  */
26
41
 
27
42
  import { existsSync } from "node:fs";
28
43
  import { homedir } from "node:os";
29
44
  import { dirname, join, resolve } from "node:path";
30
45
 
46
+ /** Back-compat default — the shim's original (and still primary) consumer. */
47
+ const DEFAULT_PACKAGE = "@openparachute/notes";
48
+
31
49
  interface Args {
32
50
  port: number;
33
51
  dist?: string;
34
52
  mount: string;
53
+ pkg: string;
35
54
  }
36
55
 
37
56
  function parseArgs(argv: string[]): Args {
38
57
  let port = 5173;
39
58
  let dist: string | undefined;
40
59
  let mount = "/notes";
60
+ let pkg = DEFAULT_PACKAGE;
41
61
  for (let i = 0; i < argv.length; i++) {
42
62
  const a = argv[i];
43
63
  if (a === "--port") {
@@ -56,11 +76,15 @@ function parseArgs(argv: string[]): Args {
56
76
  const v = argv[++i];
57
77
  if (v === undefined) throw new Error("--mount requires a value");
58
78
  mount = normalizeMount(v);
79
+ } else if (a === "--package") {
80
+ const v = argv[++i];
81
+ if (!v) throw new Error("--package requires a value");
82
+ pkg = v;
59
83
  } else {
60
84
  throw new Error(`unknown argument: ${a}`);
61
85
  }
62
86
  }
63
- return { port, dist, mount };
87
+ return { port, dist, mount, pkg };
64
88
  }
65
89
 
66
90
  export function normalizeMount(raw: string): string {
@@ -70,22 +94,22 @@ export function normalizeMount(raw: string): string {
70
94
 
71
95
  /**
72
96
  * Candidate base directories that `Bun.resolveSync` walks from when looking
73
- * for `@openparachute/notes/package.json`. Order matters:
97
+ * for `<package>/package.json`. Order matters:
74
98
  *
75
- * 1. `process.cwd()` — works when notes-serve is invoked from inside the
76
- * notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
77
- * any project that depends on `@openparachute/notes`.
99
+ * 1. `process.cwd()` — works when the shim is invoked from inside the
100
+ * package's own checkout (e.g. via `installDir` cwd in lifecycle.ts) or
101
+ * from any project that depends on the package.
78
102
  * 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
79
- * layout. This is where `bun add -g @openparachute/notes` lands the
80
- * package, and where `bun link @openparachute/notes` symlinks it.
103
+ * layout. This is where `bun add -g <package>` lands the package, and
104
+ * where `bun link <package>` symlinks it.
81
105
  * 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
82
106
  *
83
- * Hub itself does NOT depend on `@openparachute/notes`, so when
84
- * `parachute start notes` is run from the hub repo dir, the cwd-relative
85
- * resolve walks ancestral node_modules and finds nothing. Bun does not
86
- * auto-consult the global install dir, so bun-linked installs fail to
87
- * resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
88
- * `/notes/` because of this — fixed by trying the global install dirs.
107
+ * Hub itself does NOT depend on the served package, so when `parachute
108
+ * start <svc>` is run from the hub repo dir, the cwd-relative resolve walks
109
+ * ancestral node_modules and finds nothing. Bun does not auto-consult the
110
+ * global install dir, so bun-linked installs fail to resolve without
111
+ * (2)/(3). hub#194: Aaron hit silent 502 on tailnet `/notes/` because of
112
+ * this — fixed by trying the global install dirs.
89
113
  *
90
114
  * Exported (and parameterized via `cwd`/`home`) so tests can drive the
91
115
  * resolution order against a real fixture install without monkey-patching
@@ -98,6 +122,8 @@ export function notesDistCandidates(cwd: string, home: string): string[] {
98
122
  export interface ResolveNotesDistDeps {
99
123
  cwd?: string;
100
124
  home?: string;
125
+ /** npm package name to resolve. Defaults to `@openparachute/notes` (back-compat). */
126
+ pkg?: string;
101
127
  /** Override `Bun.resolveSync` for tests. */
102
128
  resolveSync?: (specifier: string, base: string) => string;
103
129
  existsSync?: (path: string) => boolean;
@@ -106,6 +132,7 @@ export interface ResolveNotesDistDeps {
106
132
  export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
107
133
  const cwd = deps.cwd ?? process.cwd();
108
134
  const home = deps.home ?? homedir();
135
+ const pkg = deps.pkg ?? DEFAULT_PACKAGE;
109
136
  const resolveSync = deps.resolveSync ?? Bun.resolveSync;
110
137
  const exists = deps.existsSync ?? existsSync;
111
138
  const candidates = notesDistCandidates(cwd, home);
@@ -113,7 +140,7 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
113
140
  for (const base of candidates) {
114
141
  let pkgPath: string;
115
142
  try {
116
- pkgPath = resolveSync("@openparachute/notes/package.json", base);
143
+ pkgPath = resolveSync(`${pkg}/package.json`, base);
117
144
  } catch (err) {
118
145
  resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
119
146
  continue;
@@ -126,18 +153,18 @@ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
126
153
  // other candidates — they'd resolve to the same package and report
127
154
  // the same problem.
128
155
  throw new Error(
129
- `@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
156
+ `${pkg} resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the ${pkg} maintainer to add a prepublishOnly build step.`,
130
157
  );
131
158
  }
132
159
  return dist;
133
160
  }
134
161
  throw new Error(
135
- `Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
162
+ `Could not resolve ${pkg} from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g ${pkg}\` or the matching \`parachute install <short>\`.`,
136
163
  );
137
164
  }
138
165
 
139
- function resolveNotesDist(): string {
140
- return resolveNotesDistFrom();
166
+ function resolveNotesDist(pkg: string): string {
167
+ return resolveNotesDistFrom({ pkg });
141
168
  }
142
169
 
143
170
  function mimeFor(path: string): string | undefined {
@@ -160,6 +187,17 @@ export function notesFetch(dist: string, mount: string): (req: Request) => Respo
160
187
  if (mount && (pathname === mount || pathname.startsWith(`${mount}/`))) {
161
188
  pathname = pathname.slice(mount.length) || "/";
162
189
  }
190
+ if (pathname === "/health") {
191
+ // Explicit rather than falling through to the SPA shell: the doctor /
192
+ // status probe (`probeModuleHealth`) only checks for a 2xx status, but
193
+ // answering directly means a missing/corrupt `dist/index.html` can't
194
+ // take the health check down with it. Every FIRST_PARTY_FALLBACKS
195
+ // entry served by this shim declares `health` under its own mount
196
+ // (e.g. `/notes/health`, `/app/health`) — this answers all of them.
197
+ return new Response(JSON.stringify({ status: "ok" }), {
198
+ headers: { "content-type": "application/json" },
199
+ });
200
+ }
163
201
  if (pathname === "/" || pathname.endsWith("/")) {
164
202
  return spaShell();
165
203
  }
@@ -200,17 +238,21 @@ export function notesServeOptions(
200
238
  }
201
239
 
202
240
  if (import.meta.main) {
203
- const { port, dist: distArg, mount } = parseArgs(process.argv.slice(2));
241
+ const { port, dist: distArg, mount, pkg } = parseArgs(process.argv.slice(2));
204
242
 
205
243
  let dist: string;
206
244
  try {
207
- dist = distArg ?? resolveNotesDist();
245
+ dist = distArg ?? resolveNotesDist(pkg);
208
246
  } catch (err) {
209
- console.error(`parachute-notes-serve: ${err instanceof Error ? err.message : String(err)}`);
247
+ console.error(
248
+ `parachute-static-serve (${pkg}): ${err instanceof Error ? err.message : String(err)}`,
249
+ );
210
250
  process.exit(1);
211
251
  }
212
252
 
213
253
  Bun.serve(notesServeOptions(port, dist, mount));
214
254
 
215
- console.log(`notes static-serve listening on :${port} (dist=${dist}, mount=${mount || "/"})`);
255
+ console.log(
256
+ `static-serve listening on :${port} (pkg=${pkg}, dist=${dist}, mount=${mount || "/"})`,
257
+ );
216
258
  }
@@ -11,7 +11,13 @@ import type { ServiceEntry } from "./services-manifest.ts";
11
11
  * 1941 parachute-agent exploration (renamed from parachute-channel)
12
12
  * 1942 parachute-notes committed core (PWA bundle)
13
13
  * 1943 parachute-scribe committed core
14
- * 1944–1949 unassigned
14
+ * 1944 parachute-app the super-surface front door (PWA bundle,
15
+ * hub-parity P5) — see the PORT_RESERVATIONS
16
+ * comment below for why this is a DIFFERENT
17
+ * module from the pre-2026-05-27 `app`
18
+ * 1945 unassigned
19
+ * 1946 parachute-surface UI host (renamed from `app` 2026-05-27)
20
+ * 1947–1949 unassigned
15
21
  *
16
22
  * Hub pins 1939: `parachute expose` composes hub targets as
17
23
  * `http://127.0.0.1:1939/` and that URL has to be stable across machines for
@@ -67,12 +73,24 @@ export const PORT_RESERVATIONS: readonly PortReservation[] = [
67
73
  { port: 1941, name: "parachute-agent", status: "assigned" },
68
74
  { port: 1942, name: "parachute-notes", status: "assigned" },
69
75
  { port: 1943, name: "parachute-scribe", status: "assigned" },
70
- { port: 1944, name: "unassigned", status: "reserved" },
76
+ // hub-parity P5 (2026-07-11): parachute-app's canonical slot — the NEW
77
+ // super-surface front door (@openparachute/parachute-app), landing as a
78
+ // FIRST_PARTY_FALLBACKS entry (short "app") with its own hub-side static-
79
+ // serve shim (notes-serve.ts --package). This is a DIFFERENT, unrelated
80
+ // module from the pre-2026-05-27 `app` package described in the 1946
81
+ // comment below — the two just happen to share the name across a rename.
82
+ // Status `assigned` keeps the fallback-port walker (`assignPort` in
83
+ // port-assign.ts) from handing this port to a colliding third-party
84
+ // module.
85
+ { port: 1944, name: "parachute-app", status: "assigned" },
71
86
  { port: 1945, name: "unassigned", status: "reserved" },
72
- // hub#323: parachute-app's canonical slot. Status `assigned` keeps the
73
- // fallback-port walker (`assignPort` in port-assign.ts) from handing this
74
- // port out to a colliding third-party module. The matching KNOWN_MODULES
75
- // row carries the canonicalPort + paths for status/expose surfaces.
87
+ // hub#323 (historical): this WAS `parachute-app`'s canonical slot back
88
+ // when "app" meant the UI-host module. That module renamed to
89
+ // `parachute-surface` 2026-05-27 (patterns#102); port 1944 above is the
90
+ // different, new parachute-app (super-surface) from hub-parity P5. Status
91
+ // `assigned` keeps the fallback-port walker from handing 1946 to a
92
+ // colliding third-party module. The matching KNOWN_MODULES row carries
93
+ // the canonicalPort + paths for status/expose surfaces.
76
94
  { port: 1946, name: "parachute-surface", status: "assigned" },
77
95
  { port: 1947, name: "unassigned", status: "reserved" },
78
96
  { port: 1948, name: "unassigned", status: "reserved" },
@@ -278,9 +296,15 @@ export function composeServiceSpec(opts: {
278
296
  // — its startCmd is composed from the services.json entry's port + mount,
279
297
  // which is hub-side logic, not something notes itself runs. (The archive
280
298
  // isn't done — notes-daemon Phase 3 retirement hasn't landed.)
299
+ // - app: the NEW super-surface front door (@openparachute/parachute-app,
300
+ // hub-parity P5, 2026-07-11) — same shape as notes: a frontend bundle
301
+ // with no server of its own, served by the SAME hub-side shim
302
+ // (`notes-serve.ts --package @openparachute/parachute-app`). Unrelated
303
+ // to the pre-2026-05-27 `app` package that renamed to `surface` (see
304
+ // the KNOWN_MODULES.surface tagline + the RETIRED_MODULES note below).
281
305
  //
282
- // The remaining entry keeps its "FALLBACK: Delete when …" marker so the
283
- // next cleanup pass is a one-grep operation.
306
+ // Each entry keeps its "FALLBACK: Delete when …" marker so the next cleanup
307
+ // pass is a one-grep operation.
284
308
  // ---------------------------------------------------------------------------
285
309
 
286
310
  // FALLBACK: Delete when @openparachute/notes ships .parachute/module.json AND
@@ -317,11 +341,58 @@ const NOTES_FALLBACK: FirstPartyFallback = {
317
341
  },
318
342
  };
319
343
 
344
+ // FALLBACK: Delete when @openparachute/parachute-app ships
345
+ // .parachute/module.json AND self-registers its services.json row at boot.
346
+ // As of hub-parity P5 the app repo doesn't carry the real module.json yet —
347
+ // only a stale `dist/.parachute/info` artifact (name: "parachute-notes")
348
+ // left over from seeding the app off notes-ui, which is a DIFFERENT file
349
+ // (module-manifest.ts's runtime `/.parachute/info`, not the static
350
+ // `.parachute/module.json` contract this fallback stands in for) and
351
+ // doesn't block this fallback path. parachute-app is a frontend bundle (the
352
+ // super-surface front door) with no server of its own, so — same as notes —
353
+ // its startCmd is hub-side logic composed from the services.json entry
354
+ // (port + mount), running through the SAME `notes-serve.ts` shim
355
+ // generalized in hub-parity P5 via `--package`.
356
+ const APP_FALLBACK: FirstPartyFallback = {
357
+ package: "@openparachute/parachute-app",
358
+ manifest: {
359
+ name: "app",
360
+ manifestName: "parachute-app",
361
+ displayName: "Parachute",
362
+ tagline: "The Parachute app — your parachute's front door.",
363
+ port: 1944,
364
+ paths: ["/app"],
365
+ health: "/app/health",
366
+ },
367
+ extras: {
368
+ startCmd: (entry) => {
369
+ const first = entry.paths[0] ?? "/app";
370
+ const mount = first === "/" ? "" : first.replace(/\/+$/, "");
371
+ return [
372
+ "bun",
373
+ NOTES_SERVE_PATH,
374
+ "--port",
375
+ String(entry.port),
376
+ "--mount",
377
+ mount,
378
+ "--package",
379
+ "@openparachute/parachute-app",
380
+ ];
381
+ },
382
+ postInstallFooter: () => [
383
+ "",
384
+ "Open your Parachute at <origin>/app — it signs in with your hub account.",
385
+ "The hub's front page (`/`) now opens the app too, unless you've already",
386
+ "set a custom root redirect (`parachute hub set-root-redirect` or the admin SPA).",
387
+ ],
388
+ },
389
+ };
390
+
320
391
  /**
321
392
  * Vendored manifests + extras for first-party modules that still need them.
322
393
  * Indexed by short name (the `parachute install <X>` token).
323
394
  *
324
- * Only notes remains — see the block comment above for the rationale
395
+ * notes + app remain — see the block comment above for the rationale
325
396
  * (vault/scribe/agent now self-register and ship their own
326
397
  * module.json). Other code paths consult both this table AND `KNOWN_MODULES`
327
398
  * (which carries the post-self-register-retirement entries) via the helpers
@@ -329,6 +400,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
329
400
  */
330
401
  export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
331
402
  notes: NOTES_FALLBACK,
403
+ app: APP_FALLBACK,
332
404
  };
333
405
 
334
406
  /**
@@ -520,8 +592,15 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
520
592
  * legacy operators. `notes` (the daemon) is the canonical
521
593
  * "deprecating-but-not-retired" case as of 2026-05-22: do not add
522
594
  * until its Phase 3 retirement lands.
523
- * - Entries stay forever. Removing an entry would let a stale row
524
- * reappear silently on legacy installs.
595
+ * - Entries stay forever UNLESS the retired name is later reclaimed by a
596
+ * genuinely new, unrelated module that needs to self-register under it
597
+ * for real (see the `app` / `parachute-app` un-retirement note below).
598
+ * That's the one case where removal is correct: keeping a retirement
599
+ * entry alongside a live FIRST_PARTY_FALLBACKS/KNOWN_MODULES entry of
600
+ * the SAME name would make `dropRetiredModuleRows` GC the live module's
601
+ * own services.json row on every read — an outright regression, not a
602
+ * conservative default. Ordinary "this module is genuinely gone"
603
+ * entries (no successor reusing the name) still stay forever.
525
604
  */
526
605
  export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
527
606
  // NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
@@ -538,21 +617,24 @@ export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?:
538
617
  // which is harmless / arguably correct since paraclaw was the original
539
618
  // "agent".)
540
619
  //
541
- // The `parachute-app` row name retires 2026-05-27 along with the
542
- // app surface rename (patterns#102). Operators upgrading from
543
- // 0.5.13-stable will have a `parachute-app` row in services.json
544
- // pointing at the now-removed @openparachute/app package; this entry
545
- // drops it on load + steers them at `parachute install surface`.
546
- // The short-name `app` form is included for legacy rows that used
547
- // the short name as the `name` field.
548
- app: {
549
- retiredAt: "2026-05-27",
550
- replacement: "parachute-surface (renamed from parachute-app `parachute install surface`)",
551
- },
552
- "parachute-app": {
553
- retiredAt: "2026-05-27",
554
- replacement: "parachute-surface (renamed from parachute-app `parachute install surface`)",
555
- },
620
+ // NOTE (2026-07-11, hub-parity P5 `app` / `parachute-app` UN-RETIRED):
621
+ // this table carried `app` + `parachute-app` (retired 2026-05-27, the
622
+ // app surface rename, patterns#102) from hub#219 through hub-parity P4.
623
+ // They are REMOVED as of this note: a NEW, unrelated module — the real
624
+ // `@openparachute/parachute-app` super-surface front door (see
625
+ // FIRST_PARTY_FALLBACKS.app + PORT_RESERVATIONS' port-1944 comment)
626
+ // claims the `parachute-app` manifestName (and the bare `app` short) for
627
+ // real. Keeping these entries would make `dropRetiredModuleRows`
628
+ // unconditionally GC the new module's services.json row on every read,
629
+ // breaking it outright — see the curation-rule exception above. An
630
+ // operator with a genuinely stale PRE-2026-05-27 `app`/`parachute-app` row
631
+ // (from the old, now-defunct @openparachute/app package) is handled the
632
+ // same as any other unrecognized row once un-retired: `parachute status`
633
+ // renders it, and `parachute serve` boots it via installDir's
634
+ // module.json if present and logs-and-skips otherwise — the same
635
+ // graceful fallback the `runner` registry removal (2026-07-01, see
636
+ // KNOWN_MODULES below) relies on. In practice the real parachute-app
637
+ // module's own first boot overwrites any stale row sharing its name.
556
638
  };
557
639
 
558
640
  /**
@@ -632,7 +714,8 @@ export function knownServices(): string[] {
632
714
  * manifest-declared `focus`; this map is the fallback when `module.json` omits
633
715
  * it (and the bootstrap value before a module is installed).
634
716
  *
635
- * - `core` — vault / scribe / hub / surface (the product surface).
717
+ * - `core` — vault / scribe / hub / surface / app (the product surface;
718
+ * `app` joined 2026-07-11, hub-parity P5 — the super-surface front door).
636
719
  * - `experimental` — agent (legit preview; still OFFERED on a fresh install)
637
720
  * + any unlisted third-party short.
638
721
  * - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
@@ -650,6 +733,7 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
650
733
  scribe: "core",
651
734
  hub: "core",
652
735
  surface: "core",
736
+ app: "core",
653
737
  agent: "experimental",
654
738
  notes: "deprecated",
655
739
  };
@@ -738,7 +822,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
738
822
  /**
739
823
  * Resolve the runtime spec for a known short name.
740
824
  *
741
- * FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
825
+ * FIRST_PARTY_FALLBACKS shorts (notes / app) return a fully-composed
742
826
  * spec with embedded manifest + extras — the vendored manifest is the
743
827
  * source of truth pre-install and the install path preserves it through.
744
828
  *
@@ -851,7 +935,7 @@ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
851
935
  };
852
936
 
853
937
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
854
- * Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
938
+ * Consults both FIRST_PARTY_FALLBACKS (notes / app) and KNOWN_MODULES
855
939
  * (vault / scribe / agent / surface — post-FALLBACK-retirement).
856
940
  * Returns undefined for unknown manifests. */
857
941
  export function shortNameForManifest(manifestName: string): string | undefined {
@@ -623,7 +623,8 @@ export function readManifestLenient(
623
623
  );
624
624
  return { services: [] };
625
625
  }
626
- const afterRetired = dropRetiredModuleRows(raw, path);
626
+ const healed = healStaleUiHostAppRows(raw, path);
627
+ const afterRetired = dropRetiredModuleRows(healed.raw, path);
627
628
  const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
628
629
  // `typeof null === "object"` in JS, so the `!cleaned.raw` part of this
629
630
  // guard is load-bearing for the null case — not a typo or redundancy.
@@ -671,21 +672,28 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
671
672
  `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
672
673
  );
673
674
  }
674
- // Retired-module + legacy short-name row cleanup runs BEFORE shape
675
+ // Row-heal + retired-module + legacy short-name cleanup all run BEFORE shape
675
676
  // validation because the bugs they heal (rows that conflict on port with
676
677
  // a current row) would otherwise throw inside `validateManifest`'s
677
- // duplicate-port gate. Order matters: drop retired-module rows FIRST so
678
- // their absence can unmask a `parachute-<X>` `<X>` pair underneath that
679
- // `dropLegacyShortNameRows` then handles. The reverse order would leave
680
- // a retired row that the short-name pass doesn't recognize. Both passes
681
- // mutate the raw JSON object's `services` array; we re-validate against
682
- // the cleaned shape. See `dropRetiredModuleRows` + `dropLegacyShortNameRows`
683
- // for the full discipline.
684
- const afterRetired = dropRetiredModuleRows(raw, path);
678
+ // duplicate-port gate. Order matters:
679
+ // 1. `healStaleUiHostAppRows` FIRST a pre-rename operator can carry BOTH
680
+ // a `parachute-app` AND an `app` stale row (2026-05-22 double-write
681
+ // reproducer); dropping by OLD-UI-host shape here clears both, whereas
682
+ // `dropLegacyShortNameRows` would keep the `parachute-app` half. Runs
683
+ // ahead of the retired-module pass too (the `app`/`parachute-app`
684
+ // RETIRED_MODULES entries were removed in hub-parity P5 — the names are
685
+ // the NEW app's now — so this shape-gated heal is their replacement).
686
+ // 2. `dropRetiredModuleRows` — drop genuinely-retired rows so their
687
+ // absence can unmask a `parachute-<X>` ↔ `<X>` pair underneath that
688
+ // 3. `dropLegacyShortNameRows` then handles.
689
+ // Each pass mutates the raw JSON object's `services` array; we re-validate
690
+ // against the cleaned shape. See the three helpers for the full discipline.
691
+ const healed = healStaleUiHostAppRows(raw, path);
692
+ const afterRetired = dropRetiredModuleRows(healed.raw, path);
685
693
  const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
686
694
  const validated = validateManifest(cleaned.raw, path);
687
695
  const migrated = migrateClawToAgent(validated);
688
- const changed = afterRetired.changed || cleaned.changed || migrated.changed;
696
+ const changed = healed.changed || afterRetired.changed || cleaned.changed || migrated.changed;
689
697
  if (changed) writeManifest(migrated.manifest, path);
690
698
  return migrated.manifest;
691
699
  }
@@ -745,6 +753,91 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
745
753
  };
746
754
  }
747
755
 
756
+ /**
757
+ * Heal stale OLD-UI-host `app`/`parachute-app` rows on load (hub-parity P5,
758
+ * 2026-07-11).
759
+ *
760
+ * Context: the name `parachute-app` originally belonged to the UI-host module
761
+ * that renamed to `parachute-surface` on 2026-05-27 (patterns#102). Its
762
+ * retirement used to be handled by a `RETIRED_MODULES` entry that
763
+ * `dropRetiredModuleRows` GC'd unconditionally. Hub-parity P5 REUSES the
764
+ * `parachute-app` manifestName (+ the bare `app` short) for a genuinely NEW,
765
+ * unrelated module — the super-surface front door — so that retirement entry
766
+ * had to be removed (keeping it would GC the new module's own row on every
767
+ * read). This heal is the surgically narrow replacement for the ONE case the
768
+ * removed retirement still needs to cover: an operator upgrading DIRECTLY from
769
+ * a pre-rename release (~0.5.13-stable) who skipped every intermediate boot
770
+ * that would have GC'd their stale row. Without a heal, that stale row would:
771
+ * - crash-loop on boot — `reconcilePortToCanonical` rewrites its port to
772
+ * 1944, then the supervisor spawns the app static-serve shim against a
773
+ * `@openparachute/parachute-app` that isn't installed → exit 1 → a
774
+ * forever-restarting "app" unit the operator never installed;
775
+ * - hijack `/surface/*` if a real `parachute-surface` row is also present
776
+ * (both claim `/surface`);
777
+ * - and, after `parachute install app`, keep its stale `/surface` mount so
778
+ * the app serves under `/surface` while the install's `root_redirect=/app/`
779
+ * points the front page at a 404.
780
+ *
781
+ * Discriminant (structural — pinned against the OLD-UI-host row shape verified
782
+ * in `git show origin/main`'s retired-`app` + legacy-short-name test fixtures:
783
+ * `port: 1946`, `paths: ["/surface"]`, `health: "/surface/..."`):
784
+ * - `name` is exactly `"app"` or `"parachute-app"`, AND
785
+ * - it is NOT the NEW app's own row (the new app mounts at `/app` — never
786
+ * healed regardless of port), AND
787
+ * - it carries the OLD-UI-host signature: `paths[0]` is `/surface`-rooted OR
788
+ * `port === 1946` (the old canonical slot; now `parachute-surface`'s).
789
+ *
790
+ * The NEW app row (`paths: ["/app"]`, port 1944) is structurally disjoint on
791
+ * BOTH clauses, so it is always left intact. Matches `dropRetiredModuleRows`'s
792
+ * behavior: a plain DROP + a stderr warning steering the operator to
793
+ * `parachute install surface` (the same replacement the retirement named).
794
+ *
795
+ * Operates on raw JSON (before `validateManifest`) because a stale row can
796
+ * collide on port 1946 with a real `parachute-surface` row and would otherwise
797
+ * trip the duplicate-port gate before this heal runs. Runs FIRST in the load
798
+ * pipeline (ahead of `dropRetiredModuleRows` / `dropLegacyShortNameRows`): a
799
+ * pre-rename operator can carry BOTH a `parachute-app` and an `app` stale row
800
+ * (the 2026-05-22 double-write reproducer) — dropping by shape here clears
801
+ * both, whereas `dropLegacyShortNameRows` would keep the `parachute-app` half.
802
+ */
803
+ function healStaleUiHostAppRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
804
+ if (!raw || typeof raw !== "object") return { raw, changed: false };
805
+ const services = (raw as Record<string, unknown>).services;
806
+ if (!Array.isArray(services)) return { raw, changed: false };
807
+
808
+ const dropped: string[] = [];
809
+ const nextServices = services.filter((row) => {
810
+ if (!row || typeof row !== "object") return true;
811
+ const rec = row as Record<string, unknown>;
812
+ const name = rec.name;
813
+ if (name !== "app" && name !== "parachute-app") return true;
814
+ const paths = rec.paths;
815
+ const first = Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
816
+ // NEW super-surface app mounts at `/app` (port 1944) — never heal it,
817
+ // whatever its port happens to be after a reconcile/collision walk.
818
+ if (first === "/app" || first?.startsWith("/app/")) return true;
819
+ const port = rec.port;
820
+ const oldSurfaceMount = first === "/surface" || first?.startsWith("/surface/") === true;
821
+ const oldCanonicalPort = port === 1946;
822
+ if (!oldSurfaceMount && !oldCanonicalPort) return true;
823
+ dropped.push(String(name));
824
+ return false;
825
+ });
826
+
827
+ if (dropped.length === 0) return { raw, changed: false };
828
+
829
+ for (const name of dropped) {
830
+ console.error(
831
+ `${where}: dropped stale pre-rename '${name}' row (the OLD UI-host module, renamed to parachute-surface 2026-05-27). The name now belongs to the new Parachute app (mount /app, port 1944). If an old daemon is still running, stop it; install the current UI host with \`parachute install surface\`.`,
832
+ );
833
+ }
834
+
835
+ return {
836
+ raw: { ...(raw as Record<string, unknown>), services: nextServices },
837
+ changed: true,
838
+ };
839
+ }
840
+
748
841
  /**
749
842
  * Drop legacy short-name rows when a same-module manifestName row exists
750
843
  * on the same port. Handles the duplicate-row class introduced when a