@openparachute/hub 0.7.7-rc.9 → 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 (78) 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 +210 -38
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/oauth-handlers.test.ts +19 -8
  29. package/src/__tests__/operator-token.test.ts +1 -2
  30. package/src/__tests__/port-assign.test.ts +17 -26
  31. package/src/__tests__/root-serve.test.ts +139 -0
  32. package/src/__tests__/scope-explanations.test.ts +0 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +22 -92
  36. package/src/__tests__/setup-wizard.test.ts +301 -22
  37. package/src/__tests__/setup.test.ts +8 -13
  38. package/src/__tests__/status.test.ts +0 -5
  39. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  40. package/src/__tests__/wizard-transcription.test.ts +35 -0
  41. package/src/__tests__/wizard.test.ts +79 -0
  42. package/src/account-api.ts +6 -0
  43. package/src/admin-connections.ts +4 -2
  44. package/src/admin-lock.ts +1 -2
  45. package/src/admin-module-token.ts +13 -8
  46. package/src/admin-surfaces.ts +2 -1
  47. package/src/api-hub-upgrade.ts +2 -1
  48. package/src/api-mint-token.ts +2 -1
  49. package/src/api-modules-ops.ts +2 -1
  50. package/src/api-modules.ts +9 -7
  51. package/src/api-revoke-token.ts +2 -1
  52. package/src/api-settings-hub-origin.ts +2 -1
  53. package/src/api-settings-root-redirect.ts +97 -20
  54. package/src/api-tokens.ts +2 -1
  55. package/src/audience-gate.ts +2 -1
  56. package/src/chrome-strip.ts +16 -4
  57. package/src/commands/hub.ts +104 -0
  58. package/src/commands/install.ts +72 -27
  59. package/src/commands/migrate.ts +5 -1
  60. package/src/commands/setup.ts +0 -2
  61. package/src/commands/wizard-transcription.ts +24 -0
  62. package/src/commands/wizard.ts +34 -1
  63. package/src/help.ts +0 -1
  64. package/src/hub-server.ts +111 -54
  65. package/src/hub-settings.ts +147 -0
  66. package/src/hub.ts +64 -31
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/oauth-handlers.ts +1 -11
  69. package/src/operator-token.ts +0 -1
  70. package/src/origin-check.ts +2 -2
  71. package/src/root-serve.ts +156 -0
  72. package/src/scope-explanations.ts +2 -5
  73. package/src/service-spec.ts +18 -47
  74. package/src/services-manifest.ts +9 -42
  75. package/src/setup-wizard.ts +144 -31
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
@@ -418,13 +418,9 @@ function oauthErrorRedirect(
418
418
  * Keyed on the SHORT name (resolved from each services.json row via
419
419
  * `shortNameForManifest`) rather than the raw manifest name so a row written
420
420
  * under a legacy manifest name still counts as installed — e.g. an un-upgraded
421
- * `parachute-channel` row aliases to short `agent` (channel→agent rename
422
- * 2026-06-17), so `agent:send` is still correctly advertised until the daemon
423
- * re-registers under `parachute-agent`.
424
421
  */
425
422
  const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, short: string]> = [
426
423
  ["scribe:", "scribe"],
427
- ["agent:", "agent"],
428
424
  ["surface:", "surface"],
429
425
  ];
430
426
 
@@ -1903,13 +1899,7 @@ export async function handleApproveClientPost(
1903
1899
  // Headers logged are non-sensitive (Origin/Referer/Host are public);
1904
1900
  // the bound set is hub's own configuration. Body content not logged.
1905
1901
  console.warn(
1906
- `[oauth] approve POST same-origin check failed. headers: ` +
1907
- `origin=${JSON.stringify(req.headers.get("origin"))} ` +
1908
- `referer=${JSON.stringify(req.headers.get("referer"))} ` +
1909
- `host=${JSON.stringify(req.headers.get("host"))} ` +
1910
- `xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} ` +
1911
- `xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. ` +
1912
- `bound origins: ${JSON.stringify(bound)}`,
1902
+ `[oauth] approve POST same-origin check failed. headers: origin=${JSON.stringify(req.headers.get("origin"))} referer=${JSON.stringify(req.headers.get("referer"))} host=${JSON.stringify(req.headers.get("host"))} xff-host=${JSON.stringify(req.headers.get("x-forwarded-host"))} xff-proto=${JSON.stringify(req.headers.get("x-forwarded-proto"))}. bound origins: ${JSON.stringify(bound)}`,
1913
1903
  );
1914
1904
  return htmlError(
1915
1905
  "Cross-origin request rejected",
@@ -104,7 +104,6 @@ export const OPERATOR_TOKEN_SCOPE_SETS: Readonly<Record<OperatorScopeSet, readon
104
104
  "parachute:host:vault",
105
105
  "vault:admin",
106
106
  "scribe:admin",
107
- "agent:send",
108
107
  ],
109
108
  };
110
109
 
@@ -198,8 +198,8 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
198
198
  *
199
199
  * NOT belted, and why:
200
200
  * - GET/HEAD/OPTIONS — read-shaped; the mint GETs
201
- * (`/admin/host-admin-token`, `/admin/agent-token`,
202
- * `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
201
+ * (`/admin/host-admin-token`, `/admin/module-token/<short>`,
202
+ * `/admin/vault-admin-token/<name>`)
203
203
  * enforce GET-only with a 405 and their response bodies are unreadable
204
204
  * cross-origin (no CORS on these routes).
205
205
  * - Bearer-authed requests — a cross-site page cannot attach an
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Serving the Parachute app AT the hub's origin root (`root_mode = serve-app`).
5
+ *
6
+ * When an operator flips the root mode to `serve-app` (or a fresh hub installs
7
+ * the app during setup), the hub answers its own origin root with the installed
8
+ * `@openparachute/parachute-app` bundle instead of 302-ing to `/admin`. This is
9
+ * the self-hosted mirror of the hosted door: hit your box's URL, land in the
10
+ * app — no redirect hop.
11
+ *
12
+ * Why this is asset-correct with no rebuild: the app's Vite build is ROOT-based
13
+ * (absolute `/assets/*`, PWA scope `/`, OAuth redirect URIs at the origin root).
14
+ * The `/app` service mount serves the SAME dist through the `notes-serve.ts`
15
+ * shim with a `/app` prefix-strip; here we serve that identical dist verbatim at
16
+ * `/`, which is exactly the origin the build assumes. So `serveAppAtRoot` and
17
+ * the `/app` mount hand back byte-identical bundle files — the only difference
18
+ * is the URL prefix.
19
+ *
20
+ * Precedence is the caller's job (hub-server.ts): the fresh-hub setup wizard
21
+ * funnel and the pre-admin 503 lockout run BEFORE the root `/` handler, and
22
+ * every hub-owned route (/admin, /oauth, /.well-known, /vault, /git, /api,
23
+ * service mounts) dispatches BEFORE the SPA-fallback tail. This module only
24
+ * decides "given an already-unclaimed GET, does the app dist answer it?".
25
+ *
26
+ * NO chrome injection ever rides a root-served response — the app owns its whole
27
+ * page (same posture as the public/surface chrome opt-out). We build the
28
+ * responses here directly rather than routing through `decorateWithChrome`.
29
+ */
30
+
31
+ import { existsSync, statSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { resolveNotesDistFrom } from "./notes-serve.ts";
34
+
35
+ /** The npm package whose built `dist/` is the app front door. */
36
+ export const APP_PACKAGE = "@openparachute/parachute-app";
37
+
38
+ /**
39
+ * Namespaces that keep the hub's branded 404 even in serve-app mode. These are
40
+ * hub / protocol surfaces, never app client-side routes, so an SPA shell there
41
+ * would be wrong — and would mask a genuine 404 for an API/OAuth/well-known
42
+ * typo. (Real dist files never live under these prefixes anyway; this makes the
43
+ * "don't shell it" decision explicit rather than incidental.)
44
+ */
45
+ export const ROOT_SERVE_RESERVED_PREFIXES: readonly string[] = [
46
+ "/api/",
47
+ "/oauth/",
48
+ "/.well-known/",
49
+ ];
50
+
51
+ /**
52
+ * Build a resolver for the installed app's `dist/` directory.
53
+ *
54
+ * Resolution goes through the SAME `resolveNotesDistFrom` machinery the `/app`
55
+ * mount's static-serve shim uses (so root-serve and `/app` resolve to one dist).
56
+ * A SUCCESSFUL resolution is memoized for the process lifetime — a resolved dist
57
+ * path doesn't move while the hub runs, and re-walking `Bun.resolveSync` on every
58
+ * asset request would be needless work. A FAILURE is NOT cached: an operator who
59
+ * set `serve-app` before installing the app, then runs `parachute install app`,
60
+ * is picked up on the next request without a hub restart (dynamic recovery).
61
+ *
62
+ * `resolve` is the test seam (defaults to the real package resolution). Return
63
+ * value is the dist dir, or `null` when the app can't be resolved (not installed
64
+ * / ships no `dist/`).
65
+ */
66
+ export function makeAppDistResolver(
67
+ resolve: () => string = () => resolveNotesDistFrom({ pkg: APP_PACKAGE }),
68
+ ): () => string | null {
69
+ let cached: string | null = null;
70
+ return () => {
71
+ if (cached !== null) return cached;
72
+ try {
73
+ cached = resolve();
74
+ return cached;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ }
80
+
81
+ /** MIME overrides Bun.file doesn't infer (mirrors notes-serve.ts). */
82
+ function mimeFor(path: string): string | undefined {
83
+ // Without this the PWA install prompt sees text/html for the manifest + bails.
84
+ if (path.endsWith(".webmanifest")) return "application/manifest+json";
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Answer an already-unclaimed request from the app's root-based `dist/`, or
90
+ * return `null` to tell the caller to fall through to its normal handling
91
+ * (the `/` 302 fallback, or the branded 404 tail).
92
+ *
93
+ * Contract:
94
+ * - non-GET → null (fall through)
95
+ * - a reserved hub/protocol prefix → null (keep the branded 404)
96
+ * - GET + an existing file under dist → that file (asset-correct MIME)
97
+ * - GET `/` or a trailing-slash "dir" request → dist/index.html (SPA shell)
98
+ * - GET + no file + Accept: text/html → dist/index.html (SPA deep link)
99
+ * - GET + no file + non-HTML (API probe, etc.) → null (branded 404)
100
+ *
101
+ * `dist` is a resolved directory (from `makeAppDistResolver`); if its
102
+ * `index.html` has since vanished (app uninstalled after a successful resolve)
103
+ * we return `null` rather than serving a broken shell.
104
+ */
105
+ export function serveAppAtRoot(dist: string, req: Request, pathname: string): Response | null {
106
+ // Only GET is served (HEAD/POST/etc. keep the caller's default — a non-GET to
107
+ // an unclaimed path stays a 404, and a non-GET `/` keeps its 302).
108
+ if (req.method !== "GET") return null;
109
+
110
+ // Hub/protocol namespaces are never the app's — leave their 404 branded.
111
+ for (const prefix of ROOT_SERVE_RESERVED_PREFIXES) {
112
+ if (pathname.startsWith(prefix)) return null;
113
+ }
114
+
115
+ const indexHtml = join(dist, "index.html");
116
+ // The resolved bundle lost its shell (uninstalled mid-flight) → fall through.
117
+ if (!existsSync(indexHtml)) return null;
118
+
119
+ const spaShell = () =>
120
+ new Response(Bun.file(indexHtml), {
121
+ headers: { "content-type": "text/html; charset=utf-8" },
122
+ });
123
+
124
+ // Bare root or a trailing-slash "directory" request → the SPA shell.
125
+ if (pathname === "/" || pathname.endsWith("/")) return spaShell();
126
+
127
+ // An existing dist file: /assets/*, /icon.svg, /manifest.webmanifest, /sw.js …
128
+ // Malformed percent-encoding (e.g. `/foo%`, `/assets/%ZZ`) makes
129
+ // `decodeURIComponent` throw a URIError. Catch it and fall through to the
130
+ // branded 404 (redirect-mode parity) — WITHOUT the guard the throw escapes to
131
+ // the dispatch outer catch, which classifies a non-DB error as "other" and
132
+ // re-throws → a bare 500. This path is reachable pre-auth on a public expose,
133
+ // so a garbage-encoded asset URL must 404, never 500.
134
+ let decodedPath: string;
135
+ try {
136
+ decodedPath = decodeURIComponent(pathname);
137
+ } catch {
138
+ return null;
139
+ }
140
+ // Path-traversal guard: the joined path must stay strictly under dist/.
141
+ const filePath = join(dist, decodedPath);
142
+ if (filePath.startsWith(`${dist}/`) && existsSync(filePath) && statSync(filePath).isFile()) {
143
+ const mime = mimeFor(filePath);
144
+ return new Response(
145
+ Bun.file(filePath),
146
+ mime ? { headers: { "content-type": mime } } : undefined,
147
+ );
148
+ }
149
+
150
+ // No matching file. Serve the SPA shell ONLY for HTML navigations (a deep link
151
+ // like /some-note the app routes client-side). A browser navigation sends
152
+ // `Accept: text/html`; a non-HTML unclaimed request (API probe, a missing
153
+ // asset fetched with `Accept: */*`) falls through to the branded 404.
154
+ const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
155
+ return wantsHtml ? spaShell() : null;
156
+ }
@@ -46,7 +46,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
46
46
  "Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
47
47
  level: "admin",
48
48
  },
49
- // Optional-module scopes (scribe / agent / surface). These are in
49
+ // Optional-module scopes (scribe / surface). These are in
50
50
  // FIRST_PARTY_SCOPES (= Object.keys(this map)) but the modules may not be
51
51
  // installed — so they're GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts)
52
52
  // and only advertised in `scopes_supported` when the service is in
@@ -61,10 +61,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
61
61
  label: "Manage Scribe configuration (provider keys, models, quotas).",
62
62
  level: "admin",
63
63
  },
64
- "agent:send": {
65
- label: "Post messages to your Agent.",
66
- level: "send",
67
- },
64
+
68
65
  // Surface Git Transport scopes (surface-host). `surface:read` = clone/fetch a
69
66
  // surface's hub-hosted git repo; `surface:write` = push to it. Named forms
70
67
  // (`surface:<name>:<verb>`) collapse to these via the 3→2-segment rule in
@@ -8,7 +8,7 @@ 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
14
  * 1944 parachute-app the super-surface front door (PWA bundle,
@@ -70,7 +70,7 @@ export interface PortReservation {
70
70
  export const PORT_RESERVATIONS: readonly PortReservation[] = [
71
71
  { port: 1939, name: "parachute-hub", status: "assigned" },
72
72
  { port: 1940, name: "parachute-vault", status: "assigned" },
73
- { port: 1941, name: "parachute-agent", status: "assigned" },
73
+ { port: 1941, name: "unassigned", status: "reserved" },
74
74
  { port: 1942, name: "parachute-notes", status: "assigned" },
75
75
  { port: 1943, name: "parachute-scribe", status: "assigned" },
76
76
  // hub-parity P5 (2026-07-11): parachute-app's canonical slot — the NEW
@@ -382,8 +382,9 @@ const APP_FALLBACK: FirstPartyFallback = {
382
382
  postInstallFooter: () => [
383
383
  "",
384
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).",
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).",
387
388
  ],
388
389
  },
389
390
  };
@@ -510,8 +511,8 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
510
511
  },
511
512
  },
512
513
  // NOTE (2026-07-01): `runner` was REMOVED from this registry (decision:
513
- // Aaron 2026-07-01 — the module set of record is vault / hub / agent /
514
- // 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
515
516
  // lifecycle-addressable by short name from the hub's bootstrap registries.
516
517
  // Existing installs stay GRACEFUL: a legacy `parachute-runner` services.json
517
518
  // row is handled exactly like any unknown/third-party row — `parachute
@@ -520,29 +521,7 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
520
521
  // stamped and logs-and-skips otherwise. Deliberately NOT added to
521
522
  // RETIRED_MODULES: that registry GC-drops rows on load, which would break
522
523
  // routing for operators still running the runner daemon.
523
- agent: {
524
- short: "agent",
525
- package: "@openparachute/agent",
526
- manifestName: "parachute-agent",
527
- canonicalPort: 1941,
528
- displayName: "Agent",
529
- // Mirrors agent's own module.json (the canonical fields below do too —
530
- // keep in sync if agent's declaration changes). Renamed from `channel`
531
- // 2026-06-17 (parachute-channel → parachute-agent); the module is the
532
- // same webhook-fan-out + MCP bridge daemon, on the same port 1941.
533
- tagline: "Chat with your Claude Code sessions — a channel per session.",
534
- canonicalPaths: ["/agent"],
535
- canonicalHealth: "/health",
536
- canonicalStripPrefix: true,
537
- extras: {
538
- // Backward-compat startCmd for rows without installDir — same rationale
539
- // as scribe / vault. The bare binary IS the daemon (agent's
540
- // package.json bin maps `parachute-agent` → src/daemon.ts).
541
- startCmd: () => ["parachute-agent"],
542
- // Agent gates its endpoints behind hub-issued JWTs (agent:* scopes).
543
- hasAuth: true,
544
- },
545
- },
524
+
546
525
  surface: {
547
526
  short: "surface",
548
527
  package: "@openparachute/surface",
@@ -603,20 +582,14 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
603
582
  * entries (no successor reusing the name) still stay forever.
604
583
  */
605
584
  export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
606
- // NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
607
- // names were retired here on 2026-05-20 (the Claude-in-containers module).
608
- // They are NOW RE-ASSIGNED to the renamed channel module (parachute-channel
609
- // → parachute-agent). Keeping these as RETIRED entries would make
610
- // `dropRetiredModuleRows` drop the NEW agent module's services.json row on
611
- // load breaking the live module. The names are no longer retired; they
612
- // belong to the active agent module. The retired containers code now lives
613
- // under `parachute-agent-legacy` (GitHub) and is not installable, so there
614
- // is no live `agent` daemon to GC. (The historical `claw → agent`
615
- // migration in services-manifest.ts still rewrites stale paraclaw rows to
616
- // `name: "agent"` — they now resolve to the LIVE agent module's mount,
617
- // which is harmless / arguably correct since paraclaw was the original
618
- // "agent".)
619
- //
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.
620
593
  // NOTE (2026-07-11, hub-parity P5 — `app` / `parachute-app` UN-RETIRED):
621
594
  // this table carried `app` + `parachute-app` (retired 2026-05-27, the
622
595
  // app → surface rename, patterns#102) from hub#219 through hub-parity P4.
@@ -716,8 +689,7 @@ export function knownServices(): string[] {
716
689
  *
717
690
  * - `core` — vault / scribe / hub / surface / app (the product surface;
718
691
  * `app` joined 2026-07-11, hub-parity P5 — the super-surface front door).
719
- * - `experimental` — agent (legit preview; still OFFERED on a fresh install)
720
- * + any unlisted third-party short.
692
+ * - `experimental` — any unlisted third-party short.
721
693
  * - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
722
694
  * into parachute-surface). Still RESOLVABLE (discoverableShorts unchanged)
723
695
  * and SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but
@@ -734,7 +706,7 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
734
706
  hub: "core",
735
707
  surface: "core",
736
708
  app: "core",
737
- agent: "experimental",
709
+
738
710
  notes: "deprecated",
739
711
  };
740
712
 
@@ -931,7 +903,6 @@ export async function getSpecFromInstallDir(
931
903
  */
932
904
  const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
933
905
  "parachute-lens": "notes",
934
- "parachute-channel": "agent",
935
906
  };
936
907
 
937
908
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
@@ -692,10 +692,9 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
692
692
  const afterRetired = dropRetiredModuleRows(healed.raw, path);
693
693
  const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
694
694
  const validated = validateManifest(cleaned.raw, path);
695
- const migrated = migrateClawToAgent(validated);
696
- const changed = healed.changed || afterRetired.changed || cleaned.changed || migrated.changed;
697
- if (changed) writeManifest(migrated.manifest, path);
698
- return migrated.manifest;
695
+ const changed = healed.changed || afterRetired.changed || cleaned.changed;
696
+ if (changed) writeManifest(validated, path);
697
+ return validated;
699
698
  }
700
699
 
701
700
  /**
@@ -732,7 +731,12 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
732
731
  if (!row || typeof row !== "object") return true;
733
732
  const name = (row as Record<string, unknown>).name;
734
733
  if (typeof name !== "string") return true;
735
- 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);
736
740
  if (retired === undefined) return true;
737
741
  dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
738
742
  return false;
@@ -929,43 +933,6 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
929
933
  };
930
934
  }
931
935
 
932
- /**
933
- * Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
934
- * to parachute-agent across the ecosystem (npm package, mount path, short
935
- * name); operators who upgraded hub but still have the old paraclaw row
936
- * in services.json would otherwise see a tile labelled "Claw" and a hub
937
- * route at `/claw` while their newly-upgraded daemon listens on `/agent`.
938
- *
939
- * Idempotent. Only rewrites when both `name === "claw"` AND the first path
940
- * is `/claw` — narrow enough that a deliberately-named third-party module
941
- * (e.g. `name: "claw"` on a different mount) is left alone. Health and any
942
- * `/claw`-rooted paths are rewritten in lockstep.
943
- */
944
- function migrateClawToAgent(manifest: ServicesManifest): {
945
- manifest: ServicesManifest;
946
- changed: boolean;
947
- } {
948
- let changed = false;
949
- const services = manifest.services.map((entry) => {
950
- if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
951
- changed = true;
952
- const next: ServiceEntry = {
953
- ...entry,
954
- name: "agent",
955
- paths: entry.paths.map((p) => rewriteClawPath(p)),
956
- health: rewriteClawPath(entry.health),
957
- };
958
- return next;
959
- });
960
- return { manifest: { services }, changed };
961
- }
962
-
963
- function rewriteClawPath(p: string): string {
964
- if (p === "/claw") return "/agent";
965
- if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
966
- return p;
967
- }
968
-
969
936
  export function writeManifest(
970
937
  manifest: ServicesManifest,
971
938
  path: string = SERVICES_MANIFEST_PATH,