@sentropic/h2a-runtime 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/dist/account-pool.d.ts +168 -0
  2. package/dist/account-pool.d.ts.map +1 -0
  3. package/dist/account-pool.js +385 -0
  4. package/dist/account-pool.js.map +1 -0
  5. package/dist/agents-projection.d.ts +56 -0
  6. package/dist/agents-projection.d.ts.map +1 -0
  7. package/dist/agents-projection.js +82 -0
  8. package/dist/agents-projection.js.map +1 -0
  9. package/dist/attach.d.ts +75 -0
  10. package/dist/attach.d.ts.map +1 -0
  11. package/dist/attach.js +432 -0
  12. package/dist/attach.js.map +1 -0
  13. package/dist/auth-bundle.d.ts +21 -0
  14. package/dist/auth-bundle.d.ts.map +1 -0
  15. package/dist/auth-bundle.js +112 -0
  16. package/dist/auth-bundle.js.map +1 -0
  17. package/dist/auth-diagnostics.d.ts +18 -0
  18. package/dist/auth-diagnostics.d.ts.map +1 -0
  19. package/dist/auth-diagnostics.js +23 -0
  20. package/dist/auth-diagnostics.js.map +1 -0
  21. package/dist/auth-login.d.ts +9 -0
  22. package/dist/auth-login.d.ts.map +1 -0
  23. package/dist/auth-login.js +21 -0
  24. package/dist/auth-login.js.map +1 -0
  25. package/dist/auth-refresh.d.ts +32 -0
  26. package/dist/auth-refresh.d.ts.map +1 -0
  27. package/dist/auth-refresh.js +83 -0
  28. package/dist/auth-refresh.js.map +1 -0
  29. package/dist/auth-tools.d.ts +55 -0
  30. package/dist/auth-tools.d.ts.map +1 -0
  31. package/dist/auth-tools.js +163 -0
  32. package/dist/auth-tools.js.map +1 -0
  33. package/dist/browser-bridge-local.d.ts +27 -0
  34. package/dist/browser-bridge-local.d.ts.map +1 -0
  35. package/dist/browser-bridge-local.js +51 -0
  36. package/dist/browser-bridge-local.js.map +1 -0
  37. package/dist/browser.d.ts +53 -0
  38. package/dist/browser.d.ts.map +1 -0
  39. package/dist/browser.js +61 -0
  40. package/dist/browser.js.map +1 -0
  41. package/dist/conductor-launch.d.ts +179 -0
  42. package/dist/conductor-launch.d.ts.map +1 -0
  43. package/dist/conductor-launch.js +414 -0
  44. package/dist/conductor-launch.js.map +1 -0
  45. package/dist/config.d.ts +182 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +334 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/conv-guard.d.ts +76 -0
  50. package/dist/conv-guard.d.ts.map +1 -0
  51. package/dist/conv-guard.js +131 -0
  52. package/dist/conv-guard.js.map +1 -0
  53. package/dist/convsync.d.ts +24 -0
  54. package/dist/convsync.d.ts.map +1 -0
  55. package/dist/convsync.js +108 -0
  56. package/dist/convsync.js.map +1 -0
  57. package/dist/cred-health.d.ts +96 -0
  58. package/dist/cred-health.d.ts.map +1 -0
  59. package/dist/cred-health.js +170 -0
  60. package/dist/cred-health.js.map +1 -0
  61. package/dist/delegate.d.ts +406 -0
  62. package/dist/delegate.d.ts.map +1 -0
  63. package/dist/delegate.js +739 -0
  64. package/dist/delegate.js.map +1 -0
  65. package/dist/enroll.d.ts +91 -0
  66. package/dist/enroll.d.ts.map +1 -0
  67. package/dist/enroll.js +248 -0
  68. package/dist/enroll.js.map +1 -0
  69. package/dist/fanout.d.ts +63 -0
  70. package/dist/fanout.d.ts.map +1 -0
  71. package/dist/fanout.js +115 -0
  72. package/dist/fanout.js.map +1 -0
  73. package/dist/forward.d.ts +66 -0
  74. package/dist/forward.d.ts.map +1 -0
  75. package/dist/forward.js +141 -0
  76. package/dist/forward.js.map +1 -0
  77. package/dist/gitdiff.d.ts +31 -0
  78. package/dist/gitdiff.d.ts.map +1 -0
  79. package/dist/gitdiff.js +133 -0
  80. package/dist/gitdiff.js.map +1 -0
  81. package/dist/h2a-bridge.d.ts +102 -0
  82. package/dist/h2a-bridge.d.ts.map +1 -0
  83. package/dist/h2a-bridge.js +333 -0
  84. package/dist/h2a-bridge.js.map +1 -0
  85. package/dist/h2a-jobs.d.ts +235 -0
  86. package/dist/h2a-jobs.d.ts.map +1 -0
  87. package/dist/h2a-jobs.js +388 -0
  88. package/dist/h2a-jobs.js.map +1 -0
  89. package/dist/h2a-ping.d.ts +38 -0
  90. package/dist/h2a-ping.d.ts.map +1 -0
  91. package/dist/h2a-ping.js +58 -0
  92. package/dist/h2a-ping.js.map +1 -0
  93. package/dist/index.d.ts +98 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +5887 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/interactive-throttle.d.ts +164 -0
  98. package/dist/interactive-throttle.d.ts.map +1 -0
  99. package/dist/interactive-throttle.js +194 -0
  100. package/dist/interactive-throttle.js.map +1 -0
  101. package/dist/lineage-client.d.ts +77 -0
  102. package/dist/lineage-client.d.ts.map +1 -0
  103. package/dist/lineage-client.js +117 -0
  104. package/dist/lineage-client.js.map +1 -0
  105. package/dist/lineage-lease.d.ts +152 -0
  106. package/dist/lineage-lease.d.ts.map +1 -0
  107. package/dist/lineage-lease.js +303 -0
  108. package/dist/lineage-lease.js.map +1 -0
  109. package/dist/llm-gateway-runtime/accounts.d.ts +48 -0
  110. package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -0
  111. package/dist/llm-gateway-runtime/accounts.js +226 -0
  112. package/dist/llm-gateway-runtime/accounts.js.map +1 -0
  113. package/dist/llm-gateway-runtime/index.d.ts +4 -0
  114. package/dist/llm-gateway-runtime/index.d.ts.map +1 -0
  115. package/dist/llm-gateway-runtime/index.js +58 -0
  116. package/dist/llm-gateway-runtime/index.js.map +1 -0
  117. package/dist/llm-gateway-runtime/model-catalog.d.ts +36 -0
  118. package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -0
  119. package/dist/llm-gateway-runtime/model-catalog.js +146 -0
  120. package/dist/llm-gateway-runtime/model-catalog.js.map +1 -0
  121. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +3 -0
  122. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -0
  123. package/dist/llm-gateway-runtime/proxy-anthropic.js +139 -0
  124. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -0
  125. package/dist/llm-gateway-runtime/proxy-openai.d.ts +117 -0
  126. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -0
  127. package/dist/llm-gateway-runtime/proxy-openai.js +1132 -0
  128. package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -0
  129. package/dist/llm-gateway-runtime/session-ledger.d.ts +32 -0
  130. package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -0
  131. package/dist/llm-gateway-runtime/session-ledger.js +62 -0
  132. package/dist/llm-gateway-runtime/session-ledger.js.map +1 -0
  133. package/dist/llm-gateway-runtime/sticky.d.ts +40 -0
  134. package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -0
  135. package/dist/llm-gateway-runtime/sticky.js +254 -0
  136. package/dist/llm-gateway-runtime/sticky.js.map +1 -0
  137. package/dist/llm-mesh.d.ts +102 -0
  138. package/dist/llm-mesh.d.ts.map +1 -0
  139. package/dist/llm-mesh.js +433 -0
  140. package/dist/llm-mesh.js.map +1 -0
  141. package/dist/migrate-candidates.d.ts +35 -0
  142. package/dist/migrate-candidates.d.ts.map +1 -0
  143. package/dist/migrate-candidates.js +128 -0
  144. package/dist/migrate-candidates.js.map +1 -0
  145. package/dist/migrate.d.ts +125 -0
  146. package/dist/migrate.d.ts.map +1 -0
  147. package/dist/migrate.js +561 -0
  148. package/dist/migrate.js.map +1 -0
  149. package/dist/plugin-manifest.d.ts +143 -0
  150. package/dist/plugin-manifest.d.ts.map +1 -0
  151. package/dist/plugin-manifest.js +233 -0
  152. package/dist/plugin-manifest.js.map +1 -0
  153. package/dist/plugin.d.ts +274 -0
  154. package/dist/plugin.d.ts.map +1 -0
  155. package/dist/plugin.js +919 -0
  156. package/dist/plugin.js.map +1 -0
  157. package/dist/pod-liveness.d.ts +51 -0
  158. package/dist/pod-liveness.d.ts.map +1 -0
  159. package/dist/pod-liveness.js +80 -0
  160. package/dist/pod-liveness.js.map +1 -0
  161. package/dist/profile-menu.d.ts +7 -0
  162. package/dist/profile-menu.d.ts.map +1 -0
  163. package/dist/profile-menu.js +52 -0
  164. package/dist/profile-menu.js.map +1 -0
  165. package/dist/profiles.d.ts +23 -0
  166. package/dist/profiles.d.ts.map +1 -0
  167. package/dist/profiles.js +66 -0
  168. package/dist/profiles.js.map +1 -0
  169. package/dist/protocol-local.d.ts +27 -0
  170. package/dist/protocol-local.d.ts.map +1 -0
  171. package/dist/protocol-local.js +12 -0
  172. package/dist/protocol-local.js.map +1 -0
  173. package/dist/pty.d.ts +26 -0
  174. package/dist/pty.d.ts.map +1 -0
  175. package/dist/pty.js +45 -0
  176. package/dist/pty.js.map +1 -0
  177. package/dist/readiness.d.ts +38 -0
  178. package/dist/readiness.d.ts.map +1 -0
  179. package/dist/readiness.js +169 -0
  180. package/dist/readiness.js.map +1 -0
  181. package/dist/registry.d.ts +258 -0
  182. package/dist/registry.d.ts.map +1 -0
  183. package/dist/registry.js +570 -0
  184. package/dist/registry.js.map +1 -0
  185. package/dist/relaunch.d.ts +52 -0
  186. package/dist/relaunch.d.ts.map +1 -0
  187. package/dist/relaunch.js +76 -0
  188. package/dist/relaunch.js.map +1 -0
  189. package/dist/restore.d.ts +165 -0
  190. package/dist/restore.d.ts.map +1 -0
  191. package/dist/restore.js +530 -0
  192. package/dist/restore.js.map +1 -0
  193. package/dist/run.d.ts +29 -0
  194. package/dist/run.d.ts.map +1 -0
  195. package/dist/run.js +206 -0
  196. package/dist/run.js.map +1 -0
  197. package/dist/secrets.d.ts +25 -0
  198. package/dist/secrets.d.ts.map +1 -0
  199. package/dist/secrets.js +98 -0
  200. package/dist/secrets.js.map +1 -0
  201. package/dist/session-restore.d.ts +26 -0
  202. package/dist/session-restore.d.ts.map +1 -0
  203. package/dist/session-restore.js +122 -0
  204. package/dist/session-restore.js.map +1 -0
  205. package/dist/skills-sync.d.ts +126 -0
  206. package/dist/skills-sync.d.ts.map +1 -0
  207. package/dist/skills-sync.js +255 -0
  208. package/dist/skills-sync.js.map +1 -0
  209. package/dist/smoke.d.ts +23 -0
  210. package/dist/smoke.d.ts.map +1 -0
  211. package/dist/smoke.js +138 -0
  212. package/dist/smoke.js.map +1 -0
  213. package/dist/soft-refresh.d.ts +107 -0
  214. package/dist/soft-refresh.d.ts.map +1 -0
  215. package/dist/soft-refresh.js +426 -0
  216. package/dist/soft-refresh.js.map +1 -0
  217. package/dist/sync-status.d.ts +22 -0
  218. package/dist/sync-status.d.ts.map +1 -0
  219. package/dist/sync-status.js +41 -0
  220. package/dist/sync-status.js.map +1 -0
  221. package/dist/sync.d.ts +77 -0
  222. package/dist/sync.d.ts.map +1 -0
  223. package/dist/sync.js +302 -0
  224. package/dist/sync.js.map +1 -0
  225. package/dist/throttle-signatures.d.ts +43 -0
  226. package/dist/throttle-signatures.d.ts.map +1 -0
  227. package/dist/throttle-signatures.js +116 -0
  228. package/dist/throttle-signatures.js.map +1 -0
  229. package/dist/tmux.d.ts +286 -0
  230. package/dist/tmux.d.ts.map +1 -0
  231. package/dist/tmux.js +970 -0
  232. package/dist/tmux.js.map +1 -0
  233. package/dist/tunnel.d.ts +25 -0
  234. package/dist/tunnel.d.ts.map +1 -0
  235. package/dist/tunnel.js +127 -0
  236. package/dist/tunnel.js.map +1 -0
  237. package/dist/workspace-merge.d.ts +17 -0
  238. package/dist/workspace-merge.d.ts.map +1 -0
  239. package/dist/workspace-merge.js +153 -0
  240. package/dist/workspace-merge.js.map +1 -0
  241. package/dist/workspace-sync-incremental.d.ts +64 -0
  242. package/dist/workspace-sync-incremental.d.ts.map +1 -0
  243. package/dist/workspace-sync-incremental.js +199 -0
  244. package/dist/workspace-sync-incremental.js.map +1 -0
  245. package/dist/workspace-sync.d.ts +3 -0
  246. package/dist/workspace-sync.d.ts.map +1 -0
  247. package/dist/workspace-sync.js +187 -0
  248. package/dist/workspace-sync.js.map +1 -0
  249. package/dist/workspace.d.ts +70 -0
  250. package/dist/workspace.d.ts.map +1 -0
  251. package/dist/workspace.js +173 -0
  252. package/dist/workspace.js.map +1 -0
  253. package/package.json +44 -0
package/dist/plugin.js ADDED
@@ -0,0 +1,919 @@
1
+ /**
2
+ * `remote plugin` — install npm "plugin" packages (a CLI + an MCP server, e.g.
3
+ * @sentropic/track shipping bins `track` and `track-mcp`) for every agent CLI
4
+ * (claude, codex, agy), both LOCALLY (npm i -g + MCP registration) and inside
5
+ * live REMOTE session Pods (`remote plugin sync`: kubectl exec → npm i -g +
6
+ * per-profile MCP registration).
7
+ *
8
+ * agy (Antigravity CLI) has NO `agy mcp` subcommand: MCP servers are declared
9
+ * in ~/.gemini/config/mcp_config.json, a Claude-style `{"mcpServers": {…}}`
10
+ * JSON file (the agy changelog 1.0.3 calls this the "migrated" path; the old
11
+ * ~/.gemini/antigravity/mcp_config.json is legacy). We merge idempotently and
12
+ * keep a one-shot `.bak.<epoch>` backup the first time we touch a non-empty
13
+ * file.
14
+ *
15
+ * KNOWN PITFALL — broken entrypoint guard through the npm-global symlink:
16
+ * some packages (track@0.2.0) guard their entry script with a
17
+ * "was-I-run-directly?" check that compares argv[1] with the module path;
18
+ * invoked through the npm-global bin SYMLINK the two differ and the guard
19
+ * never fires (the CLI/MCP silently does nothing). So MCP servers are ALWAYS
20
+ * registered as `node <realpathSync(script)>` — never the bare bin name.
21
+ *
22
+ * Baking plugins into the session image is a separate TODO that belongs in
23
+ * packages/session-agent/Dockerfile (left untouched here): until then a Pod
24
+ * restart loses globally-installed plugins and `remote plugin sync` must be
25
+ * re-run.
26
+ *
27
+ * DRIFT (remote supervise slice 3, ADDITIVE): a desired-state manifest
28
+ * (plugin-manifest.ts) + a sidecar pushed to each Pod (~/.remote-manifest.json /
29
+ * ~/.remote-manifest.sha256, gated EXACTLY like CREDS_HASH_FILE), a read-only
30
+ * `plugin sync --check` drift report, and a watch-pass reconcile that re-runs
31
+ * the EXISTING buildPodSyncScript on a hash mismatch (an extra TRIGGER, not a
32
+ * new push path). The plain `plugin add`/`plugin sync` push is UNCHANGED.
33
+ *
34
+ * TODO(slice 4 — converge-on-session-start, the BIGGEST remaining drift source):
35
+ * a freshly (re)created Pod boots with NO plugins until the next sync/watch pass,
36
+ * so its CLI launches WITHOUT track/h2a/etc. Closing that needs the session-agent
37
+ * startup path (packages/session-agent, the pane that launches `<cli>`) to run
38
+ * the sync + write the manifest sidecar BEFORE exec'ing the CLI — OUT OF SCOPE
39
+ * here (do not touch session-agent this slice). The watch-pass reconcile is the
40
+ * interim safety net.
41
+ */
42
+ import { spawnSync } from "node:child_process";
43
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
44
+ import { homedir } from "node:os";
45
+ import { join } from "node:path";
46
+ import { listRemoteSessions } from "./attach.js";
47
+ import { getPlugins, getTunnel, setPlugins, } from "./config.js";
48
+ import { canonicalManifestJson, checkExitCode, diffManifest, isDrift, manifestHash, parseNpmLsVersions, parseRegisteredMcpServers, renderManifest, } from "./plugin-manifest.js";
49
+ // ---------------------------------------------------------------------------
50
+ // Pure helpers (exported for tests)
51
+ // ---------------------------------------------------------------------------
52
+ /** MCP server names end up as TOML bare keys and shell words: keep them tame. */
53
+ const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
54
+ /** npm package name, optionally scoped. */
55
+ const SAFE_PKG = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
56
+ /** Version as installed (1.2.3, 1.2.3-rc.1+build, …). */
57
+ const SAFE_VERSION = /^[0-9A-Za-z.+-]+$/;
58
+ /** Package-relative script path embedded in the Pod sync script. */
59
+ const SAFE_REL = /^[A-Za-z0-9_@./-]+$/;
60
+ function assertSafeName(name) {
61
+ if (!SAFE_NAME.test(name)) {
62
+ throw new Error(`invalid MCP server name "${name}" (allowed: letters, digits, "_", "-")`);
63
+ }
64
+ }
65
+ function assertSafePkg(pkg) {
66
+ if (!SAFE_PKG.test(pkg))
67
+ throw new Error(`invalid npm package name "${pkg}"`);
68
+ }
69
+ /**
70
+ * The shell install command for a plugin + a one-line label. Pure, exported for
71
+ * tests. npm (default): `npm install -g <pkg>@<ver>`. curl: pipe an https
72
+ * installer (`curl -fsSL <url> | bash`) — the url is single-quoted and must be
73
+ * a clean https URL. script: an arbitrary shell line straight from the user's
74
+ * own config (no extra guard — it is the user's command). Same command runs
75
+ * locally (pluginAdd) and in the Pod (buildPodSyncScript).
76
+ */
77
+ export function buildInstallCommand(plugin) {
78
+ const method = plugin.install?.method ?? "npm";
79
+ if (method === "npm") {
80
+ assertSafePkg(plugin.pkg);
81
+ assertSafeVersion(plugin.version);
82
+ const spec = `${plugin.pkg}@${plugin.version}`;
83
+ return { cmd: `npm install -g '${spec}'`, label: `installed ${spec}` };
84
+ }
85
+ if (method === "curl") {
86
+ const url = plugin.install?.spec ?? "";
87
+ if (!/^https:\/\/[^'"\s]+$/.test(url)) {
88
+ throw new Error(`invalid curl install url "${url}" (need a clean https URL)`);
89
+ }
90
+ return {
91
+ cmd: `curl -fsSL '${url}' | bash`,
92
+ label: `installed ${plugin.pkg} (curl)`,
93
+ };
94
+ }
95
+ // script: the user's own shell command, run verbatim.
96
+ const sh = (plugin.install?.spec ?? "").trim();
97
+ if (!sh)
98
+ throw new Error(`empty script install for "${plugin.pkg}"`);
99
+ return { cmd: sh, label: `installed ${plugin.pkg} (script)` };
100
+ }
101
+ function assertSafeVersion(version) {
102
+ if (!SAFE_VERSION.test(version))
103
+ throw new Error(`invalid version "${version}"`);
104
+ }
105
+ function assertSafeRel(rel) {
106
+ if (!SAFE_REL.test(rel) || rel.split("/").includes("..")) {
107
+ throw new Error(`invalid package-relative script path "${rel}"`);
108
+ }
109
+ }
110
+ /** Split `pkg[@version]` (scope-aware: the leading @ is not a version sep). */
111
+ export function splitNpmSpec(spec) {
112
+ const at = spec.indexOf("@", 1);
113
+ if (at === -1)
114
+ return { pkg: spec };
115
+ return { pkg: spec.slice(0, at), version: spec.slice(at + 1) };
116
+ }
117
+ /** Parse one `--mcp <name>=<bin>` spec. */
118
+ export function parseMcpSpec(spec) {
119
+ const eq = spec.indexOf("=");
120
+ if (eq <= 0 || eq === spec.length - 1) {
121
+ throw new Error(`invalid --mcp "${spec}" — expected <name>=<bin> (e.g. track=track-mcp)`);
122
+ }
123
+ const name = spec.slice(0, eq).trim();
124
+ const bin = spec.slice(eq + 1).trim();
125
+ assertSafeName(name);
126
+ if (!bin)
127
+ throw new Error(`invalid --mcp "${spec}" — empty bin`);
128
+ return { name, bin };
129
+ }
130
+ export function parseMcpSpecs(specs) {
131
+ return specs.map(parseMcpSpec);
132
+ }
133
+ /**
134
+ * Heuristic when no --mcp is given: every bin ending in `-mcp` is an MCP
135
+ * server named after the bin minus the suffix (track-mcp → track).
136
+ */
137
+ export function detectMcpBins(bins) {
138
+ const requests = [];
139
+ for (const bin of Object.keys(bins).sort()) {
140
+ if (!bin.endsWith("-mcp"))
141
+ continue;
142
+ const name = bin.slice(0, -"-mcp".length);
143
+ if (!name || !SAFE_NAME.test(name))
144
+ continue;
145
+ requests.push({ name, bin });
146
+ }
147
+ return requests;
148
+ }
149
+ /** Normalize a package.json `bin` field (string or map) to name -> rel path. */
150
+ export function normalizeBins(pkgName, bin) {
151
+ if (typeof bin === "string") {
152
+ const name = pkgName.split("/").pop() ?? pkgName;
153
+ return { [name]: bin };
154
+ }
155
+ if (bin && typeof bin === "object") {
156
+ const out = {};
157
+ for (const [name, rel] of Object.entries(bin)) {
158
+ if (typeof rel === "string")
159
+ out[name] = rel;
160
+ }
161
+ return out;
162
+ }
163
+ return {};
164
+ }
165
+ /** The `[mcp_servers.<name>]` TOML section for ~/.codex/config.toml. */
166
+ export function codexMcpServerBlock(name, command, args) {
167
+ assertSafeName(name);
168
+ return [
169
+ `[mcp_servers.${name}]`,
170
+ `command = ${JSON.stringify(command)}`,
171
+ `args = [${args.map((a) => JSON.stringify(a)).join(", ")}]`,
172
+ ].join("\n");
173
+ }
174
+ /**
175
+ * Idempotently upsert the `[mcp_servers.<name>]` section in a config.toml
176
+ * body: an existing section (up to the next `[…]` header) is replaced in
177
+ * place, otherwise the block is appended. Applying twice is a no-op.
178
+ */
179
+ export function upsertCodexMcpServer(toml, name, command, args) {
180
+ const block = codexMcpServerBlock(name, command, args); // validates the name
181
+ // SAFE_NAME chars ([A-Za-z0-9_-]) are all regex-literal outside classes.
182
+ const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${name}\\]\\s*$`);
183
+ const lines = toml.split("\n");
184
+ const start = lines.findIndex((line) => headerRe.test(line));
185
+ if (start === -1) {
186
+ const body = toml.replace(/\n+$/, "");
187
+ return (body ? `${body}\n\n` : "") + block + "\n";
188
+ }
189
+ let end = start + 1;
190
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
191
+ end++;
192
+ const replacement = block.split("\n");
193
+ if (end < lines.length)
194
+ replacement.push("");
195
+ const next = [
196
+ ...lines.slice(0, start),
197
+ ...replacement,
198
+ ...lines.slice(end),
199
+ ].join("\n");
200
+ // A section at EOF swallows the final-newline "" line element — restore it.
201
+ return end >= lines.length && toml.endsWith("\n") && !next.endsWith("\n")
202
+ ? next + "\n"
203
+ : next;
204
+ }
205
+ /**
206
+ * Idempotently merge `mcpServers.<name>` into a JSON config body (the shape
207
+ * shared by ~/.claude.json and agy's mcp_config.json). Empty input starts a
208
+ * fresh object; invalid JSON throws (never clobber the user's state). All
209
+ * sibling keys are preserved.
210
+ */
211
+ function mergeMcpServersJson(json, name, command, args, label) {
212
+ assertSafeName(name);
213
+ let root = {};
214
+ if (json.trim()) {
215
+ const parsed = JSON.parse(json); // throws on corrupt input — on purpose
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
217
+ throw new Error(`${label} root is not an object — not touching it`);
218
+ }
219
+ root = parsed;
220
+ }
221
+ const existing = root.mcpServers;
222
+ const servers = existing && typeof existing === "object" && !Array.isArray(existing)
223
+ ? existing
224
+ : {};
225
+ servers[name] = { command, args: [...args] };
226
+ root.mcpServers = servers;
227
+ return JSON.stringify(root, null, 2) + "\n";
228
+ }
229
+ /** Idempotent `mcpServers.<name>` merge for a ~/.claude.json body. */
230
+ export function mergeClaudeMcpServers(json, name, command, args) {
231
+ return mergeMcpServersJson(json, name, command, args, "claude.json");
232
+ }
233
+ /**
234
+ * Idempotent `mcpServers.<name>` merge for agy's
235
+ * ~/.gemini/config/mcp_config.json (same Claude-style shape — the agy binary
236
+ * schema accepts command/args/env/url per server; we only emit command+args).
237
+ */
238
+ export function mergeAgyMcpServers(json, name, command, args) {
239
+ return mergeMcpServersJson(json, name, command, args, "mcp_config.json");
240
+ }
241
+ /**
242
+ * Pure write plan for agy's mcp_config.json: the merged body, whether it
243
+ * differs from `before`, and whether a `.bak.<epoch>` backup is owed (only on
244
+ * the FIRST real modification of a non-empty file — an empty/absent file has
245
+ * nothing worth backing up, and an unchanged file is never rewritten).
246
+ */
247
+ export function planAgyMcpConfigUpdate(before, name, command, args) {
248
+ const next = mergeAgyMcpServers(before, name, command, args);
249
+ const changed = next !== before;
250
+ return { next, changed, needsBackup: changed && before.trim().length > 0 };
251
+ }
252
+ /**
253
+ * In-Pod ~/.claude.json merge, run as `node -e '<this>' <name> <scriptPath>`.
254
+ * Double quotes only — the snippet is single-quoted inside the bash script.
255
+ */
256
+ export const POD_CLAUDE_MERGE_JS = 'const fs=require("fs");const p=process.env.HOME+"/.claude.json";' +
257
+ 'let j={};try{const t=fs.readFileSync(p,"utf8");if(t.trim())j=JSON.parse(t)}' +
258
+ 'catch(e){if(e.code!=="ENOENT")throw e}' +
259
+ 'if(typeof j!=="object"||j===null||Array.isArray(j))j={};' +
260
+ 'const s=j.mcpServers&&typeof j.mcpServers==="object"&&!Array.isArray(j.mcpServers)?j.mcpServers:{};' +
261
+ 's[process.argv[1]]={command:"node",args:[process.argv[2]]};j.mcpServers=s;' +
262
+ 'fs.writeFileSync(p,JSON.stringify(j,null,2)+"\\n");';
263
+ /**
264
+ * In-Pod ~/.gemini/config/mcp_config.json merge, run as
265
+ * `node -e '<this>' <name> <scriptPath>`. Double quotes only — the snippet is
266
+ * single-quoted inside the bash script. Same shape as the claude merge; the
267
+ * config dir may not exist yet in a fresh Pod, hence mkdirSync.
268
+ */
269
+ export const POD_AGY_MERGE_JS = 'const fs=require("fs");const d=process.env.HOME+"/.gemini/config";' +
270
+ "fs.mkdirSync(d,{recursive:true});" +
271
+ 'const p=d+"/mcp_config.json";' +
272
+ 'let j={};try{const t=fs.readFileSync(p,"utf8");if(t.trim())j=JSON.parse(t)}' +
273
+ 'catch(e){if(e.code!=="ENOENT")throw e}' +
274
+ 'if(typeof j!=="object"||j===null||Array.isArray(j))j={};' +
275
+ 'const s=j.mcpServers&&typeof j.mcpServers==="object"&&!Array.isArray(j.mcpServers)?j.mcpServers:{};' +
276
+ 's[process.argv[1]]={command:"node",args:[process.argv[2]]};j.mcpServers=s;' +
277
+ 'fs.writeFileSync(p,JSON.stringify(j,null,2)+"\\n");';
278
+ /** claude / claude-code → claude; codex → codex; agy / antigravity → agy. */
279
+ export function mcpTargetForProfile(profile) {
280
+ if (profile === "claude" || profile === "claude-code")
281
+ return "claude";
282
+ if (profile === "codex")
283
+ return "codex";
284
+ if (profile === "agy" || profile === "antigravity")
285
+ return "agy";
286
+ return "todo";
287
+ }
288
+ /**
289
+ * Bash script run inside a session Pod (`kubectl exec … bash -lc`) by
290
+ * `remote plugin sync`: installs the plugin globally, recomputes each MCP
291
+ * script's realpath against the POD's npm global root (the local realpath is
292
+ * meaningless there), then registers the MCP server for the Pod's profile.
293
+ * Every step echoes one line — the CLI prints them as the per-session recap.
294
+ */
295
+ export function buildPodSyncScript(plugin, profile) {
296
+ const { cmd, label } = buildInstallCommand(plugin);
297
+ const lines = [
298
+ "set -e",
299
+ `${cmd} >/dev/null 2>&1`,
300
+ `echo "${label}"`,
301
+ `ROOT="$(npm root -g)"`,
302
+ ];
303
+ const target = mcpTargetForProfile(profile);
304
+ for (const mcp of plugin.mcp) {
305
+ assertSafeName(mcp.name);
306
+ if (!mcp.scriptRel) {
307
+ lines.push(`echo "mcp ${mcp.name}: no scriptRel recorded — re-run: remote plugin add ${plugin.pkg}"`);
308
+ continue;
309
+ }
310
+ assertSafeRel(mcp.scriptRel);
311
+ if (target === "todo") {
312
+ lines.push(`echo "mcp ${mcp.name}: profile ${profile} TODO non câblé (installed only)"`);
313
+ continue;
314
+ }
315
+ // realpath INSIDE the Pod (same symlink pitfall as locally — see header).
316
+ lines.push(`REAL="$(node -p 'require("fs").realpathSync(process.argv[1])' "$ROOT/${plugin.pkg}/${mcp.scriptRel}")"`);
317
+ if (target === "claude") {
318
+ lines.push(`node -e '${POD_CLAUDE_MERGE_JS}' '${mcp.name}' "$REAL"`, `echo "mcp ${mcp.name} -> claude.json (node $REAL)"`);
319
+ }
320
+ else if (target === "agy") {
321
+ lines.push(`node -e '${POD_AGY_MERGE_JS}' '${mcp.name}' "$REAL"`, `echo "mcp ${mcp.name} -> agy mcp_config.json (node $REAL)"`);
322
+ }
323
+ else {
324
+ lines.push(`mkdir -p "$HOME/.codex"`, `touch "$HOME/.codex/config.toml"`, `if grep -q "^\\[mcp_servers\\.${mcp.name}\\]" "$HOME/.codex/config.toml"; then ` +
325
+ `echo "mcp ${mcp.name} already in codex config.toml"; else ` +
326
+ `printf '\\n[mcp_servers.${mcp.name}]\\ncommand = "node"\\nargs = ["%s"]\\n' "$REAL" >> "$HOME/.codex/config.toml"; ` +
327
+ `echo "mcp ${mcp.name} -> codex config.toml (node $REAL)"; fi`);
328
+ }
329
+ }
330
+ return lines.join("\n");
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // Local side effects (npm, claude/codex registration)
334
+ // ---------------------------------------------------------------------------
335
+ function npmGlobalRoot() {
336
+ const r = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
337
+ if (r.status !== 0) {
338
+ throw new Error(`npm root -g failed: ${(r.stderr || "").trim()}`);
339
+ }
340
+ return r.stdout.trim();
341
+ }
342
+ function commandExists(cmd) {
343
+ const r = spawnSync("bash", ["-lc", `command -v ${cmd}`], {
344
+ stdio: "ignore",
345
+ });
346
+ return r.status === 0;
347
+ }
348
+ function installGlobally(spec, stderr) {
349
+ const r = spawnSync("npm", ["install", "-g", spec], { encoding: "utf8" });
350
+ if (r.status !== 0) {
351
+ const tail = (r.stderr || r.stdout || "")
352
+ .trim()
353
+ .split("\n")
354
+ .slice(-3)
355
+ .join(" | ");
356
+ throw new Error(`npm install -g ${spec} failed: ${tail}`);
357
+ }
358
+ // stdio summary: npm's "added N packages in Xs" line, not the full wall.
359
+ const summary = (r.stdout || "")
360
+ .split("\n")
361
+ .map((line) => line.trim())
362
+ .find((line) => /^(added|changed|removed|up to date)/.test(line)) ??
363
+ "installed";
364
+ stderr.write(`[remote] npm i -g ${spec}: ${summary}\n`);
365
+ }
366
+ function readFileIfExists(path) {
367
+ try {
368
+ return readFileSync(path, "utf8");
369
+ }
370
+ catch (error) {
371
+ if (error.code === "ENOENT")
372
+ return "";
373
+ throw error;
374
+ }
375
+ }
376
+ /**
377
+ * Register an MCP server with claude: `claude mcp add --scope user` when the
378
+ * CLI is available (remove-first for idempotence — add errors on an existing
379
+ * name), else merge ~/.claude.json mcpServers directly.
380
+ */
381
+ function registerClaudeLocal(name, scriptPath, stderr) {
382
+ if (commandExists("claude")) {
383
+ spawnSync("claude", ["mcp", "remove", "--scope", "user", name], {
384
+ stdio: "ignore",
385
+ });
386
+ const r = spawnSync("claude", ["mcp", "add", "--scope", "user", name, "--", "node", scriptPath], { encoding: "utf8" });
387
+ if (r.status !== 0) {
388
+ throw new Error(`claude mcp add ${name} failed: ${(r.stderr || r.stdout || "").trim()}`);
389
+ }
390
+ stderr.write(`[remote] claude: MCP ${name} -> node ${scriptPath} (scope user)\n`);
391
+ return;
392
+ }
393
+ const path = join(homedir(), ".claude.json");
394
+ writeFileSync(path, mergeClaudeMcpServers(readFileIfExists(path), name, "node", [scriptPath]), "utf8");
395
+ stderr.write(`[remote] claude: MCP ${name} merged into ~/.claude.json (claude CLI not found)\n`);
396
+ }
397
+ /**
398
+ * Idempotent `mcpServers.<name>` merge into ~/.gemini/config/mcp_config.json
399
+ * (agy has no `mcp add` CLI command — file merge is the only mechanism). The
400
+ * first time a non-empty file is actually modified we keep a `.bak.<epoch>`
401
+ * sibling; an unchanged file is never rewritten.
402
+ */
403
+ function registerAgyLocal(name, scriptPath, stderr) {
404
+ const dir = join(homedir(), ".gemini", "config");
405
+ mkdirSync(dir, { recursive: true });
406
+ const path = join(dir, "mcp_config.json");
407
+ const before = readFileIfExists(path);
408
+ const { next, changed, needsBackup } = planAgyMcpConfigUpdate(before, name, "node", [scriptPath]);
409
+ if (!changed) {
410
+ stderr.write(`[remote] agy: MCP ${name} already in ${path} (unchanged)\n`);
411
+ return;
412
+ }
413
+ if (needsBackup) {
414
+ const backup = `${path}.bak.${Date.now()}`;
415
+ writeFileSync(backup, before, "utf8");
416
+ stderr.write(`[remote] agy: backed up ${path} -> ${backup}\n`);
417
+ }
418
+ writeFileSync(path, next, "utf8");
419
+ stderr.write(`[remote] agy: MCP ${name} -> node ${scriptPath} in ~/.gemini/config/mcp_config.json\n`);
420
+ }
421
+ /** Idempotent `[mcp_servers.<name>]` upsert in ~/.codex/config.toml. */
422
+ function registerCodexLocal(name, scriptPath, stderr) {
423
+ const dir = join(homedir(), ".codex");
424
+ mkdirSync(dir, { recursive: true });
425
+ const path = join(dir, "config.toml");
426
+ writeFileSync(path, upsertCodexMcpServer(readFileIfExists(path), name, "node", [scriptPath]), "utf8");
427
+ stderr.write(`[remote] codex: [mcp_servers.${name}] -> node ${scriptPath} in ~/.codex/config.toml\n`);
428
+ }
429
+ // ---------------------------------------------------------------------------
430
+ // kubectl exec into session Pods (pattern: soft-refresh.ts)
431
+ // ---------------------------------------------------------------------------
432
+ function expandHome(p) {
433
+ return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
434
+ }
435
+ function kubeEnv(tunnel) {
436
+ const env = { ...process.env };
437
+ if (tunnel.kubeconfig)
438
+ env.KUBECONFIG = expandHome(tunnel.kubeconfig);
439
+ return env;
440
+ }
441
+ /** Exec a bash -lc script in the session-agent container. Throws on non-zero. */
442
+ function execPod(tunnel, pod, script, input) {
443
+ const base = ["-n", tunnel.namespace, "exec"];
444
+ if (input !== undefined)
445
+ base.push("-i");
446
+ const r = spawnSync("kubectl", [...base, pod, "-c", "session-agent", "--", "bash", "-lc", script], {
447
+ encoding: "utf8",
448
+ env: kubeEnv(tunnel),
449
+ ...(input !== undefined ? { input } : {}),
450
+ });
451
+ if (r.status !== 0) {
452
+ throw new Error(`kubectl exec ${pod} failed: ${(r.stderr || r.stdout || "").trim()}`);
453
+ }
454
+ return r.stdout;
455
+ }
456
+ /**
457
+ * Exec an ARGV directly in the session-agent container — NO `bash -lc`
458
+ * string-concat (mirrors soft-refresh.ts execPodArgv, the slice-2 argv-safe pod
459
+ * exec). NEVER throws: a non-zero exit is the SIGNAL (e.g. `npm ls -g` exits
460
+ * non-zero on extraneous/missing deps but still prints the JSON we parse).
461
+ * Returns the exit code + stdout. Used by the read-only drift probe.
462
+ */
463
+ function execPodArgv(tunnel, pod, argv) {
464
+ const r = spawnSync("kubectl", ["-n", tunnel.namespace, "exec", pod, "-c", "session-agent", "--", ...argv], { encoding: "utf8", env: kubeEnv(tunnel) });
465
+ return { status: r.status ?? 1, stdout: r.stdout ?? "" };
466
+ }
467
+ // ---------------------------------------------------------------------------
468
+ // Slice 3 — desired-state manifest sidecar + drift probe + reconcile (ADDITIVE).
469
+ //
470
+ // A NEW sidecar artifact, gated EXACTLY like CREDS_HASH_FILE (skip the write
471
+ // when the Pod's recorded hash already matches). The reconcile TRIGGER re-runs
472
+ // the EXISTING buildPodSyncScript — it is NOT a new push path. The probe is
473
+ // read-only (argv-safe execPodArgv). The plain `plugin sync`/`plugin add` push
474
+ // below is untouched.
475
+ // ---------------------------------------------------------------------------
476
+ /** HOME-relative manifest sidecar in the Pod (the desired-state JSON). */
477
+ export const MANIFEST_FILE = ".remote-manifest.json";
478
+ /** HOME-relative file recording the sha256 of the last pushed manifest. */
479
+ export const MANIFEST_HASH_FILE = ".remote-manifest.sha256";
480
+ /** Render the desired-state manifest for the configured plugins (no tracked skills today). */
481
+ export function desiredManifest() {
482
+ return renderManifest(getPlugins());
483
+ }
484
+ /** Production wiring of `ManifestPodIo` against a live Pod (real kubectl exec). */
485
+ export function manifestPodIo(tunnel, pod, profile) {
486
+ return {
487
+ readHash: () => {
488
+ try {
489
+ return execPod(tunnel, pod, `cat "$HOME/${MANIFEST_HASH_FILE}" 2>/dev/null || true`).trim();
490
+ }
491
+ catch {
492
+ return "";
493
+ }
494
+ },
495
+ writeSidecar: (json, hash) => {
496
+ // Write the canonical JSON via stdin (argv-safe: no manifest content in
497
+ // the command string) then record the hash. SAFE_PKG/SAFE_NAME bound the
498
+ // JSON to tame chars; it still rides stdin so nothing is shell-interpolated.
499
+ execPod(tunnel, pod, `cat > "$HOME/${MANIFEST_FILE}"`, json);
500
+ execPod(tunnel, pod, `printf %s '${hash}' > "$HOME/${MANIFEST_HASH_FILE}"`);
501
+ },
502
+ runSync: (plugin) => execPod(tunnel, pod, buildPodSyncScript(plugin, profile)),
503
+ };
504
+ }
505
+ /**
506
+ * Push the desired-state manifest into a Pod as ~/.remote-manifest.json +
507
+ * ~/.remote-manifest.sha256, GATED exactly like CREDS_HASH_FILE: read the Pod's
508
+ * recorded hash first and SKIP the write when it already matches (so a steady
509
+ * Pod is a single `cat`, no churn). Mirrors soft-refresh's hash-sidecar pattern.
510
+ * Returns whether the sidecar was (re)written. Best-effort: caller logs errors.
511
+ */
512
+ export function pushManifestSidecar(tunnel, pod, manifest) {
513
+ return pushManifestSidecarVia(manifestPodIo(tunnel, pod, ""), manifest);
514
+ }
515
+ /** Sidecar push over an injected `ManifestPodIo` (the testable core). PURE-ish. */
516
+ export function pushManifestSidecarVia(io, manifest) {
517
+ const hash = manifestHash(manifest);
518
+ let podHash = "";
519
+ try {
520
+ podHash = io.readHash();
521
+ }
522
+ catch {
523
+ // unreadable — fall through and (re)write; the write surfaces real errors.
524
+ }
525
+ if (podHash === hash)
526
+ return { wrote: false, hash };
527
+ io.writeSidecar(canonicalManifestJson(manifest), hash);
528
+ return { wrote: true, hash };
529
+ }
530
+ /** The Pod's recorded manifest hash, or "" when absent/unreadable. */
531
+ export function readPodManifestHash(tunnel, pod) {
532
+ return manifestPodIo(tunnel, pod, "").readHash();
533
+ }
534
+ /**
535
+ * Probe ONE Pod's actual plugin/MCP state into a `PodPluginState` (pure shape):
536
+ * installed versions for the desired pkgs (`npm ls -g --json` → parsed) + the
537
+ * registered MCP server names (claude/agy JSON + codex TOML → parsed). PURE-ish:
538
+ * all IO is injected via `deps` (tests never shell out). Exported for tests.
539
+ */
540
+ export function probePodPluginState(desiredPkgs, deps) {
541
+ const pluginVersions = parseNpmLsVersions(deps.npmLs(desiredPkgs), desiredPkgs);
542
+ const { json, toml } = deps.readMcpConfigs();
543
+ const mcpRegistered = parseRegisteredMcpServers({ json, toml });
544
+ return { pluginVersions, mcpRegistered };
545
+ }
546
+ /** Production wiring of `PodProbeDeps` against a live Pod (real kubectl exec). */
547
+ function podProbeDeps(tunnel, pod) {
548
+ return {
549
+ npmLs: (pkgs) =>
550
+ // `npm ls -g --json <pkg…>`: argv-safe (pkgs are SAFE_PKG-validated by the
551
+ // caller). npm exits non-zero on extraneous deps but still prints the JSON.
552
+ execPodArgv(tunnel, pod, [
553
+ "npm",
554
+ "ls",
555
+ "-g",
556
+ "--json",
557
+ "--depth=0",
558
+ ...pkgs,
559
+ ]).stdout,
560
+ readMcpConfigs: () => {
561
+ // One bash read of the three config bodies, each delimited by a marker so
562
+ // we can split them back apart. Static markers — no untrusted interpolation.
563
+ const out = execPod(tunnel, pod, [
564
+ 'echo "===CLAUDE==="',
565
+ 'cat "$HOME/.claude.json" 2>/dev/null || true',
566
+ 'echo "===AGY==="',
567
+ 'cat "$HOME/.gemini/config/mcp_config.json" 2>/dev/null || true',
568
+ 'echo "===CODEX==="',
569
+ 'cat "$HOME/.codex/config.toml" 2>/dev/null || true',
570
+ ].join("; "));
571
+ const claude = sliceBetween(out, "===CLAUDE===", "===AGY===");
572
+ const agy = sliceBetween(out, "===AGY===", "===CODEX===");
573
+ const toml = sliceBetween(out, "===CODEX===", undefined);
574
+ return { json: [claude, agy], toml };
575
+ },
576
+ };
577
+ }
578
+ /** Extract the text between two markers (start-exclusive, end-exclusive). */
579
+ function sliceBetween(text, start, end) {
580
+ const s = text.indexOf(start);
581
+ if (s === -1)
582
+ return "";
583
+ const from = s + start.length;
584
+ const e = end ? text.indexOf(end, from) : -1;
585
+ return (e === -1 ? text.slice(from) : text.slice(from, e))
586
+ .replace(/^\n/, "")
587
+ .trim();
588
+ }
589
+ /**
590
+ * Watch-pass RECONCILE for ONE live Pod (an EXTRA trigger, NOT a new push path):
591
+ * compare the local desired-state hash with the Pod's recorded
592
+ * ~/.remote-manifest.sha256 (one `cat`); on a MISMATCH, re-run the EXISTING
593
+ * buildPodSyncScript for every plugin (the same mechanism `plugin sync` uses)
594
+ * and re-push the manifest sidecar. A match is a zero-work no-op. Mirrors the
595
+ * slice-2 probe→push advisory structure. Best-effort: a per-plugin failure is
596
+ * logged; the others proceed. Returns what happened. Used by the supervise pass.
597
+ */
598
+ export function reconcilePodManifest(tunnel, pod, profile, manifest, plugins, stderr = process.stderr) {
599
+ return reconcilePodManifestVia(manifestPodIo(tunnel, pod, profile), pod, manifest, plugins, stderr);
600
+ }
601
+ /**
602
+ * Reconcile core over an injected `ManifestPodIo` (the testable seam). PURE-ish:
603
+ * all Pod IO goes through `io`, so tests drive a stateful fake Pod and assert the
604
+ * gate — a matching hash is a zero-work no-op; an absent/mismatched hash resyncs
605
+ * ONCE then writes the sidecar so the NEXT pass converges (no re-sync).
606
+ *
607
+ * PERSISTENCE FIX (slice-3 regression): the sidecar is written via the SAME `io`
608
+ * (no second pod read, no `pushManifestSidecar` re-gate that could skip it) and
609
+ * is written even when some plugin syncs FAILED, so the Pod's recorded hash
610
+ * always converges to local after a reconcile — otherwise the next pass re-reads
611
+ * an absent hash and re-reports drift forever. (The dead-pod guard upstream keeps
612
+ * us off Evicted Pods where the write would throw and never persist at all.)
613
+ */
614
+ export function reconcilePodManifestVia(io, pod, manifest, plugins, stderr = process.stderr) {
615
+ const localHash = manifestHash(manifest);
616
+ const podHash = io.readHash();
617
+ if (podHash === localHash) {
618
+ return { reconciled: false, localHash, podHash, failures: 0 };
619
+ }
620
+ // Drift detected (hash only — never a secret). Re-run the EXISTING sync script.
621
+ stderr.write(`[remote] ${pod}: plugin/MCP drift (manifest ${podHash ? "mismatch" : "absent"}) — reconciling via plugin sync\n`);
622
+ let failures = 0;
623
+ for (const plugin of plugins) {
624
+ try {
625
+ const out = io.runSync(plugin);
626
+ for (const line of out.trim().split("\n").filter(Boolean)) {
627
+ stderr.write(` ${line}\n`);
628
+ }
629
+ }
630
+ catch (error) {
631
+ failures++;
632
+ stderr.write(` ${plugin.pkg}@${plugin.version} reconcile FAILED: ${String(error).slice(0, 200)}\n`);
633
+ }
634
+ }
635
+ // Re-push the sidecar so the Pod's recorded hash converges to local — through
636
+ // the SAME io and UNCONDITIONALLY (we already know podHash != localHash), so a
637
+ // converged Pod reports NO drift next pass.
638
+ try {
639
+ io.writeSidecar(canonicalManifestJson(manifest), localHash);
640
+ }
641
+ catch (error) {
642
+ stderr.write(` manifest sidecar re-push failed: ${String(error).slice(0, 120)}\n`);
643
+ }
644
+ return { reconciled: failures === 0, localHash, podHash, failures };
645
+ }
646
+ /**
647
+ * Production wiring used by the supervise/watch pass: reconcile manifest drift
648
+ * for ONE session via the configured tunnel. Thin — the decision + the reused
649
+ * buildPodSyncScript live in `reconcilePodManifest`. No-op (and no error) when
650
+ * no plugins are configured. Best-effort; never throws into the caller.
651
+ */
652
+ export function reconcileSessionPlugins(sessionId, profile, stderr = process.stderr) {
653
+ const plugins = getPlugins();
654
+ if (plugins.length === 0)
655
+ return { reconciled: false };
656
+ const tunnel = getTunnel();
657
+ if (!tunnel)
658
+ return { reconciled: false };
659
+ const pod = `session-${sessionId}`;
660
+ const manifest = renderManifest(plugins);
661
+ const r = reconcilePodManifest(tunnel, pod, profile, manifest, plugins, stderr);
662
+ return { reconciled: r.reconciled };
663
+ }
664
+ // ---------------------------------------------------------------------------
665
+ // Commands
666
+ // ---------------------------------------------------------------------------
667
+ /**
668
+ * `remote plugin add <npmPkg> [--mcp name=bin]...` — npm i -g, register the
669
+ * MCP server(s) with claude + codex + agy, persist in the config.
670
+ */
671
+ export function pluginAdd(npmSpec, mcpSpecs, stderr = process.stderr) {
672
+ const { pkg, version: requestedVersion } = splitNpmSpec(npmSpec);
673
+ assertSafePkg(pkg);
674
+ if (requestedVersion !== undefined)
675
+ assertSafeVersion(requestedVersion);
676
+ installGlobally(npmSpec, stderr);
677
+ const pkgDir = join(npmGlobalRoot(), pkg);
678
+ const pkgJson = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
679
+ const version = pkgJson.version ?? requestedVersion ?? "latest";
680
+ assertSafeVersion(version);
681
+ const bins = normalizeBins(pkg, pkgJson.bin);
682
+ const requests = mcpSpecs.length > 0 ? parseMcpSpecs(mcpSpecs) : detectMcpBins(bins);
683
+ if (requests.length === 0) {
684
+ stderr.write(`[remote] no MCP bin detected for ${pkg} (none ends in -mcp; bins: ${Object.keys(bins).join(", ") || "none"}) — pass --mcp <name>=<bin>\n`);
685
+ }
686
+ const mcp = [];
687
+ for (const { name, bin } of requests) {
688
+ const rel = bins[bin];
689
+ if (!rel) {
690
+ throw new Error(`package ${pkg} has no bin "${bin}" (bins: ${Object.keys(bins).join(", ") || "none"})`);
691
+ }
692
+ // realpathSync: NEVER register the bare bin — the npm-global symlink breaks
693
+ // some packages' entrypoint guard (track@0.2.0). See the module header.
694
+ const scriptPath = realpathSync(join(pkgDir, rel));
695
+ const scriptRel = rel.replace(/^\.\//, "");
696
+ registerClaudeLocal(name, scriptPath, stderr);
697
+ registerCodexLocal(name, scriptPath, stderr);
698
+ registerAgyLocal(name, scriptPath, stderr);
699
+ mcp.push({ name, command: "node", args: [scriptPath], scriptRel });
700
+ }
701
+ const entry = { pkg, version, mcp };
702
+ setPlugins([...getPlugins().filter((p) => p.pkg !== pkg), entry]);
703
+ stderr.write(`[remote] plugin ${pkg}@${version} installed — ${mcp.length} MCP server(s) registered; persisted in remote config\n`);
704
+ stderr.write(`[remote] propagate to live remote sessions with: remote plugin sync\n`);
705
+ return entry;
706
+ }
707
+ /**
708
+ * `remote plugin add <name> --curl <url>` / `--install "<shell>"` — register a
709
+ * NON-npm plugin (a tool installed by piping an https script, or an arbitrary
710
+ * shell command). No local install is run (unlike the npm path, which must read
711
+ * the package to detect MCP bins): the installer runs in each Pod on
712
+ * `remote plugin sync`. Validates the command up front so a bad URL fails now.
713
+ */
714
+ export function pluginAddInstaller(name, install, stderr = process.stderr) {
715
+ assertSafePkg(name);
716
+ const entry = {
717
+ pkg: name,
718
+ version: "installer",
719
+ mcp: [],
720
+ install,
721
+ };
722
+ buildInstallCommand(entry); // throws on a bad curl url / empty script
723
+ setPlugins([...getPlugins().filter((p) => p.pkg !== name), entry]);
724
+ stderr.write(`[remote] plugin ${name} (${install.method}) persisted — installs in Pods on \`remote plugin sync\`\n`);
725
+ return entry;
726
+ }
727
+ /**
728
+ * `remote plugin ls` — pkg / version / MCPs / where (local ok/missing, remote).
729
+ *
730
+ * The REMOTE column is now REAL per-Pod drift status (slice 3), not the old
731
+ * guess-`?`: when a `url` is passed (the command is connected to the control-
732
+ * plane) we probe each live Pod via the same drift mechanism `--check` uses and
733
+ * summarize each plugin's worst per-Pod status across all Pods
734
+ * (ok / version-drift / missing). Without a url (offline) we still print `?`
735
+ * with the documented hint — the `?` only remains when we genuinely can't reach
736
+ * the cluster, never as guesswork when we can.
737
+ */
738
+ export async function pluginLs(stdout = process.stdout, url) {
739
+ const plugins = getPlugins();
740
+ if (plugins.length === 0) {
741
+ stdout.write("[remote] no plugins configured (remote plugin add <npmPkg>)\n");
742
+ return;
743
+ }
744
+ let root;
745
+ try {
746
+ root = npmGlobalRoot();
747
+ }
748
+ catch {
749
+ root = undefined;
750
+ }
751
+ // REAL per-Pod status (replaces the `?` guess) when connected: pkg -> worst
752
+ // status across all live Pods. Best-effort — any probe failure falls back to `?`.
753
+ let remoteByPkg;
754
+ const tunnel = getTunnel();
755
+ if (url && tunnel) {
756
+ try {
757
+ const manifest = renderManifest(plugins);
758
+ const desiredPkgs = manifest.plugins.map((p) => p.pkg);
759
+ const sessions = await listRemoteSessions(url);
760
+ const worst = new Map();
761
+ for (const session of sessions) {
762
+ const pod = `session-${session.id}`;
763
+ const state = probePodPluginState(desiredPkgs, podProbeDeps(tunnel, pod));
764
+ for (const r of diffManifest(manifest, pod, state)) {
765
+ if (!r.item.startsWith("plugin:"))
766
+ continue;
767
+ const pkg = r.item.slice("plugin:".length);
768
+ worst.set(pkg, worseStatus(worst.get(pkg), r.status));
769
+ }
770
+ }
771
+ if (sessions.length > 0)
772
+ remoteByPkg = worst;
773
+ }
774
+ catch {
775
+ remoteByPkg = undefined; // unreachable cluster → fall back to `?`
776
+ }
777
+ }
778
+ const w = (s, n) => s.padEnd(n);
779
+ stdout.write(`${w("PKG", 28)} ${w("VERSION", 9)} ${w("MCPS", 20)} ${w("LOCAL", 8)} REMOTE\n`);
780
+ for (const p of plugins) {
781
+ const local = root && existsSync(join(root, p.pkg, "package.json")) ? "ok" : "missing";
782
+ const mcps = p.mcp.map((m) => m.name).join(",") || "-";
783
+ const remote = remoteByPkg ? (remoteByPkg.get(p.pkg) ?? "ok") : "?";
784
+ stdout.write(`${w(p.pkg, 28)} ${w(p.version, 9)} ${w(mcps, 20)} ${w(local, 8)} ${remote}\n`);
785
+ }
786
+ stdout.write(remoteByPkg
787
+ ? "\n(REMOTE: real per-Pod status, worst across live sessions — `remote plugin sync --check` for the full per-Pod table.)\n"
788
+ : '\n(REMOTE "?": not connected to the control-plane — `remote plugin sync --check` (with the tunnel up) shows real per-Pod status.)\n');
789
+ }
790
+ /** Severity-rank two drift statuses; returns the worse (missing > version-drift > ok). */
791
+ function worseStatus(a, b) {
792
+ const rank = {
793
+ ok: 0,
794
+ "mcp-unregistered": 1,
795
+ "version-drift": 2,
796
+ missing: 3,
797
+ };
798
+ if (a === undefined)
799
+ return b;
800
+ return (rank[b] ?? 0) > (rank[a] ?? 0) ? b : a;
801
+ }
802
+ /**
803
+ * `remote plugin sync` — for every live remote session: kubectl exec into the
804
+ * Pod, `npm i -g <pkg>@<version>`, then register the MCP servers for the
805
+ * Pod's profile. Prints a per-session recap. Needs the configured tunnel.
806
+ */
807
+ export async function pluginSync(url, stderr = process.stderr) {
808
+ const plugins = getPlugins();
809
+ if (plugins.length === 0) {
810
+ stderr.write("[remote] no plugins configured (remote plugin add <npmPkg>)\n");
811
+ return;
812
+ }
813
+ const tunnel = getTunnel();
814
+ if (!tunnel) {
815
+ throw new Error("plugin sync needs a tunnel configured (remote config tunnel …)");
816
+ }
817
+ const sessions = await listRemoteSessions(url);
818
+ if (sessions.length === 0) {
819
+ stderr.write("[remote] no live remote sessions to sync\n");
820
+ return;
821
+ }
822
+ const manifest = renderManifest(plugins);
823
+ let failures = 0;
824
+ for (const session of sessions) {
825
+ const pod = `session-${session.id}`;
826
+ stderr.write(`[remote] ${session.id} (${session.profile}):\n`);
827
+ for (const plugin of plugins) {
828
+ try {
829
+ const out = execPod(tunnel, pod, buildPodSyncScript(plugin, session.profile));
830
+ for (const line of out.trim().split("\n").filter(Boolean)) {
831
+ stderr.write(` ${line}\n`);
832
+ }
833
+ }
834
+ catch (error) {
835
+ failures++;
836
+ stderr.write(` ${plugin.pkg}@${plugin.version} FAILED: ${String(error).slice(0, 200)}\n`);
837
+ }
838
+ }
839
+ // Additive: record the desired-state sidecar so drift detection (--check /
840
+ // the watch pass) has a hash to compare against. Best-effort — never fails
841
+ // the sync (a sidecar write error does not undo the converged plugins).
842
+ try {
843
+ pushManifestSidecar(tunnel, pod, manifest);
844
+ }
845
+ catch (error) {
846
+ stderr.write(` manifest sidecar push skipped: ${String(error).slice(0, 120)}\n`);
847
+ }
848
+ }
849
+ stderr.write(`[remote] plugin sync done: ${sessions.length} session(s), ${plugins.length} plugin(s)${failures > 0 ? `, ${failures} failure(s)` : ""}\n`);
850
+ if (failures > 0)
851
+ process.exitCode = 1;
852
+ }
853
+ /**
854
+ * `remote plugin sync --check` — a READ-ONLY drift report (converges NOTHING).
855
+ * For each live Pod it probes the actual installed state (npm ls -g --json for
856
+ * the desired pkgs + the registered MCP server names from the config files the
857
+ * existing sync writes) via the argv-safe pod exec, then the pure
858
+ * `diffManifest` rows it: ok / version-drift / missing / mcp-unregistered.
859
+ * Prints a table and EXITS 1 when ANY row is drift (via process.exitCode). This
860
+ * REPLACES the `plugin ls` `REMOTE ?` guesswork with REAL per-Pod status. Pure
861
+ * decisions live in plugin-manifest.ts; this is the thin probe + printer.
862
+ */
863
+ export async function pluginSyncCheck(url, stdout = process.stdout, stderr = process.stderr) {
864
+ const plugins = getPlugins();
865
+ if (plugins.length === 0) {
866
+ stderr.write("[remote] no plugins configured (remote plugin add <npmPkg>)\n");
867
+ return [];
868
+ }
869
+ const tunnel = getTunnel();
870
+ if (!tunnel) {
871
+ throw new Error("plugin sync --check needs a tunnel configured (remote config tunnel …)");
872
+ }
873
+ const sessions = await listRemoteSessions(url);
874
+ if (sessions.length === 0) {
875
+ stderr.write("[remote] no live remote sessions to check\n");
876
+ return [];
877
+ }
878
+ const manifest = renderManifest(plugins);
879
+ const desiredPkgs = manifest.plugins.map((p) => p.pkg);
880
+ const allRows = [];
881
+ for (const session of sessions) {
882
+ const pod = `session-${session.id}`;
883
+ try {
884
+ const state = probePodPluginState(desiredPkgs, podProbeDeps(tunnel, pod));
885
+ allRows.push(...diffManifest(manifest, pod, state));
886
+ }
887
+ catch (error) {
888
+ stderr.write(`[remote] ${pod}: drift probe failed: ${String(error).slice(0, 200)}\n`);
889
+ // A probe failure is itself drift we can't clear — surface every desired
890
+ // item as a probe-failure row (status missing/unregistered conservatively).
891
+ for (const p of manifest.plugins) {
892
+ allRows.push({
893
+ pod,
894
+ item: `plugin:${p.pkg}`,
895
+ status: "missing",
896
+ detail: "probe failed",
897
+ });
898
+ }
899
+ }
900
+ }
901
+ printDriftTable(allRows, stdout);
902
+ const code = checkExitCode(allRows);
903
+ if (code !== 0)
904
+ process.exitCode = code;
905
+ return allRows;
906
+ }
907
+ /** Render the drift rows as an aligned POD / ITEM / STATUS / DETAIL table. */
908
+ function printDriftTable(rows, stdout) {
909
+ const w = (s, n) => s.padEnd(n);
910
+ stdout.write(`${w("POD", 22)} ${w("ITEM", 30)} ${w("STATUS", 18)} DETAIL\n`);
911
+ for (const r of rows) {
912
+ stdout.write(`${w(r.pod, 22)} ${w(r.item, 30)} ${w(r.status, 18)} ${r.detail}\n`);
913
+ }
914
+ const drifted = rows.filter(isDrift).length;
915
+ stdout.write(drifted === 0
916
+ ? `\n[remote] no drift — every live Pod matches the desired manifest.\n`
917
+ : `\n[remote] ${drifted} drift row(s) — run \`remote plugin sync\` to converge (exit 1).\n`);
918
+ }
919
+ //# sourceMappingURL=plugin.js.map