@openparachute/hub 0.7.7-rc.8 → 0.7.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.
Files changed (80) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +359 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/root-serve.test.ts +139 -0
  33. package/src/__tests__/scope-explanations.test.ts +0 -2
  34. package/src/__tests__/serve-boot.test.ts +25 -36
  35. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  36. package/src/__tests__/services-manifest.test.ts +372 -132
  37. package/src/__tests__/setup-wizard.test.ts +301 -22
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/__tests__/wizard-transcription.test.ts +35 -0
  42. package/src/__tests__/wizard.test.ts +79 -0
  43. package/src/account-api.ts +6 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-mint-token.ts +2 -1
  50. package/src/api-modules-ops.ts +2 -1
  51. package/src/api-modules.ts +10 -8
  52. package/src/api-revoke-token.ts +2 -1
  53. package/src/api-settings-hub-origin.ts +2 -1
  54. package/src/api-settings-root-redirect.ts +97 -20
  55. package/src/api-tokens.ts +2 -1
  56. package/src/audience-gate.ts +2 -1
  57. package/src/chrome-strip.ts +16 -4
  58. package/src/commands/hub.ts +104 -0
  59. package/src/commands/install.ts +97 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/commands/wizard-transcription.ts +24 -0
  63. package/src/commands/wizard.ts +34 -1
  64. package/src/help.ts +6 -6
  65. package/src/hub-server.ts +111 -54
  66. package/src/hub-settings.ts +147 -0
  67. package/src/hub.ts +64 -31
  68. package/src/module-ops-client.ts +2 -1
  69. package/src/notes-serve.ts +73 -31
  70. package/src/oauth-handlers.ts +1 -11
  71. package/src/operator-token.ts +0 -1
  72. package/src/origin-check.ts +2 -2
  73. package/src/root-serve.ts +156 -0
  74. package/src/scope-explanations.ts +2 -5
  75. package/src/service-spec.ts +129 -74
  76. package/src/services-manifest.ts +112 -52
  77. package/src/setup-wizard.ts +144 -31
  78. package/src/surface-notes-alias.ts +126 -0
  79. package/src/__tests__/admin-agent-token.test.ts +0 -173
  80. package/src/admin-agent-token.ts +0 -147
@@ -8,10 +8,16 @@ import type { ServiceEntry } from "./services-manifest.ts";
8
8
  *
9
9
  * 1939 parachute-hub internal static + proxy, CLI-managed
10
10
  * 1940 parachute-vault committed core
11
- * 1941 parachute-agent exploration (renamed from parachute-channel)
11
+ * 1941 unassigned
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
@@ -64,15 +70,27 @@ export interface PortReservation {
64
70
  export const PORT_RESERVATIONS: readonly PortReservation[] = [
65
71
  { port: 1939, name: "parachute-hub", status: "assigned" },
66
72
  { port: 1940, name: "parachute-vault", status: "assigned" },
67
- { port: 1941, name: "parachute-agent", status: "assigned" },
73
+ { port: 1941, name: "unassigned", status: "reserved" },
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,59 @@ 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 serves the app directly too, unless you've",
386
+ "already customized the root (`parachute hub set-root-mode` / `set-root-redirect`",
387
+ "or the admin SPA).",
388
+ ],
389
+ },
390
+ };
391
+
320
392
  /**
321
393
  * Vendored manifests + extras for first-party modules that still need them.
322
394
  * Indexed by short name (the `parachute install <X>` token).
323
395
  *
324
- * Only notes remains — see the block comment above for the rationale
396
+ * notes + app remain — see the block comment above for the rationale
325
397
  * (vault/scribe/agent now self-register and ship their own
326
398
  * module.json). Other code paths consult both this table AND `KNOWN_MODULES`
327
399
  * (which carries the post-self-register-retirement entries) via the helpers
@@ -329,6 +401,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
329
401
  */
330
402
  export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
331
403
  notes: NOTES_FALLBACK,
404
+ app: APP_FALLBACK,
332
405
  };
333
406
 
334
407
  /**
@@ -438,8 +511,8 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
438
511
  },
439
512
  },
440
513
  // NOTE (2026-07-01): `runner` was REMOVED from this registry (decision:
441
- // Aaron 2026-07-01 — the module set of record is vault / hub / agent /
442
- // scribe / surface). Runner is no longer offered, installable, or
514
+ // Aaron 2026-07-01 — the module set of record is vault / hub / scribe /
515
+ // surface). Runner is no longer offered, installable, or
443
516
  // lifecycle-addressable by short name from the hub's bootstrap registries.
444
517
  // Existing installs stay GRACEFUL: a legacy `parachute-runner` services.json
445
518
  // row is handled exactly like any unknown/third-party row — `parachute
@@ -448,29 +521,7 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
448
521
  // stamped and logs-and-skips otherwise. Deliberately NOT added to
449
522
  // RETIRED_MODULES: that registry GC-drops rows on load, which would break
450
523
  // routing for operators still running the runner daemon.
451
- agent: {
452
- short: "agent",
453
- package: "@openparachute/agent",
454
- manifestName: "parachute-agent",
455
- canonicalPort: 1941,
456
- displayName: "Agent",
457
- // Mirrors agent's own module.json (the canonical fields below do too —
458
- // keep in sync if agent's declaration changes). Renamed from `channel`
459
- // 2026-06-17 (parachute-channel → parachute-agent); the module is the
460
- // same webhook-fan-out + MCP bridge daemon, on the same port 1941.
461
- tagline: "Chat with your Claude Code sessions — a channel per session.",
462
- canonicalPaths: ["/agent"],
463
- canonicalHealth: "/health",
464
- canonicalStripPrefix: true,
465
- extras: {
466
- // Backward-compat startCmd for rows without installDir — same rationale
467
- // as scribe / vault. The bare binary IS the daemon (agent's
468
- // package.json bin maps `parachute-agent` → src/daemon.ts).
469
- startCmd: () => ["parachute-agent"],
470
- // Agent gates its endpoints behind hub-issued JWTs (agent:* scopes).
471
- hasAuth: true,
472
- },
473
- },
524
+
474
525
  surface: {
475
526
  short: "surface",
476
527
  package: "@openparachute/surface",
@@ -520,39 +571,43 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
520
571
  * legacy operators. `notes` (the daemon) is the canonical
521
572
  * "deprecating-but-not-retired" case as of 2026-05-22: do not add
522
573
  * until its Phase 3 retirement lands.
523
- * - Entries stay forever. Removing an entry would let a stale row
524
- * reappear silently on legacy installs.
574
+ * - Entries stay forever UNLESS the retired name is later reclaimed by a
575
+ * genuinely new, unrelated module that needs to self-register under it
576
+ * for real (see the `app` / `parachute-app` un-retirement note below).
577
+ * That's the one case where removal is correct: keeping a retirement
578
+ * entry alongside a live FIRST_PARTY_FALLBACKS/KNOWN_MODULES entry of
579
+ * the SAME name would make `dropRetiredModuleRows` GC the live module's
580
+ * own services.json row on every read — an outright regression, not a
581
+ * conservative default. Ordinary "this module is genuinely gone"
582
+ * entries (no successor reusing the name) still stay forever.
525
583
  */
526
584
  export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
527
- // NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
528
- // names were retired here on 2026-05-20 (the Claude-in-containers module).
529
- // They are NOW RE-ASSIGNED to the renamed channel module (parachute-channel
530
- // → parachute-agent). Keeping these as RETIRED entries would make
531
- // `dropRetiredModuleRows` drop the NEW agent module's services.json row on
532
- // load breaking the live module. The names are no longer retired; they
533
- // belong to the active agent module. The retired containers code now lives
534
- // under `parachute-agent-legacy` (GitHub) and is not installable, so there
535
- // is no live `agent` daemon to GC. (The historical `claw → agent`
536
- // migration in services-manifest.ts still rewrites stale paraclaw rows to
537
- // `name: "agent"` they now resolve to the LIVE agent module's mount,
538
- // which is harmless / arguably correct since paraclaw was the original
539
- // "agent".)
540
- //
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
- },
585
+ agent: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
586
+ channel: { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
587
+ "parachute-agent": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
588
+ "parachute-channel": { retiredAt: "2026-07-15", replacement: "Vault and Surface" },
589
+ // Agent was removed from Hub on 2026-07-15. All historical service-row
590
+ // identities are retired so normal manifest reads stop routing or supervising
591
+ // the deprecated daemon. Generic AI-agent authorization grants/tokens are a
592
+ // separate Hub capability and remain supported.
593
+ // NOTE (2026-07-11, hub-parity P5 — `app` / `parachute-app` UN-RETIRED):
594
+ // this table carried `app` + `parachute-app` (retired 2026-05-27, the
595
+ // app surface rename, patterns#102) from hub#219 through hub-parity P4.
596
+ // They are REMOVED as of this note: a NEW, unrelated module — the real
597
+ // `@openparachute/parachute-app` super-surface front door (see
598
+ // FIRST_PARTY_FALLBACKS.app + PORT_RESERVATIONS' port-1944 comment) —
599
+ // claims the `parachute-app` manifestName (and the bare `app` short) for
600
+ // real. Keeping these entries would make `dropRetiredModuleRows`
601
+ // unconditionally GC the new module's services.json row on every read,
602
+ // breaking it outright — see the curation-rule exception above. An
603
+ // operator with a genuinely stale PRE-2026-05-27 `app`/`parachute-app` row
604
+ // (from the old, now-defunct @openparachute/app package) is handled the
605
+ // same as any other unrecognized row once un-retired: `parachute status`
606
+ // renders it, and `parachute serve` boots it via installDir's
607
+ // module.json if present and logs-and-skips otherwise — the same
608
+ // graceful fallback the `runner` registry removal (2026-07-01, see
609
+ // KNOWN_MODULES below) relies on. In practice the real parachute-app
610
+ // module's own first boot overwrites any stale row sharing its name.
556
611
  };
557
612
 
558
613
  /**
@@ -632,9 +687,9 @@ export function knownServices(): string[] {
632
687
  * manifest-declared `focus`; this map is the fallback when `module.json` omits
633
688
  * it (and the bootstrap value before a module is installed).
634
689
  *
635
- * - `core` — vault / scribe / hub / surface (the product surface).
636
- * - `experimental` agent (legit preview; still OFFERED on a fresh install)
637
- * + any unlisted third-party short.
690
+ * - `core` — vault / scribe / hub / surface / app (the product surface;
691
+ * `app` joined 2026-07-11, hub-parity P5 the super-surface front door).
692
+ * - `experimental` — any unlisted third-party short.
638
693
  * - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
639
694
  * into parachute-surface). Still RESOLVABLE (discoverableShorts unchanged)
640
695
  * and SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but
@@ -650,7 +705,8 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
650
705
  scribe: "core",
651
706
  hub: "core",
652
707
  surface: "core",
653
- agent: "experimental",
708
+ app: "core",
709
+
654
710
  notes: "deprecated",
655
711
  };
656
712
 
@@ -738,7 +794,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
738
794
  /**
739
795
  * Resolve the runtime spec for a known short name.
740
796
  *
741
- * FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
797
+ * FIRST_PARTY_FALLBACKS shorts (notes / app) return a fully-composed
742
798
  * spec with embedded manifest + extras — the vendored manifest is the
743
799
  * source of truth pre-install and the install path preserves it through.
744
800
  *
@@ -847,11 +903,10 @@ export async function getSpecFromInstallDir(
847
903
  */
848
904
  const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
849
905
  "parachute-lens": "notes",
850
- "parachute-channel": "agent",
851
906
  };
852
907
 
853
908
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
854
- * Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
909
+ * Consults both FIRST_PARTY_FALLBACKS (notes / app) and KNOWN_MODULES
855
910
  * (vault / scribe / agent / surface — post-FALLBACK-retirement).
856
911
  * Returns undefined for unknown manifests. */
857
912
  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,23 +672,29 @@ 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
- const migrated = migrateClawToAgent(validated);
688
- const changed = afterRetired.changed || cleaned.changed || migrated.changed;
689
- if (changed) writeManifest(migrated.manifest, path);
690
- return migrated.manifest;
695
+ const changed = healed.changed || afterRetired.changed || cleaned.changed;
696
+ if (changed) writeManifest(validated, path);
697
+ return validated;
691
698
  }
692
699
 
693
700
  /**
@@ -724,7 +731,12 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
724
731
  if (!row || typeof row !== "object") return true;
725
732
  const name = (row as Record<string, unknown>).name;
726
733
  if (typeof name !== "string") return true;
727
- const retired = RETIRED_MODULES[name];
734
+ // `claw` is too generic to reserve globally, but the historical Agent row
735
+ // had a structural identity: name=claw mounted at /claw. Retire only that
736
+ // shape; an unrelated third-party `claw` on another mount remains valid.
737
+ const paths = (row as Record<string, unknown>).paths;
738
+ const retiredClawShape = name === "claw" && Array.isArray(paths) && paths[0] === "/claw";
739
+ const retired = RETIRED_MODULES[name] ?? (retiredClawShape ? RETIRED_MODULES.agent : undefined);
728
740
  if (retired === undefined) return true;
729
741
  dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
730
742
  return false;
@@ -745,6 +757,91 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
745
757
  };
746
758
  }
747
759
 
760
+ /**
761
+ * Heal stale OLD-UI-host `app`/`parachute-app` rows on load (hub-parity P5,
762
+ * 2026-07-11).
763
+ *
764
+ * Context: the name `parachute-app` originally belonged to the UI-host module
765
+ * that renamed to `parachute-surface` on 2026-05-27 (patterns#102). Its
766
+ * retirement used to be handled by a `RETIRED_MODULES` entry that
767
+ * `dropRetiredModuleRows` GC'd unconditionally. Hub-parity P5 REUSES the
768
+ * `parachute-app` manifestName (+ the bare `app` short) for a genuinely NEW,
769
+ * unrelated module — the super-surface front door — so that retirement entry
770
+ * had to be removed (keeping it would GC the new module's own row on every
771
+ * read). This heal is the surgically narrow replacement for the ONE case the
772
+ * removed retirement still needs to cover: an operator upgrading DIRECTLY from
773
+ * a pre-rename release (~0.5.13-stable) who skipped every intermediate boot
774
+ * that would have GC'd their stale row. Without a heal, that stale row would:
775
+ * - crash-loop on boot — `reconcilePortToCanonical` rewrites its port to
776
+ * 1944, then the supervisor spawns the app static-serve shim against a
777
+ * `@openparachute/parachute-app` that isn't installed → exit 1 → a
778
+ * forever-restarting "app" unit the operator never installed;
779
+ * - hijack `/surface/*` if a real `parachute-surface` row is also present
780
+ * (both claim `/surface`);
781
+ * - and, after `parachute install app`, keep its stale `/surface` mount so
782
+ * the app serves under `/surface` while the install's `root_redirect=/app/`
783
+ * points the front page at a 404.
784
+ *
785
+ * Discriminant (structural — pinned against the OLD-UI-host row shape verified
786
+ * in `git show origin/main`'s retired-`app` + legacy-short-name test fixtures:
787
+ * `port: 1946`, `paths: ["/surface"]`, `health: "/surface/..."`):
788
+ * - `name` is exactly `"app"` or `"parachute-app"`, AND
789
+ * - it is NOT the NEW app's own row (the new app mounts at `/app` — never
790
+ * healed regardless of port), AND
791
+ * - it carries the OLD-UI-host signature: `paths[0]` is `/surface`-rooted OR
792
+ * `port === 1946` (the old canonical slot; now `parachute-surface`'s).
793
+ *
794
+ * The NEW app row (`paths: ["/app"]`, port 1944) is structurally disjoint on
795
+ * BOTH clauses, so it is always left intact. Matches `dropRetiredModuleRows`'s
796
+ * behavior: a plain DROP + a stderr warning steering the operator to
797
+ * `parachute install surface` (the same replacement the retirement named).
798
+ *
799
+ * Operates on raw JSON (before `validateManifest`) because a stale row can
800
+ * collide on port 1946 with a real `parachute-surface` row and would otherwise
801
+ * trip the duplicate-port gate before this heal runs. Runs FIRST in the load
802
+ * pipeline (ahead of `dropRetiredModuleRows` / `dropLegacyShortNameRows`): a
803
+ * pre-rename operator can carry BOTH a `parachute-app` and an `app` stale row
804
+ * (the 2026-05-22 double-write reproducer) — dropping by shape here clears
805
+ * both, whereas `dropLegacyShortNameRows` would keep the `parachute-app` half.
806
+ */
807
+ function healStaleUiHostAppRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
808
+ if (!raw || typeof raw !== "object") return { raw, changed: false };
809
+ const services = (raw as Record<string, unknown>).services;
810
+ if (!Array.isArray(services)) return { raw, changed: false };
811
+
812
+ const dropped: string[] = [];
813
+ const nextServices = services.filter((row) => {
814
+ if (!row || typeof row !== "object") return true;
815
+ const rec = row as Record<string, unknown>;
816
+ const name = rec.name;
817
+ if (name !== "app" && name !== "parachute-app") return true;
818
+ const paths = rec.paths;
819
+ const first = Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
820
+ // NEW super-surface app mounts at `/app` (port 1944) — never heal it,
821
+ // whatever its port happens to be after a reconcile/collision walk.
822
+ if (first === "/app" || first?.startsWith("/app/")) return true;
823
+ const port = rec.port;
824
+ const oldSurfaceMount = first === "/surface" || first?.startsWith("/surface/") === true;
825
+ const oldCanonicalPort = port === 1946;
826
+ if (!oldSurfaceMount && !oldCanonicalPort) return true;
827
+ dropped.push(String(name));
828
+ return false;
829
+ });
830
+
831
+ if (dropped.length === 0) return { raw, changed: false };
832
+
833
+ for (const name of dropped) {
834
+ console.error(
835
+ `${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\`.`,
836
+ );
837
+ }
838
+
839
+ return {
840
+ raw: { ...(raw as Record<string, unknown>), services: nextServices },
841
+ changed: true,
842
+ };
843
+ }
844
+
748
845
  /**
749
846
  * Drop legacy short-name rows when a same-module manifestName row exists
750
847
  * on the same port. Handles the duplicate-row class introduced when a
@@ -836,43 +933,6 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
836
933
  };
837
934
  }
838
935
 
839
- /**
840
- * Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
841
- * to parachute-agent across the ecosystem (npm package, mount path, short
842
- * name); operators who upgraded hub but still have the old paraclaw row
843
- * in services.json would otherwise see a tile labelled "Claw" and a hub
844
- * route at `/claw` while their newly-upgraded daemon listens on `/agent`.
845
- *
846
- * Idempotent. Only rewrites when both `name === "claw"` AND the first path
847
- * is `/claw` — narrow enough that a deliberately-named third-party module
848
- * (e.g. `name: "claw"` on a different mount) is left alone. Health and any
849
- * `/claw`-rooted paths are rewritten in lockstep.
850
- */
851
- function migrateClawToAgent(manifest: ServicesManifest): {
852
- manifest: ServicesManifest;
853
- changed: boolean;
854
- } {
855
- let changed = false;
856
- const services = manifest.services.map((entry) => {
857
- if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
858
- changed = true;
859
- const next: ServiceEntry = {
860
- ...entry,
861
- name: "agent",
862
- paths: entry.paths.map((p) => rewriteClawPath(p)),
863
- health: rewriteClawPath(entry.health),
864
- };
865
- return next;
866
- });
867
- return { manifest: { services }, changed };
868
- }
869
-
870
- function rewriteClawPath(p: string): string {
871
- if (p === "/claw") return "/agent";
872
- if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
873
- return p;
874
- }
875
-
876
936
  export function writeManifest(
877
937
  manifest: ServicesManifest,
878
938
  path: string = SERVICES_MANIFEST_PATH,