@app-vitals/agent-runtime 0.0.1 → 0.1.1

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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/setup.ts ADDED
@@ -0,0 +1,853 @@
1
+ /**
2
+ * agent/src/runtime/setup.ts
3
+ * Workspace bootstrapping — directory scaffolding, file seeding, plugin installation.
4
+ *
5
+ * Safe to call on every agent startup:
6
+ * - Identity/config files are seeded once using wx semantics (user edits preserved)
7
+ * - Plugin installs are idempotent (exit 0 when already present / already at latest)
8
+ */
9
+
10
+ import {
11
+ appendFileSync,
12
+ existsSync,
13
+ lstatSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ symlinkSync,
20
+ unlinkSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import type { AgentPlugin } from "@app-vitals/agent-admin";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Template loading
28
+ // ---------------------------------------------------------------------------
29
+
30
+ // Templates live in a `workspace/` directory next to the runtime source: two
31
+ // levels up in the monorepo (agent/src/runtime -> agent/workspace) and beside
32
+ // this file in the extracted layout (runtime/workspace). Probe for whichever exists.
33
+ const WORKSPACE_TEMPLATE_DIR =
34
+ [
35
+ join(import.meta.dir, "workspace"),
36
+ join(import.meta.dir, "..", "..", "workspace"),
37
+ ].find((dir) => existsSync(dir)) ??
38
+ join(import.meta.dir, "..", "..", "workspace");
39
+
40
+ // Repo root — two levels up from agent/src/. In Docker this is /app; on the Pi
41
+ // it resolves to the checkout root. Uses a local marketplace so plugins are always
42
+ // available regardless of whether the GitHub repo is accessible.
43
+ const SHIPWRIGHT_REPO_ROOT = join(import.meta.dir, "..", "..", "..");
44
+
45
+ // Local marketplace name — matches the `name` field in .claude-plugin/marketplace.json.
46
+ const SHIPWRIGHT_MARKETPLACE = "shipwright";
47
+
48
+ // Default plugin installed on all Shipwright agents.
49
+ const DEFAULT_PLUGINS: readonly string[] = ["shipwright"];
50
+
51
+ // The universal fallback type — mirrors admin's DEFAULT_SPECIALTY_NAME. Its
52
+ // templates are the built-in workspace/ dir, so it never needs a manifest lookup.
53
+ const DEFAULT_SPECIALTY_NAME = "coding";
54
+
55
+ // The persona templates a type's templatesDir must provide or it is not usable.
56
+ // Everything else (VOICE, mise, requirements, BOOTSTRAP) falls back per file to
57
+ // the built-in workspace/ dir, and SPECIALTY.md.template is optional (ARD-6.2).
58
+ const REQUIRED_TEMPLATES: readonly string[] = [
59
+ "CLAUDE.md.template",
60
+ "SOUL.md.template",
61
+ "IDENTITY.md.template",
62
+ ];
63
+
64
+ /**
65
+ * Resolves the workspace template directory for a specialty (ARD-6.1).
66
+ *
67
+ * Reads `identity.templatesDir` from `<repoRoot>/specialties/<typeName>/manifest.yaml`
68
+ * (relative paths resolve against the repo root, matching how the manifests
69
+ * spell them). Returns the built-in workspace/ dir for `coding`, an absent
70
+ * type, and any type that does not resolve (no manifest, unparseable, or the
71
+ * directory is missing or lacks a required template) — logging a warning for the latter. Never throws:
72
+ * a template lookup must not fail boot.
73
+ */
74
+ export function resolveTemplatesDir(
75
+ requestedType: string | undefined,
76
+ repoRoot: string = SHIPWRIGHT_REPO_ROOT,
77
+ warn: (message: string) => void = (m) => console.warn(m),
78
+ env: Record<string, string | undefined> = process.env,
79
+ ): string {
80
+ const typeName =
81
+ requestedType ||
82
+ env.SHIPWRIGHT_DEFAULT_AGENT_TYPE ||
83
+ DEFAULT_SPECIALTY_NAME;
84
+ if (typeName === DEFAULT_SPECIALTY_NAME) {
85
+ return WORKSPACE_TEMPLATE_DIR;
86
+ }
87
+ try {
88
+ const manifestPath = join(
89
+ env.SHIPWRIGHT_SPECIALTIES_DIR || join(repoRoot, "specialties"),
90
+ typeName,
91
+ "manifest.yaml",
92
+ );
93
+ const manifest = Bun.YAML.parse(readFileSync(manifestPath, "utf8")) as {
94
+ identity?: { templatesDir?: unknown };
95
+ };
96
+ const templatesDir = manifest?.identity?.templatesDir;
97
+ if (typeof templatesDir !== "string" || templatesDir === "") {
98
+ throw new Error("manifest has no identity.templatesDir");
99
+ }
100
+ // Manifests written for the monorepo spell templatesDir relative to the
101
+ // repo root; a self-contained specialty (e.g. the platform test fixture)
102
+ // spells it relative to its own directory.
103
+ const fromRepoRoot = resolve(repoRoot, templatesDir);
104
+ const dir = existsSync(fromRepoRoot)
105
+ ? fromRepoRoot
106
+ : resolve(dirname(manifestPath), templatesDir);
107
+ if (!statSync(dir).isDirectory()) {
108
+ throw new Error(`${dir} is not a directory`);
109
+ }
110
+ const missing = REQUIRED_TEMPLATES.filter(
111
+ (name) => !existsSync(join(dir, name)),
112
+ );
113
+ if (missing.length > 0) {
114
+ throw new Error(`${dir} is missing templates: ${missing.join(", ")}`);
115
+ }
116
+ return dir;
117
+ } catch (err) {
118
+ warn(
119
+ `[setup] could not resolve workspace templates for specialty "${typeName}" ` +
120
+ `(${(err as Error).message}) — falling back to "${DEFAULT_SPECIALTY_NAME}" templates`,
121
+ );
122
+ return WORKSPACE_TEMPLATE_DIR;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Reads a template file from `dir` (the built-in workspace/ dir by default).
128
+ * Throws clearly if the file is missing so deployment failures are loud.
129
+ */
130
+ function readTemplate(
131
+ name: string,
132
+ dir: string = WORKSPACE_TEMPLATE_DIR,
133
+ ): string {
134
+ const path = join(dir, name);
135
+ try {
136
+ return readFileSync(path, "utf8");
137
+ } catch {
138
+ throw new Error(
139
+ `Missing workspace template: ${path}. The runtime ships no product ` +
140
+ "templates of its own: set SHIPWRIGHT_SPECIALTIES_DIR to a directory " +
141
+ "of specialties and SHIPWRIGHT_DEFAULT_AGENT_TYPE to the specialty " +
142
+ "whose identity.templatesDir provides it.",
143
+ );
144
+ }
145
+ }
146
+
147
+ /** Reads `name` from `dir`, falling back to the built-in workspace/ dir when `dir` lacks it. */
148
+ function readOptionalTemplate(name: string, dir: string): string {
149
+ return readTemplate(
150
+ name,
151
+ existsSync(join(dir, name)) ? dir : WORKSPACE_TEMPLATE_DIR,
152
+ );
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Workspace state
157
+ // ---------------------------------------------------------------------------
158
+
159
+ interface WorkspaceState {
160
+ version: 1;
161
+ bootstrapSeededAt?: string;
162
+ setupCompletedAt?: string;
163
+ }
164
+
165
+ /**
166
+ * Loads workspace state from $AGENT_HOME/workspace-state.json.
167
+ * Returns { version: 1 } when the file is absent or corrupted.
168
+ */
169
+ export function loadState(home: string): WorkspaceState {
170
+ const statePath = join(home, "workspace-state.json");
171
+ if (!existsSync(statePath)) {
172
+ return { version: 1 };
173
+ }
174
+ const raw = readFileSync(statePath, "utf8");
175
+ try {
176
+ return JSON.parse(raw) as WorkspaceState;
177
+ } catch {
178
+ // Corrupted or truncated file (e.g., partial write from a prior crash) —
179
+ // fall back to defaults so startup is never blocked.
180
+ return { version: 1 };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Saves workspace state to $AGENT_HOME/workspace-state.json.
186
+ */
187
+ export function saveState(home: string, state: WorkspaceState): void {
188
+ const statePath = join(home, "workspace-state.json");
189
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // File seeding
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Writes content to path using exclusive-create flag (wx).
198
+ * Silently skips if the file already exists.
199
+ * Rethrows any other error.
200
+ */
201
+ export function seedFile(path: string, content: string): void {
202
+ try {
203
+ writeFileSync(path, content, { flag: "wx", encoding: "utf8" });
204
+ } catch (err) {
205
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
206
+ return;
207
+ }
208
+ throw err;
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // New workspace detection
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Returns true only when ALL of these markers are absent from the workspace dir:
218
+ * - IDENTITY.md
219
+ * - SOUL.md
220
+ * - memory/ directory
221
+ * - .git directory
222
+ */
223
+ export function isNewWorkspace(home: string): boolean {
224
+ const workspaceDir = join(home, "workspace");
225
+
226
+ if (existsSync(join(workspaceDir, "IDENTITY.md"))) return false;
227
+ if (existsSync(join(workspaceDir, "SOUL.md"))) return false;
228
+
229
+ const memoryPath = join(workspaceDir, "memory");
230
+ if (existsSync(memoryPath)) {
231
+ try {
232
+ if (statSync(memoryPath).isDirectory()) return false;
233
+ } catch {
234
+ // stat error — treat as absent
235
+ }
236
+ }
237
+
238
+ const gitPath = join(workspaceDir, ".git");
239
+ if (existsSync(gitPath)) {
240
+ try {
241
+ if (statSync(gitPath).isDirectory()) return false;
242
+ } catch {
243
+ // stat error — treat as absent
244
+ }
245
+ }
246
+
247
+ return true;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Baked marketplace discovery
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Convention root for derived-image marketplaces:
256
+ * /opt/shipwright/marketplaces/<name>/
257
+ *
258
+ * To ship a private marketplace with a derived image, place a directory
259
+ * containing `.claude-plugin/marketplace.json` under this root:
260
+ *
261
+ * /opt/shipwright/marketplaces/
262
+ * my-org-plugins/
263
+ * .claude-plugin/
264
+ * marketplace.json ← required; triggers discovery
265
+ * plugin.json ← optional plugin metadata
266
+ * ...
267
+ *
268
+ * The harness registers each discovered marketplace at boot via
269
+ * `claude plugin marketplace add <dir>` before the built-in shipwright
270
+ * marketplace. Plugin selection is still controlled by the AgentPlugin table —
271
+ * adding a marketplace makes its plugins available but does not install them.
272
+ *
273
+ * No env var, no DB table: marketplace availability is an IMAGE property.
274
+ */
275
+ export const BAKED_MARKETPLACES_ROOT = "/opt/shipwright/marketplaces";
276
+
277
+ /**
278
+ * Scans `conventionRoot` for directories that contain a
279
+ * `.claude-plugin/marketplace.json` file and returns their absolute paths.
280
+ *
281
+ * Returns an empty array when:
282
+ * - `conventionRoot` is an empty string
283
+ * - `conventionRoot` does not exist on disk
284
+ * - no subdirectory contains `.claude-plugin/marketplace.json`
285
+ *
286
+ * Non-throwing — all errors are handled internally.
287
+ */
288
+ export function discoverBakedMarketplaces(conventionRoot: string): string[] {
289
+ if (!conventionRoot) return [];
290
+ if (!existsSync(conventionRoot)) return [];
291
+
292
+ let entries: string[];
293
+ try {
294
+ entries = readdirSync(conventionRoot);
295
+ } catch {
296
+ return [];
297
+ }
298
+
299
+ const result: string[] = [];
300
+ for (const entry of entries) {
301
+ const dirPath = join(conventionRoot, entry);
302
+ const marketplaceJson = join(dirPath, ".claude-plugin", "marketplace.json");
303
+ if (existsSync(marketplaceJson)) {
304
+ result.push(dirPath);
305
+ }
306
+ }
307
+ return result;
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Plugin install
312
+ // ---------------------------------------------------------------------------
313
+
314
+ type ExecFn = (
315
+ cmd: string,
316
+ args: string[],
317
+ opts: { cwd: string },
318
+ ) => Promise<{ stdout: string; exitCode: number }>;
319
+
320
+ async function defaultExec(
321
+ cmd: string,
322
+ args: string[],
323
+ opts: { cwd: string },
324
+ ): Promise<{ stdout: string; exitCode: number }> {
325
+ // env: process.env is required — Bun.spawn otherwise snapshots env at Bun
326
+ // startup and misses runtime mutations (MISE_CACHE_DIR, etc.).
327
+ const proc = Bun.spawn([cmd, ...args], {
328
+ cwd: opts.cwd,
329
+ env: process.env,
330
+ stdout: "pipe",
331
+ stderr: "pipe",
332
+ });
333
+ const stdout = await new Response(proc.stdout).text();
334
+ await proc.exited;
335
+ return { stdout, exitCode: proc.exitCode ?? 1 };
336
+ }
337
+
338
+ /**
339
+ * Reads the Claude Code plugin manifest and returns specs whose recorded
340
+ * installPath no longer exists on disk. This happens when a PVC is reused
341
+ * across containers running as different users (e.g. root → uid 1000): the
342
+ * old installPath points to the previous HOME's `.claude/` directory, which
343
+ * doesn't exist in the new container. Claude Code's `plugin install` is
344
+ * idempotent — it exits 0 when a spec is already recorded even if the path is
345
+ * unreachable — so hooks fire against a missing binary and silently fail.
346
+ */
347
+ export function findStalePluginSpecs(
348
+ specs: readonly string[],
349
+ manifestPath: string,
350
+ ): string[] {
351
+ if (!existsSync(manifestPath)) return [];
352
+ let manifest: { plugins?: Record<string, Array<{ installPath?: string }>> };
353
+ try {
354
+ manifest = JSON.parse(
355
+ readFileSync(manifestPath, "utf8"),
356
+ ) as typeof manifest;
357
+ } catch {
358
+ return [];
359
+ }
360
+ return specs.filter((spec) => {
361
+ const entries = manifest.plugins?.[spec];
362
+ return (
363
+ entries?.length &&
364
+ entries.every((e) => !e.installPath || !existsSync(e.installPath))
365
+ );
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Installs the shipwright default plugins plus any agent-specific plugins
371
+ * from the config bundle. All commands are idempotent (exit 0 when already
372
+ * present / already at latest), so reboots are a silent no-op.
373
+ *
374
+ * Non-fatal — a missing `claude` binary or network blip logs and returns
375
+ * without throwing, so startup is never blocked.
376
+ *
377
+ * Install order: defaults first, then agent-specific plugins.
378
+ * Update order: same.
379
+ *
380
+ * Marketplace registration order:
381
+ * 1. Extra baked marketplaces (discovered from `extraMarketplacesRoot` or
382
+ * passed directly via `extraMarketplaceDirs`)
383
+ * 2. Built-in shipwright marketplace (`shipwrightRepoRoot`)
384
+ *
385
+ * @param extraMarketplaceDirs - Explicit list of extra marketplace dirs to
386
+ * register. When provided, `extraMarketplacesRoot` is ignored. Useful for
387
+ * tests that inject dirs directly without touching the filesystem.
388
+ * @param extraMarketplacesRoot - Convention root scanned by
389
+ * `discoverBakedMarketplaces`. Defaults to `BAKED_MARKETPLACES_ROOT`
390
+ * (/opt/shipwright/marketplaces). No-op when the path does not exist.
391
+ */
392
+ export async function installPlugins(
393
+ execFn: ExecFn = defaultExec,
394
+ cwd: string = process.cwd(),
395
+ agentPlugins: AgentPlugin[] = [],
396
+ shipwrightRepoRoot: string = SHIPWRIGHT_REPO_ROOT,
397
+ pluginManifestPath: string = join(
398
+ process.env.HOME ?? "/root",
399
+ ".claude",
400
+ "plugins",
401
+ "installed_plugins.json",
402
+ ),
403
+ extraMarketplaceDirs?: string[],
404
+ extraMarketplacesRoot: string = BAKED_MARKETPLACES_ROOT,
405
+ ): Promise<void> {
406
+ try {
407
+ // Determine which extra marketplace dirs to register.
408
+ // Explicit list (injected via tests) takes precedence over discovery.
409
+ const extraDirs =
410
+ extraMarketplaceDirs !== undefined
411
+ ? extraMarketplaceDirs
412
+ : discoverBakedMarketplaces(extraMarketplacesRoot);
413
+
414
+ // Register extra baked marketplaces BEFORE the built-in shipwright marketplace.
415
+ // Idempotent — safe to call on every startup.
416
+ for (const dir of extraDirs) {
417
+ const extraAdd = await execFn(
418
+ "claude",
419
+ ["plugin", "marketplace", "add", dir],
420
+ { cwd },
421
+ );
422
+ if (extraAdd.exitCode !== 0) {
423
+ console.warn(
424
+ `[agent] claude plugin marketplace add ${dir} exited ${extraAdd.exitCode}: ${extraAdd.stdout}`,
425
+ );
426
+ }
427
+ }
428
+
429
+ // Register the local (built-in) shipwright marketplace so `claude plugin install`
430
+ // can resolve plugins by name. Idempotent — safe to call on every startup.
431
+ const add = await execFn(
432
+ "claude",
433
+ ["plugin", "marketplace", "add", shipwrightRepoRoot],
434
+ { cwd },
435
+ );
436
+ if (add.exitCode !== 0) {
437
+ console.warn(
438
+ `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
439
+ );
440
+ }
441
+
442
+ const defaultPluginSpecs = DEFAULT_PLUGINS.map(
443
+ (name) => `${name}@${SHIPWRIGHT_MARKETPLACE}`,
444
+ );
445
+ const agentPluginSpecs = agentPlugins.map(
446
+ (p) => `${p.plugin}@${p.marketplace}`,
447
+ );
448
+ const allSpecs = [...defaultPluginSpecs, ...agentPluginSpecs];
449
+
450
+ // Uninstall any plugin whose recorded installPath no longer exists. This
451
+ // clears stale manifest entries left over from a different-HOME container
452
+ // so the subsequent `install` records the correct path for the current user.
453
+ const staleSpecs = findStalePluginSpecs(allSpecs, pluginManifestPath);
454
+ const failedUninstalls = new Set<string>();
455
+ for (const spec of staleSpecs) {
456
+ console.log(
457
+ `[agent] stale plugin path for ${spec} — uninstalling to force reinstall`,
458
+ );
459
+ const uninstall = await execFn("claude", ["plugin", "uninstall", spec], {
460
+ cwd,
461
+ });
462
+ if (uninstall.exitCode !== 0) {
463
+ console.warn(
464
+ `[agent] claude plugin uninstall ${spec} exited ${uninstall.exitCode}: ${uninstall.stdout}`,
465
+ );
466
+ failedUninstalls.add(spec);
467
+ }
468
+ }
469
+
470
+ // Install all plugins (defaults then agent-specific).
471
+ // Skip any stale spec whose uninstall failed — install is idempotent and
472
+ // would silently succeed without fixing the stale path, hiding the breakage.
473
+ for (const spec of allSpecs) {
474
+ if (failedUninstalls.has(spec)) continue;
475
+ const install = await execFn("claude", ["plugin", "install", spec], {
476
+ cwd,
477
+ });
478
+ if (install.exitCode !== 0) {
479
+ console.warn(
480
+ `[agent] claude plugin install ${spec} exited ${install.exitCode}: ${install.stdout}`,
481
+ );
482
+ }
483
+ }
484
+
485
+ // Update all plugins (defaults then agent-specific).
486
+ // Skip specs whose uninstall failed for the same reason as the install loop.
487
+ for (const spec of allSpecs) {
488
+ if (failedUninstalls.has(spec)) continue;
489
+ const update = await execFn("claude", ["plugin", "update", spec], {
490
+ cwd,
491
+ });
492
+ if (update.exitCode !== 0) {
493
+ console.warn(
494
+ `[agent] claude plugin update ${spec} exited ${update.exitCode}: ${update.stdout}`,
495
+ );
496
+ }
497
+ }
498
+ } catch (err) {
499
+ console.warn(
500
+ `[agent] plugin install failed (non-fatal): ${(err as Error).message}`,
501
+ );
502
+ }
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Main entry point
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Initializes $AGENT_HOME with the required directory structure, identity
511
+ * files, and workspace scaffolding.
512
+ *
513
+ * Safe to call on every startup:
514
+ * - Identity/skill files are seeded once with wx semantics (user edits preserved)
515
+ *
516
+ * Throws if required directories cannot be created.
517
+ */
518
+ /** Filesystem ops used by ensureDotClaudeSymlink — injectable for tests. */
519
+ interface DotClaudeFs {
520
+ mkdirSync: typeof mkdirSync;
521
+ lstatSync: typeof lstatSync;
522
+ unlinkSync: typeof unlinkSync;
523
+ rmSync: typeof rmSync;
524
+ symlinkSync: typeof symlinkSync;
525
+ }
526
+
527
+ const DEFAULT_DOT_CLAUDE_FS: DotClaudeFs = {
528
+ mkdirSync,
529
+ lstatSync,
530
+ unlinkSync,
531
+ rmSync,
532
+ symlinkSync,
533
+ };
534
+
535
+ /**
536
+ * Symlink `linkPath` (~/.claude) → `target` ($AGENT_HOME/dot-claude), ensuring
537
+ * the TARGET directory exists first. ALSO symlinks the sibling `~/.claude.json`
538
+ * → `$AGENT_HOME/claude.json` (see below).
539
+ *
540
+ * The target MUST be created before symlinking: on a fresh PVC the target does
541
+ * not exist, so without this the symlink dangles and every write under
542
+ * ~/.claude — Claude Code's `session-env`, plugin installs — fails (the Bash
543
+ * tool can't initialize, plugin installs exit 1).
544
+ *
545
+ * Existence is probed with `lstatSync`, not `existsSync`: `existsSync` follows
546
+ * the link and returns false for a DANGLING symlink, which would then make
547
+ * `symlinkSync` throw EEXIST on a re-run. `lstatSync` sees the link itself, so
548
+ * a stale/dangling symlink is detected and replaced. A real directory at
549
+ * `linkPath` is removed (logged) before relinking.
550
+ *
551
+ * `~/.claude.json` holds Claude Code's MCP server registrations (`claude mcp
552
+ * add`) and lives OUTSIDE `~/.claude/`, so it needs its own symlink to survive
553
+ * pod restarts. Without it, registered MCP servers (e.g. Linear) silently
554
+ * disappear — Claude reads the ephemeral image copy instead of the PVC's. The
555
+ * target is `$AGENT_HOME/claude.json` (sibling of the dot-claude dir), so
556
+ * existing MCP config from prior deployments is picked up. Unlike the dir, the
557
+ * target is NOT pre-created: it is a FILE that Claude writes on first use, so a
558
+ * dangling symlink is correct until then.
559
+ */
560
+ export function ensureDotClaudeSymlink(
561
+ target: string,
562
+ linkPath: string,
563
+ fs: DotClaudeFs = DEFAULT_DOT_CLAUDE_FS,
564
+ log: (msg: string) => void = console.log,
565
+ ): void {
566
+ // Create the symlink target first so the link never dangles.
567
+ fs.mkdirSync(target, { recursive: true });
568
+
569
+ let existing: ReturnType<typeof lstatSync> | null = null;
570
+ try {
571
+ existing = fs.lstatSync(linkPath);
572
+ } catch {
573
+ existing = null; // linkPath does not exist yet — nothing to remove
574
+ }
575
+
576
+ if (existing) {
577
+ if (existing.isSymbolicLink()) {
578
+ fs.unlinkSync(linkPath);
579
+ } else {
580
+ log(
581
+ "[entrypoint] ~/.claude is a real directory — removing before symlinking",
582
+ );
583
+ fs.rmSync(linkPath, { recursive: true });
584
+ }
585
+ }
586
+
587
+ fs.symlinkSync(target, linkPath);
588
+ log(`[entrypoint] symlinked ${linkPath} → ${target}`);
589
+
590
+ // ── ~/.claude.json (MCP server registrations) — file symlink, no mkdir ──────
591
+ const jsonTarget = join(target, "..", "claude.json");
592
+ const jsonLink = `${linkPath}.json`;
593
+ let existingJson: ReturnType<typeof lstatSync> | null = null;
594
+ try {
595
+ existingJson = fs.lstatSync(jsonLink);
596
+ } catch {
597
+ existingJson = null;
598
+ }
599
+ if (existingJson) {
600
+ if (existingJson.isSymbolicLink()) {
601
+ fs.unlinkSync(jsonLink);
602
+ } else {
603
+ // The image ships a real ~/.claude.json — remove it before symlinking so
604
+ // the PVC copy (with MCP registrations) is the one Claude reads.
605
+ fs.rmSync(jsonLink, { recursive: false });
606
+ }
607
+ }
608
+ fs.symlinkSync(jsonTarget, jsonLink);
609
+ log(`[entrypoint] symlinked ${jsonLink} → ${jsonTarget}`);
610
+ }
611
+
612
+ /**
613
+ * Strips the `@BOOTSTRAP.md` include line from workspace/CLAUDE.md so it
614
+ * doesn't dangle once the First-Run Ritual deletes BOOTSTRAP.md itself.
615
+ * No-op if the path or the include line doesn't exist.
616
+ */
617
+ function stripBootstrapInclude(claudeMdPath: string): void {
618
+ if (!existsSync(claudeMdPath)) return;
619
+ const content = readFileSync(claudeMdPath, "utf8");
620
+ const lines = content.split("\n");
621
+ const filtered = lines.filter((line) => !line.includes("@BOOTSTRAP.md"));
622
+ if (filtered.length === lines.length) return; // nothing to strip
623
+ writeFileSync(claudeMdPath, filtered.join("\n"), "utf8");
624
+ }
625
+
626
+ /** Writes `content` to `path` only when it differs from what is on disk. */
627
+ function writeIfChanged(path: string, content: string): void {
628
+ if (existsSync(path) && readFileSync(path, "utf8") === content) return;
629
+ writeFileSync(path, content, "utf8");
630
+ }
631
+
632
+ export function ensureAgentHome(
633
+ home: string,
634
+ // ARD-6.1: the entrypoint exports the agent's type (from the config
635
+ // response) as SHIPWRIGHT_AGENT_TYPE before spawning this process.
636
+ typeName: string | undefined = process.env.SHIPWRIGHT_AGENT_TYPE,
637
+ templatesDir: string = resolveTemplatesDir(typeName),
638
+ ): void {
639
+ const workspaceDir = join(home, "workspace");
640
+ const stateDir = join(workspaceDir, "state");
641
+ const reposDir = join(workspaceDir, "repos");
642
+ const worktreesDir = join(workspaceDir, "worktrees");
643
+
644
+ const claudeDir = join(workspaceDir, ".claude");
645
+ const skillsDir = join(claudeDir, "skills");
646
+ const commandsDir = join(claudeDir, "commands");
647
+
648
+ const miseDir = join(home, "mise");
649
+
650
+ // Create required directories (throws on permission error)
651
+ for (const dir of [
652
+ workspaceDir,
653
+ claudeDir,
654
+ skillsDir,
655
+ commandsDir,
656
+ stateDir,
657
+ reposDir,
658
+ worktreesDir,
659
+ miseDir,
660
+ ]) {
661
+ mkdirSync(dir, { recursive: true });
662
+ }
663
+
664
+ // Check new workspace status BEFORE seeding identity files (markers include SOUL.md)
665
+ const newWorkspace = isNewWorkspace(home);
666
+
667
+ // Seed identity files into workspace/ (Claude's cwd — where CLAUDE.md is auto-loaded)
668
+ seedFile(
669
+ join(workspaceDir, "CLAUDE.md"),
670
+ readTemplate("CLAUDE.md.template", templatesDir),
671
+ );
672
+
673
+ // Layered files (ARD-6.2), rewritten on boot unlike the seeded-once files:
674
+ // RUNTIME.md is runtime-owned and identical for every agent; SPECIALTY.md
675
+ // comes from the specialty's template dir (empty when it provides none, so
676
+ // the CLAUDE.md include never dangles). Unused until a CLAUDE.md includes them.
677
+ writeIfChanged(
678
+ join(workspaceDir, "RUNTIME.md"),
679
+ readTemplate("RUNTIME.md.template"),
680
+ );
681
+ writeIfChanged(
682
+ join(workspaceDir, "SPECIALTY.md"),
683
+ existsSync(join(templatesDir, "SPECIALTY.md.template"))
684
+ ? readTemplate("SPECIALTY.md.template", templatesDir)
685
+ : "",
686
+ );
687
+
688
+ // Seed VOICE.md once (wx semantics — user edits preserved)
689
+ seedFile(
690
+ join(workspaceDir, "VOICE.md"),
691
+ readOptionalTemplate("VOICE.md.template", templatesDir),
692
+ );
693
+
694
+ seedFile(
695
+ join(workspaceDir, "SOUL.md"),
696
+ readTemplate("SOUL.md.template", templatesDir),
697
+ );
698
+ seedFile(
699
+ join(workspaceDir, "IDENTITY.md"),
700
+ readTemplate("IDENTITY.md.template", templatesDir),
701
+ );
702
+
703
+ // Seed workspace/mise.toml — user-editable tool declarations; seeded once (wx)
704
+ seedFile(
705
+ join(workspaceDir, "mise.toml"),
706
+ readOptionalTemplate("mise.toml.template", templatesDir),
707
+ );
708
+
709
+ // Seed workspace/requirements.txt — Python package dependencies; seeded once (wx)
710
+ seedFile(
711
+ join(workspaceDir, "requirements.txt"),
712
+ readOptionalTemplate("requirements.txt.template", templatesDir),
713
+ );
714
+
715
+ // Create state/reviews/ directory for review artifacts (PR_REVIEW files)
716
+ mkdirSync(join(stateDir, "reviews"), { recursive: true });
717
+
718
+ // Bootstrap seeding for new workspaces
719
+ const state = loadState(home);
720
+
721
+ if (newWorkspace && !state.bootstrapSeededAt) {
722
+ seedFile(
723
+ join(workspaceDir, "BOOTSTRAP.md"),
724
+ readOptionalTemplate("BOOTSTRAP.md.template", templatesDir),
725
+ );
726
+ state.bootstrapSeededAt = new Date().toISOString();
727
+ saveState(home, state);
728
+ } else if (
729
+ state.bootstrapSeededAt &&
730
+ !existsSync(join(workspaceDir, "BOOTSTRAP.md")) &&
731
+ !state.setupCompletedAt
732
+ ) {
733
+ // First startup after the ritual deletes BOOTSTRAP.md — clean up the
734
+ // now-dangling include before setupCompletedAt gates this branch off.
735
+ stripBootstrapInclude(join(workspaceDir, "CLAUDE.md"));
736
+ state.setupCompletedAt = new Date().toISOString();
737
+ saveState(home, state);
738
+ }
739
+ }
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // Mise startup
743
+ // ---------------------------------------------------------------------------
744
+
745
+ /**
746
+ * Configures mise for the agent workspace:
747
+ * - Sets MISE_DATA_DIR so installs land on the PVC
748
+ * - Sets MISE_CACHE_DIR + XDG_CACHE_HOME so download tarballs and tool caches
749
+ * land on the PVC instead of ephemeral storage
750
+ * - Sets GOPATH/GOMODCACHE + npm/bun cache dirs, which ignore XDG, for the same
751
+ * reason — an unpinned Go module cache alone can exceed the pod's
752
+ * ephemeral-storage limit and get it evicted mid-run
753
+ * - Runs `mise install` when workspace/mise.toml is present (idempotent)
754
+ * - Prepends mise shim paths to process.env.PATH so subprocesses find tools
755
+ *
756
+ * Non-fatal: mise failures are logged but do not abort startup.
757
+ */
758
+ export async function runMiseStartup(
759
+ home: string,
760
+ execFn: ExecFn = defaultExec,
761
+ ): Promise<void> {
762
+ const workspaceDir = join(home, "workspace");
763
+
764
+ // Pin mise + XDG cache/data dirs to the PVC before any mise calls so installs
765
+ // and download tarballs land on persistent storage, not ephemeral. node and
766
+ // claude are system-wide binaries (installed in the image, on /usr/bin), NOT
767
+ // mise tools — so repointing MISE_DATA_DIR at the PVC does NOT affect them.
768
+ // MISE_DATA_DIR holds only workspace-declared runtime tools.
769
+ //
770
+ // (Do NOT seed/copy the image mise dir onto the PVC here — that hack existed
771
+ // when node+claude were mise-managed; on a reused PVC the copy was skipped and
772
+ // claude resolved to a shim with no install: "claude is not a valid shim".)
773
+ process.env.MISE_DATA_DIR = join(home, "mise");
774
+ process.env.MISE_CACHE_DIR = join(home, "mise", "cache");
775
+ process.env.XDG_CACHE_HOME = join(home, "cache");
776
+ mkdirSync(process.env.MISE_CACHE_DIR, { recursive: true });
777
+ mkdirSync(process.env.XDG_CACHE_HOME, { recursive: true });
778
+
779
+ // XDG_CACHE_HOME covers most tooling, but three package managers ignore XDG
780
+ // and default to fixed paths under $HOME — which is the container's ephemeral
781
+ // overlay, not the PVC. Left unpinned they are charged against the pod's
782
+ // ephemeral-storage limit and evict it mid-run ("Pod ephemeral local storage
783
+ // usage exceeds the total limit of containers"). Go is the worst offender:
784
+ // GOMODCACHE defaults to $GOPATH/pkg/mod (NOT $XDG_CACHE_HOME), and a single
785
+ // `go mod download` on a Terratest module fetches ~1.7GiB. Note GOCACHE (the
786
+ // build cache) already follows XDG_CACHE_HOME, so only the module cache leaks.
787
+ //
788
+ // Pinning these to the PVC also means the caches survive a pod restart
789
+ // instead of being re-downloaded from scratch on every boot.
790
+ //
791
+ // CARGO_HOME is deliberately NOT pinned here: mise manages the Rust toolchain
792
+ // for workspaces that declare it and sets CARGO_HOME itself, so pre-setting it
793
+ // would fight the toolchain install.
794
+ process.env.GOPATH = join(home, "go");
795
+ process.env.GOMODCACHE = join(home, "go", "pkg", "mod");
796
+ process.env.npm_config_cache = join(home, "cache", "npm");
797
+ process.env.BUN_INSTALL_CACHE_DIR = join(home, "cache", "bun");
798
+ mkdirSync(process.env.GOMODCACHE, { recursive: true });
799
+ mkdirSync(process.env.npm_config_cache, { recursive: true });
800
+ mkdirSync(process.env.BUN_INSTALL_CACHE_DIR, { recursive: true });
801
+
802
+ // Activate mise in any bash session the agent spawns (e.g. Claude Code's Bash
803
+ // tool). The shims-on-PATH prepend below only reaches direct subprocesses;
804
+ // login/interactive shells re-source profile files that reset PATH, so
805
+ // workspace-declared tools (e.g. fern's Rust toolchain) would be missing
806
+ // without this. Unconditional (before the mise.toml check) so it's ready for
807
+ // any workspace tools; idempotent so repeated boots don't stack duplicate lines.
808
+ const bashrc = join(process.env.HOME ?? home, ".bashrc");
809
+ if (
810
+ !existsSync(bashrc) ||
811
+ !readFileSync(bashrc, "utf8").includes("mise activate bash")
812
+ ) {
813
+ appendFileSync(bashrc, '\neval "$(mise activate bash)"\n');
814
+ console.log(`[agent] mise activation appended to ${bashrc}`);
815
+ }
816
+
817
+ // Skip mise install if no mise.toml declared
818
+ const miseTomlPath = join(workspaceDir, "mise.toml");
819
+ if (!existsSync(miseTomlPath)) {
820
+ return;
821
+ }
822
+
823
+ // Trust the workspace config so mise doesn't prompt interactively
824
+ try {
825
+ await execFn("mise", ["trust", miseTomlPath], { cwd: workspaceDir });
826
+ } catch (err) {
827
+ console.warn(
828
+ `[agent] mise trust failed — tools may be unavailable: ${(err as Error).message}`,
829
+ );
830
+ return;
831
+ }
832
+
833
+ // Run mise install (idempotent — no-op when tools already cached)
834
+ let install: { stdout: string; exitCode: number };
835
+ try {
836
+ install = await execFn("mise", ["install"], { cwd: workspaceDir });
837
+ } catch (err) {
838
+ console.warn(
839
+ `[agent] mise install failed — tools may be unavailable: ${(err as Error).message}`,
840
+ );
841
+ return;
842
+ }
843
+
844
+ if (install.exitCode !== 0) {
845
+ console.warn("[agent] mise install failed — tools may be unavailable");
846
+ return;
847
+ }
848
+
849
+ // Prepend MISE_DATA_DIR/shims to PATH so workspace-declared tools are found
850
+ // in non-interactive bash sessions.
851
+ const shimsDir = join(process.env.MISE_DATA_DIR, "shims");
852
+ process.env.PATH = [shimsDir, process.env.PATH ?? ""].join(":");
853
+ }