@agent-compose/sdk 0.8.2 → 0.8.4

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 (43) hide show
  1. package/dist/agent/__tests__/perf-sampler.test.d.ts +10 -0
  2. package/dist/agent/agent-context.d.ts +1 -1
  3. package/dist/agent/agent-loop.d.ts +9 -1
  4. package/dist/agent/desktop-open.d.ts +184 -0
  5. package/dist/agent/perf-sampler.d.ts +99 -0
  6. package/dist/agent/services-manifest.d.ts +88 -0
  7. package/dist/agent/services-restore.d.ts +58 -0
  8. package/dist/client.d.ts +164 -8
  9. package/dist/display.d.ts +17 -0
  10. package/dist/index.d.ts +14 -5
  11. package/dist/index.js +1393 -53
  12. package/dist/runtimes/_cli-agent.d.ts +359 -2
  13. package/dist/runtimes/claude-code.d.ts +12 -0
  14. package/dist/runtimes/codex.d.ts +8 -0
  15. package/dist/runtimes/openai-desktop.js +1329 -53
  16. package/dist/runtimes/session-env.test.d.ts +14 -0
  17. package/dist/sandbox.d.ts +1 -1
  18. package/dist/types/api-conversations.d.ts +309 -1
  19. package/dist/types/api-factory.d.ts +115 -10
  20. package/dist/types/api-runs.d.ts +21 -0
  21. package/dist/types/protocol.d.ts +44 -1
  22. package/dist/types/runtime.d.ts +120 -0
  23. package/package.json +1 -1
  24. package/src/agent/agent-context.ts +100 -11
  25. package/src/agent/agent-loop.ts +15 -3
  26. package/src/agent/desktop-open.ts +418 -0
  27. package/src/agent/perf-sampler.ts +202 -0
  28. package/src/agent/services-manifest.ts +356 -0
  29. package/src/agent/services-restore.ts +195 -0
  30. package/src/client.ts +328 -12
  31. package/src/display.ts +44 -1
  32. package/src/index.ts +65 -2
  33. package/src/runtimes/_cli-agent.ts +911 -35
  34. package/src/runtimes/claude-code.ts +198 -14
  35. package/src/runtimes/codex.ts +58 -1
  36. package/src/sandbox/providers/e2b.ts +29 -1
  37. package/src/sandbox/providers/local.ts +16 -4
  38. package/src/sandbox.ts +1 -1
  39. package/src/types/api-conversations.ts +307 -3
  40. package/src/types/api-factory.ts +118 -10
  41. package/src/types/api-runs.ts +23 -0
  42. package/src/types/protocol.ts +44 -1
  43. package/src/types/runtime.ts +122 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * The durable services manifest — `.ac/services.yml` on the session's drive
3
+ * branch.
4
+ *
5
+ * Sessions run on cattle machines: a VM recycle (resize, eviction, failed
6
+ * reconnect) discards every process and every byte off the drive. The manifest
7
+ * is the pet — a small, versioned description of the long-running services a
8
+ * session depends on (dev servers, docker compose stacks, built dashboards) so
9
+ * the platform can rebuild the stack on a fresh machine without being asked.
10
+ *
11
+ * This module is pure string/data work: a parser for a deliberately RESTRICTED
12
+ * YAML subset (no yaml dependency — the schema is small and fixed, and a
13
+ * hand-rolled parser matches house style, cf. parseRecorderStatus /
14
+ * parsePerfToken), a serializer the CLI uses for `agentc services add/remove`,
15
+ * and the shared types. Script generation lives in ./services-restore.ts.
16
+ *
17
+ * Supported YAML subset (anything else is a loud parse error, never a guess):
18
+ *
19
+ * version: 1
20
+ * services:
21
+ * - name: postgres
22
+ * command: docker compose up postgres # required, foreground shell
23
+ * cwd: myapp # optional, relative to drive root
24
+ * port: 5432 # optional, informational + docs
25
+ * env: reads DATABASE_URL from .env # optional free-text note
26
+ * setup: docker compose pull postgres # optional one-time prep per machine
27
+ * health: # optional, at most one of:
28
+ * cmd: pg_isready -h localhost # shell probe (exit 0 = healthy)
29
+ * http: http://localhost:5432/ # or an HTTP 2xx probe
30
+ * data: # optional data hooks
31
+ * dump: pg_dump app > .ac/seeds/dev.sql # before a DELIBERATE recycle
32
+ * restore: psql app < .ac/seeds/dev.sql # after setup on a fresh boot
33
+ *
34
+ * Scalars only, one nesting level (`health:` / `data:`), full-line `#` comments
35
+ * only (an inline ` # ...` would be ambiguous inside shell commands and is kept
36
+ * as part of the value), no multi-line block scalars (`|` / `>`), no anchors,
37
+ * no flow collections.
38
+ */
39
+
40
+ export const SERVICES_MANIFEST_RELPATH = ".ac/services.yml";
41
+
42
+ /** Hard cap on manifest entries — a manifest is a stack, not a fleet. */
43
+ export const SERVICES_MAX = 16;
44
+
45
+ export const SERVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/;
46
+
47
+ export interface ServiceHealth {
48
+ /** Shell probe; exit 0 means healthy. Mutually exclusive with `http`. */
49
+ cmd?: string;
50
+ /** HTTP probe; any 2xx means healthy. Mutually exclusive with `cmd`. */
51
+ http?: string;
52
+ }
53
+
54
+ export interface ServiceDataHooks {
55
+ /**
56
+ * Run before a DELIBERATE recycle (resize). Evictions and dead-VM
57
+ * fresh-acquires cannot run it — the machine is already gone — so dumps are
58
+ * a courtesy, not a guarantee; durable data belongs on the drive.
59
+ */
60
+ dump?: string;
61
+ /** Run after `setup` on a fresh boot, before the service launches. */
62
+ restore?: string;
63
+ }
64
+
65
+ export interface ServiceEntry {
66
+ name: string;
67
+ /** Foreground shell command; launched detached with durable log + pidfile. */
68
+ command: string;
69
+ /** Working directory, relative to the drive root (absolute rejected). */
70
+ cwd?: string;
71
+ /** Informational — surfaced in `agentc services list` and the restore line. */
72
+ port?: number;
73
+ /** Free-text note about env the service expects (documentation only). */
74
+ env?: string;
75
+ /** One-time per-machine prep (e.g. `docker compose up -d`). */
76
+ setup?: string;
77
+ health?: ServiceHealth;
78
+ data?: ServiceDataHooks;
79
+ }
80
+
81
+ export interface ServicesManifest {
82
+ version: 1;
83
+ services: ServiceEntry[];
84
+ }
85
+
86
+ export interface ParseServicesManifestResult {
87
+ /** Present iff `errors` is empty. All-or-nothing: a broken manifest restores nothing, loudly. */
88
+ manifest: ServicesManifest | null;
89
+ errors: string[];
90
+ }
91
+
92
+ const TOP_KEYS = new Set(["version", "services"]);
93
+ const ENTRY_KEYS = new Set(["name", "command", "cwd", "port", "env", "setup", "health", "data"]);
94
+ const HEALTH_KEYS = new Set(["cmd", "http"]);
95
+ const DATA_KEYS = new Set(["dump", "restore"]);
96
+
97
+ interface Line {
98
+ no: number;
99
+ indent: number;
100
+ /** True when the content starts with `- ` (a sequence item). */
101
+ dash: boolean;
102
+ /** Content with indentation (and any leading `- `) stripped. */
103
+ text: string;
104
+ }
105
+
106
+ function lex(text: string): Line[] {
107
+ const out: Line[] = [];
108
+ const raw = text.split(/\r?\n/);
109
+ for (let i = 0; i < raw.length; i++) {
110
+ const line = raw[i];
111
+ const trimmed = line.trim();
112
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
113
+ const indent = line.length - line.trimStart().length;
114
+ const dash = trimmed.startsWith("- ") || trimmed === "-";
115
+ out.push({
116
+ no: i + 1,
117
+ indent,
118
+ dash,
119
+ text: dash ? trimmed.slice(1).trim() : trimmed,
120
+ });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Split `key: value` (value may be empty for map openers). Null when the line is not a mapping. */
126
+ function splitKey(text: string): { key: string; value: string } | null {
127
+ if (text.endsWith(":") && !text.includes(": ")) {
128
+ const key = text.slice(0, -1).trim();
129
+ return /^[A-Za-z_][\w-]*$/.test(key) ? { key, value: "" } : null;
130
+ }
131
+ const idx = text.indexOf(": ");
132
+ if (idx === -1) return null;
133
+ const key = text.slice(0, idx).trim();
134
+ if (!/^[A-Za-z_][\w-]*$/.test(key)) return null;
135
+ return { key, value: text.slice(idx + 2).trim() };
136
+ }
137
+
138
+ /** Unquote a scalar. Only full single/double quoting is recognized. */
139
+ function scalar(value: string, no: number, errors: string[]): string {
140
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
141
+ return value.slice(1, -1).replace(/\\(["\\])/g, "$1");
142
+ }
143
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
144
+ return value.slice(1, -1).replace(/''/g, "'");
145
+ }
146
+ if (value === "|" || value === ">" || value.startsWith("| ") || value.startsWith("> ")) {
147
+ errors.push(`line ${no}: block scalars (| / >) are not supported — keep commands single-line`);
148
+ return "";
149
+ }
150
+ return value;
151
+ }
152
+
153
+ export function parseServicesManifest(text: string): ParseServicesManifestResult {
154
+ const errors: string[] = [];
155
+ if (text.length > 64 * 1024) {
156
+ return { manifest: null, errors: ["manifest exceeds 64KB — this file is a service list, not a data store"] };
157
+ }
158
+ const lines = lex(text);
159
+ if (lines.length === 0) return { manifest: null, errors: ["manifest is empty"] };
160
+
161
+ let version: number | null = null;
162
+ const services: ServiceEntry[] = [];
163
+ let current: ServiceEntry | null = null;
164
+ let currentIndent = -1;
165
+ /** "health" | "data" while inside a nested map, else null. */
166
+ let nested: "health" | "data" | null = null;
167
+ let nestedIndent = -1;
168
+ let inServices = false;
169
+
170
+ const finish = (entry: ServiceEntry | null) => {
171
+ if (!entry) return;
172
+ services.push(entry);
173
+ };
174
+
175
+ for (const line of lines) {
176
+ const kv = splitKey(line.text);
177
+ if (!kv) {
178
+ errors.push(`line ${line.no}: not a \`key: value\` mapping — flow collections and bare scalars are not supported`);
179
+ continue;
180
+ }
181
+
182
+ // Top-level keys.
183
+ if (line.indent === 0 && !line.dash) {
184
+ inServices = false;
185
+ nested = null;
186
+ finish(current);
187
+ current = null;
188
+ if (!TOP_KEYS.has(kv.key)) {
189
+ errors.push(`line ${line.no}: unknown top-level key \`${kv.key}\` (allowed: version, services)`);
190
+ continue;
191
+ }
192
+ if (kv.key === "version") {
193
+ const v = Number(scalar(kv.value, line.no, errors));
194
+ if (v !== 1) errors.push(`line ${line.no}: unsupported manifest version \`${kv.value}\` (this build understands version 1)`);
195
+ else version = 1;
196
+ } else {
197
+ if (kv.value !== "") errors.push(`line ${line.no}: \`services:\` must open a list, not carry a value`);
198
+ inServices = true;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (!inServices) {
204
+ errors.push(`line ${line.no}: unexpected indented line outside \`services:\``);
205
+ continue;
206
+ }
207
+
208
+ // New sequence item.
209
+ if (line.dash) {
210
+ finish(current);
211
+ current = { name: "", command: "" };
212
+ currentIndent = line.indent;
213
+ nested = null;
214
+ if (kv.key !== "name") {
215
+ errors.push(`line ${line.no}: each service entry must start with \`- name: <name>\``);
216
+ continue;
217
+ }
218
+ current.name = scalar(kv.value, line.no, errors);
219
+ continue;
220
+ }
221
+
222
+ if (!current) {
223
+ errors.push(`line ${line.no}: expected \`- name: <name>\` to open a service entry`);
224
+ continue;
225
+ }
226
+
227
+ // Nested map keys (health/data), recognized by deeper indentation.
228
+ if (nested && line.indent > nestedIndent) {
229
+ const allowed = nested === "health" ? HEALTH_KEYS : DATA_KEYS;
230
+ if (!allowed.has(kv.key)) {
231
+ errors.push(`line ${line.no}: unknown \`${nested}\` key \`${kv.key}\` (allowed: ${[...allowed].join(", ")})`);
232
+ continue;
233
+ }
234
+ const value = scalar(kv.value, line.no, errors);
235
+ if (value === "") {
236
+ errors.push(`line ${line.no}: \`${nested}.${kv.key}\` needs a value`);
237
+ continue;
238
+ }
239
+ const bucket = (current[nested] ??= {});
240
+ (bucket as Record<string, string>)[kv.key] = value;
241
+ continue;
242
+ }
243
+ nested = null;
244
+
245
+ if (line.indent <= currentIndent) {
246
+ errors.push(`line ${line.no}: bad indentation — service fields must be indented under their \`- name:\` line`);
247
+ continue;
248
+ }
249
+
250
+ if (!ENTRY_KEYS.has(kv.key)) {
251
+ errors.push(`line ${line.no}: unknown service key \`${kv.key}\` (allowed: ${[...ENTRY_KEYS].join(", ")})`);
252
+ continue;
253
+ }
254
+
255
+ if (kv.key === "health" || kv.key === "data") {
256
+ if (kv.value !== "") {
257
+ errors.push(`line ${line.no}: \`${kv.key}:\` opens a nested map — put cmd/http (or dump/restore) on indented lines below`);
258
+ continue;
259
+ }
260
+ nested = kv.key;
261
+ nestedIndent = line.indent;
262
+ continue;
263
+ }
264
+
265
+ const value = scalar(kv.value, line.no, errors);
266
+ if (kv.key === "port") {
267
+ const port = Number(value);
268
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
269
+ errors.push(`line ${line.no}: port must be an integer 1-65535, got \`${kv.value}\``);
270
+ } else {
271
+ current.port = port;
272
+ }
273
+ continue;
274
+ }
275
+ if (value === "") {
276
+ errors.push(`line ${line.no}: \`${kv.key}\` needs a value`);
277
+ continue;
278
+ }
279
+ if (kv.key === "cwd" && (value.startsWith("/") || value.includes(".."))) {
280
+ errors.push(`line ${line.no}: cwd must be a relative path under the drive root (no leading / and no ..)`);
281
+ continue;
282
+ }
283
+ (current as unknown as Record<string, string>)[kv.key] = value;
284
+ }
285
+ finish(current);
286
+
287
+ // Semantic validation.
288
+ if (version === null) errors.push("missing `version: 1`");
289
+ if (services.length === 0 && errors.length === 0) errors.push("`services:` lists no entries");
290
+ if (services.length > SERVICES_MAX) errors.push(`too many services (${services.length}) — the cap is ${SERVICES_MAX}`);
291
+ const seen = new Set<string>();
292
+ for (const svc of services) {
293
+ const label = svc.name || "<unnamed>";
294
+ if (!SERVICE_NAME_RE.test(svc.name)) {
295
+ errors.push(`service \`${label}\`: name must match ${SERVICE_NAME_RE} (alphanumeric start, then [A-Za-z0-9._-], max 32)`);
296
+ } else if (seen.has(svc.name)) {
297
+ errors.push(`service \`${label}\`: duplicate name`);
298
+ }
299
+ seen.add(svc.name);
300
+ if (!svc.command) errors.push(`service \`${label}\`: missing \`command\``);
301
+ if (svc.health?.cmd && svc.health.http) {
302
+ errors.push(`service \`${label}\`: health takes \`cmd\` OR \`http\`, not both`);
303
+ }
304
+ if (svc.health?.http && !/^https?:\/\//.test(svc.health.http)) {
305
+ errors.push(`service \`${label}\`: health.http must be an http(s):// URL`);
306
+ }
307
+ }
308
+
309
+ return errors.length > 0 ? { manifest: null, errors } : { manifest: { version: 1, services }, errors: [] };
310
+ }
311
+
312
+ /** Quote a scalar for emission iff the parser would otherwise mangle it. */
313
+ function emitScalar(value: string): string {
314
+ const needsQuote =
315
+ value !== value.trim() ||
316
+ value.startsWith("'") ||
317
+ value.startsWith('"') ||
318
+ value === "|" ||
319
+ value === ">" ||
320
+ value.startsWith("| ") ||
321
+ value.startsWith("> ");
322
+ if (!needsQuote) return value;
323
+ return `"${value.replace(/([\\"])/g, "\\$1")}"`;
324
+ }
325
+
326
+ /**
327
+ * Serialize a manifest in the canonical shape the parser reads back.
328
+ * Values containing newlines are a caller bug (the CLI rejects them first).
329
+ */
330
+ export function renderServicesManifest(manifest: ServicesManifest): string {
331
+ const out: string[] = [
332
+ "# Durable services manifest — restored automatically after a machine recycle.",
333
+ "# Docs: agentc services --help",
334
+ "version: 1",
335
+ "services:",
336
+ ];
337
+ for (const svc of manifest.services) {
338
+ out.push(` - name: ${emitScalar(svc.name)}`);
339
+ out.push(` command: ${emitScalar(svc.command)}`);
340
+ if (svc.cwd) out.push(` cwd: ${emitScalar(svc.cwd)}`);
341
+ if (svc.port !== undefined) out.push(` port: ${svc.port}`);
342
+ if (svc.env) out.push(` env: ${emitScalar(svc.env)}`);
343
+ if (svc.setup) out.push(` setup: ${emitScalar(svc.setup)}`);
344
+ if (svc.health && (svc.health.cmd || svc.health.http)) {
345
+ out.push(" health:");
346
+ if (svc.health.cmd) out.push(` cmd: ${emitScalar(svc.health.cmd)}`);
347
+ if (svc.health.http) out.push(` http: ${emitScalar(svc.health.http)}`);
348
+ }
349
+ if (svc.data && (svc.data.dump || svc.data.restore)) {
350
+ out.push(" data:");
351
+ if (svc.data.dump) out.push(` dump: ${emitScalar(svc.data.dump)}`);
352
+ if (svc.data.restore) out.push(` restore: ${emitScalar(svc.data.restore)}`);
353
+ }
354
+ }
355
+ return out.join("\n") + "\n";
356
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Restore-script generation for the durable services manifest.
3
+ *
4
+ * Pure string composition — nothing here touches a sandbox (the desktop-open
5
+ * posture). The server's fresh-boot ensure and `agentc services restore` both
6
+ * feed a parsed manifest through these generators, so the launch semantics are
7
+ * one implementation:
8
+ *
9
+ * - every service is launched DETACHED (`setsid sh -c … </dev/null &`) with a
10
+ * durable log and pidfile under /tmp/ac-services/ — the same contract the
11
+ * background-work doctrine teaches, so a restored service freezes while the
12
+ * machine parks and resumes on wake exactly like an agent-launched job;
13
+ * - `setup` and `data.restore` run first, sequentially, in the service's cwd;
14
+ * - health is gated with a bounded poll, never an unbounded wait;
15
+ * - every outcome is reported as a machine-readable status line
16
+ * (`##ac-service <name> <ok|unchecked|failed|dumped|dump-failed> [detail]`)
17
+ * — per-service honesty, never a fatal abort of the whole restore.
18
+ *
19
+ * The generated script is POSIX sh, installed via base64 + `sh -n` (the
20
+ * desktop-open install idiom) so a syntax regression fails loudly at install
21
+ * time, not silently at boot.
22
+ */
23
+
24
+ import type { ServiceEntry, ServicesManifest } from "./services-manifest.js";
25
+
26
+ /** Durable per-service logs + pidfiles live here (machine-local, recreated each boot). */
27
+ export const SERVICES_STATE_DIR = "/tmp/ac-services";
28
+
29
+ /** Marker prefix for machine-readable per-service outcome lines. */
30
+ export const SERVICE_STATUS_PREFIX = "##ac-service";
31
+
32
+ /** Bound on each service's health poll (1s beats). */
33
+ export const SERVICE_HEALTH_TIMEOUT_S = 45;
34
+
35
+ export type ServiceRestoreStatus = "ok" | "unchecked" | "failed" | "dumped" | "dump-failed";
36
+
37
+ export interface ServiceRestoreOutcome {
38
+ name: string;
39
+ status: ServiceRestoreStatus;
40
+ detail?: string;
41
+ }
42
+
43
+ /** POSIX single-quote escaping: ' → '\'' */
44
+ function shq(s: string): string {
45
+ return `'${s.replace(/'/g, `'\\''`)}'`;
46
+ }
47
+
48
+ /** `sh -c <cmd>` fragment with the command safely single-quoted. */
49
+ function shc(cmd: string): string {
50
+ return `sh -c ${shq(cmd)}`;
51
+ }
52
+
53
+ function serviceCwd(driveRoot: string, svc: ServiceEntry): string {
54
+ return svc.cwd ? `${driveRoot.replace(/\/+$/, "")}/${svc.cwd}` : driveRoot;
55
+ }
56
+
57
+ function restoreOneService(driveRoot: string, svc: ServiceEntry): string[] {
58
+ const n = svc.name;
59
+ const cwd = serviceCwd(driveRoot, svc);
60
+ const lines: string[] = [
61
+ ``,
62
+ `# --- ${n} ---`,
63
+ `(`,
64
+ ` cd ${shq(cwd)} || { status ${shq(n)} failed cwd-missing; exit 0; }`,
65
+ ];
66
+ if (svc.setup) {
67
+ lines.push(
68
+ ` if ! ${shc(svc.setup)} >> "$D/${n}.setup.log" 2>&1; then`,
69
+ ` status ${shq(n)} failed setup; exit 0`,
70
+ ` fi`,
71
+ );
72
+ }
73
+ if (svc.data?.restore) {
74
+ lines.push(
75
+ ` if ! ${shc(svc.data.restore)} >> "$D/${n}.setup.log" 2>&1; then`,
76
+ ` status ${shq(n)} failed data-restore; exit 0`,
77
+ ` fi`,
78
+ );
79
+ }
80
+ lines.push(
81
+ ` $SETSID ${shc(svc.command)} >> "$D/${n}.log" 2>&1 < /dev/null &`,
82
+ ` echo $! > "$D/${n}.pid"`,
83
+ );
84
+ const health = svc.health?.cmd
85
+ ? shc(svc.health.cmd)
86
+ : svc.health?.http
87
+ ? `curl -fsS -m 2 -o /dev/null ${shq(svc.health.http)}`
88
+ : null;
89
+ if (health) {
90
+ lines.push(
91
+ ` i=0`,
92
+ ` while [ "$i" -lt ${SERVICE_HEALTH_TIMEOUT_S} ]; do`,
93
+ ` if ${health} >/dev/null 2>&1; then status ${shq(n)} ok ''; exit 0; fi`,
94
+ ` kill -0 "$(cat "$D/${n}.pid")" 2>/dev/null || { status ${shq(n)} failed exited; exit 0; }`,
95
+ ` i=$((i+1)); sleep 1`,
96
+ ` done`,
97
+ ` status ${shq(n)} failed health-timeout`,
98
+ );
99
+ } else {
100
+ lines.push(
101
+ ` sleep 1`,
102
+ ` if kill -0 "$(cat "$D/${n}.pid")" 2>/dev/null; then status ${shq(n)} unchecked ''; else status ${shq(n)} failed exited; fi`,
103
+ );
104
+ }
105
+ lines.push(`)`);
106
+ return lines;
107
+ }
108
+
109
+ /**
110
+ * The auto-restore script: sequential, per-service honest, never exits nonzero
111
+ * because one service failed (the caller reads status lines, not the exit code).
112
+ *
113
+ * @param driveRoot absolute session working dir (the drive root, e.g. /factory/files)
114
+ */
115
+ export function servicesRestoreScript(manifest: ServicesManifest, driveRoot: string): string {
116
+ const head = [
117
+ `#!/bin/sh`,
118
+ `# generated by agent-compose — restores services from ${driveRoot}/.ac/services.yml`,
119
+ `# logs + pidfiles: ${SERVICES_STATE_DIR}/<name>.{log,pid}`,
120
+ `D=${shq(SERVICES_STATE_DIR)}`,
121
+ `mkdir -p "$D"`,
122
+ `status() { printf '%s %s %s %s\\n' ${shq(SERVICE_STATUS_PREFIX)} "$1" "$2" "$3"; }`,
123
+ `# setsid detaches from the boot shell's session (Linux guests); on hosts`,
124
+ `# without it (macOS running \`agentc services restore\`) plain & still works.`,
125
+ `SETSID=""`,
126
+ `command -v setsid >/dev/null 2>&1 && SETSID=setsid`,
127
+ ];
128
+ const body = manifest.services.flatMap((svc) => restoreOneService(driveRoot, svc));
129
+ return [...head, ...body, ``].join("\n");
130
+ }
131
+
132
+ /**
133
+ * The pre-recycle dump script: runs each service's `data.dump` hook in its cwd,
134
+ * bounded by the caller's command timeout. Only DELIBERATE recycles (resize)
135
+ * get to run this — an evicted or dead machine cannot.
136
+ */
137
+ export function servicesDumpScript(manifest: ServicesManifest, driveRoot: string): string | null {
138
+ const withDump = manifest.services.filter((svc) => svc.data?.dump);
139
+ if (withDump.length === 0) return null;
140
+ const lines = [
141
+ `#!/bin/sh`,
142
+ `# generated by agent-compose — pre-recycle data dumps from .ac/services.yml`,
143
+ `status() { printf '%s %s %s %s\\n' ${shq(SERVICE_STATUS_PREFIX)} "$1" "$2" "$3"; }`,
144
+ ];
145
+ for (const svc of withDump) {
146
+ const cwd = serviceCwd(driveRoot, svc);
147
+ lines.push(
148
+ ``,
149
+ `# --- ${svc.name} ---`,
150
+ `(`,
151
+ ` cd ${shq(cwd)} || { status ${shq(svc.name)} dump-failed cwd-missing; exit 0; }`,
152
+ ` if ${shc(svc.data!.dump!)} > /dev/null 2>&1; then`,
153
+ ` status ${shq(svc.name)} dumped ''`,
154
+ ` else`,
155
+ ` status ${shq(svc.name)} dump-failed ''`,
156
+ ` fi`,
157
+ `)`,
158
+ );
159
+ }
160
+ lines.push(``);
161
+ return lines.join("\n");
162
+ }
163
+
164
+ /**
165
+ * Parse the status lines out of a restore/dump run's stdout. Non-status lines
166
+ * are ignored; malformed status lines are dropped rather than guessed at.
167
+ */
168
+ export function parseServicesRestoreOutput(stdout: string): ServiceRestoreOutcome[] {
169
+ const out: ServiceRestoreOutcome[] = [];
170
+ for (const raw of stdout.split(/\r?\n/)) {
171
+ const line = raw.trim();
172
+ if (!line.startsWith(`${SERVICE_STATUS_PREFIX} `)) continue;
173
+ const rest = line.slice(SERVICE_STATUS_PREFIX.length + 1).trim();
174
+ const [name, status, ...detail] = rest.split(/\s+/);
175
+ if (!name || !status) continue;
176
+ if (!["ok", "unchecked", "failed", "dumped", "dump-failed"].includes(status)) continue;
177
+ const d = detail.join(" ").trim();
178
+ out.push({ name, status: status as ServiceRestoreStatus, ...(d ? { detail: d } : {}) });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * One human line for the conversation notice, e.g.
185
+ * `postgres ✓ redis ✓ web ✗ (health-timeout — see /tmp/ac-services/web.log)`.
186
+ */
187
+ export function formatServicesRestoreSummary(outcomes: ServiceRestoreOutcome[]): string {
188
+ return outcomes
189
+ .map((o) => {
190
+ if (o.status === "ok" || o.status === "unchecked" || o.status === "dumped") return `${o.name} ✓`;
191
+ const why = o.detail ? `${o.detail} — see ${SERVICES_STATE_DIR}/${o.name}.log` : `see ${SERVICES_STATE_DIR}/${o.name}.log`;
192
+ return `${o.name} ✗ (${why})`;
193
+ })
194
+ .join(" ");
195
+ }