@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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
}
|