outsrc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/config.example.toml +46 -0
  4. package/dist/adapters.d.ts +110 -0
  5. package/dist/adapters.js +246 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +302 -0
  8. package/dist/config.d.ts +46 -0
  9. package/dist/config.js +87 -0
  10. package/dist/engines.d.ts +44 -0
  11. package/dist/engines.js +69 -0
  12. package/dist/fs-home.d.ts +7 -0
  13. package/dist/fs-home.js +28 -0
  14. package/dist/git.d.ts +9 -0
  15. package/dist/git.js +27 -0
  16. package/dist/init.d.ts +54 -0
  17. package/dist/init.js +322 -0
  18. package/dist/job.d.ts +19 -0
  19. package/dist/job.js +112 -0
  20. package/dist/limits.d.ts +13 -0
  21. package/dist/limits.js +10 -0
  22. package/dist/mailbox.d.ts +122 -0
  23. package/dist/mailbox.js +503 -0
  24. package/dist/migrate.d.ts +11 -0
  25. package/dist/migrate.js +122 -0
  26. package/dist/paths.d.ts +7 -0
  27. package/dist/paths.js +36 -0
  28. package/dist/permissions.d.ts +2 -0
  29. package/dist/permissions.js +29 -0
  30. package/dist/plugin-contract.d.ts +12 -0
  31. package/dist/plugin-contract.js +79 -0
  32. package/dist/plugins.d.ts +44 -0
  33. package/dist/plugins.js +215 -0
  34. package/dist/prompt.d.ts +2 -0
  35. package/dist/prompt.js +20 -0
  36. package/dist/server.d.ts +12 -0
  37. package/dist/server.js +127 -0
  38. package/dist/settings.d.ts +247 -0
  39. package/dist/settings.js +214 -0
  40. package/dist/state.d.ts +79 -0
  41. package/dist/state.js +61 -0
  42. package/dist/types.d.ts +181 -0
  43. package/dist/types.js +35 -0
  44. package/dist/workspace.d.ts +18 -0
  45. package/dist/workspace.js +66 -0
  46. package/dist/wrapper.d.ts +2 -0
  47. package/dist/wrapper.js +246 -0
  48. package/engines.lock.json +14 -0
  49. package/package.json +59 -0
@@ -0,0 +1,503 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, chmodSync } from "node:fs";
4
+ import { ensureOutsrcHome, writeHomeFile } from "./fs-home.js";
5
+ import { join } from "node:path";
6
+ import { z } from "zod";
7
+ import { buildInvocation } from "./adapters.js";
8
+ import { addWorktree, hideAgentConfig } from "./git.js";
9
+ import { commandExists, JOB_MARKER, jobEnv, pidAlive, processIdentity, sameProcess, startWrapper, stopOwnedProcess } from "./job.js";
10
+ import { MAX_LOG_READ_BYTES, resolveLimits } from "./limits.js";
11
+ import { branchName, filesystemPath, resolveInside, threadDir, threadsDir, worktreePath } from "./paths.js";
12
+ import { isVerified, pluginVersion } from "./plugin-contract.js";
13
+ import { cancelInvocation, engineDir, isPluginAdapter, teardownInvocation } from "./plugins.js";
14
+ import { wrapMessage } from "./prompt.js";
15
+ import { ProcessSchema, readJson, readResult, RunSchema, ThreadSchema, writeJson } from "./state.js";
16
+ import { collectWorkspaceDiff, getBaseCommit } from "./workspace.js";
17
+ import { DEFAULT_CALLER, parseAlias, parseRunId, parseTargetName, parseThreadId } from "./types.js";
18
+ function id() { return randomBytes(8).toString("hex"); }
19
+ function adapter(target, name) {
20
+ const kind = target.adapter ?? (name === "claude" || name === "codex" || name === "grok" ? name : "custom");
21
+ return { adapter: kind, command: target.command, args: target.args };
22
+ }
23
+ function verifyRef(repoPath, ref, field) {
24
+ const name = ref.trim();
25
+ if (!name || name.startsWith("-"))
26
+ throw new Error(`${field} must name a commit, branch or tag`);
27
+ try {
28
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", `${name}^{commit}`], { cwd: repoPath, stdio: "pipe" });
29
+ }
30
+ catch {
31
+ throw new Error(`${field} does not resolve to a commit in the repository: ${name}`);
32
+ }
33
+ }
34
+ function pluginStatus(target) {
35
+ if (!isPluginAdapter(target.adapter) || !target.command)
36
+ return {};
37
+ const version = pluginVersion(target.command);
38
+ const verified = isVerified(target.adapter, version);
39
+ return {
40
+ plugin_version: version, verified,
41
+ ...(verified ? {} : { warning: `Plugin version ${version ?? "unknown"} has not passed outsrc's plugin smoke test; results may be unreliable.` }),
42
+ };
43
+ }
44
+ function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
45
+ function failedResult(message, sessionId = null) {
46
+ return { kind: "failed", message, sessionId, findings: [], exitCode: null, finishedAt: new Date().toISOString(), diffstat: "", commit: null };
47
+ }
48
+ const RequestSchema = z.object({
49
+ fingerprint: z.string(), threadId: z.string().transform(parseThreadId), runId: z.string().transform(parseRunId),
50
+ preparation: z.object({ ownerPid: z.number(), previousRunId: z.string().optional(), thread: ThreadSchema, run: RunSchema }).optional(),
51
+ });
52
+ // Keyed by caller too, so one caller's request_id can never replay another caller's thread.
53
+ export function requestFileName(caller, requestId) {
54
+ return `${createHash("sha256").update(`${caller ?? ""}\0${requestId}`).digest("hex")}.json`;
55
+ }
56
+ const fingerprint = (input) => createHash("sha256").update(JSON.stringify({
57
+ message: input.message, repo: input.repo, target: input.target, thread: input.thread_id,
58
+ model: input.model, effort: input.effort, kind: input.kind, base: input.base, ref: input.ref,
59
+ })).digest("hex");
60
+ export function createMailbox(ctx) {
61
+ function loadThread(raw) {
62
+ const threadId = parseThreadId(raw);
63
+ const file = join(threadDir(ctx.home, threadId), "thread.json");
64
+ if (!existsSync(file))
65
+ throw new Error(`unknown thread_id: ${threadId}`);
66
+ const parsed = ThreadSchema.safeParse(readJson(file));
67
+ if (!parsed.success)
68
+ throw new Error(`thread ${threadId} uses the previous storage format; run outsrc migrate after legacy jobs finish. Its worktree is preserved`);
69
+ // Another caller's thread answers exactly like a missing one, so callers cannot read, answer or even detect each other's work.
70
+ if (ctx.caller !== undefined && parsed.data.caller !== ctx.caller)
71
+ throw new Error(`unknown thread_id: ${threadId}`);
72
+ return parsed.data;
73
+ }
74
+ function storedRunDir(threadId, runId) {
75
+ return resolveInside(threadDir(ctx.home, threadId), "runs", parseRunId(runId));
76
+ }
77
+ function readProcess(file) {
78
+ if (!existsSync(file))
79
+ return null;
80
+ const parsed = ProcessSchema.safeParse(readJson(file));
81
+ return parsed.success ? parsed.data : null;
82
+ }
83
+ function current(thread) {
84
+ const dir = storedRunDir(thread.id, thread.latestRunId);
85
+ const run = RunSchema.parse(readJson(join(dir, "run.json")));
86
+ return { dir, run };
87
+ }
88
+ function resultFor(thread) {
89
+ const { dir, run } = current(thread);
90
+ // Observe the wrapper before reading its result. A wrapper seen as gone has already published anything it
91
+ // will publish, so the read below cannot miss a result that lands between the two checks.
92
+ const processFile = join(dir, "process.json");
93
+ const started = existsSync(processFile);
94
+ const recorded = readProcess(processFile);
95
+ const identity = recorded ? processIdentity(recorded) : null;
96
+ const alive = recorded !== null && (identity === "match" || (identity === "unknown" && pidAlive(recorded.pid)));
97
+ const result = readResult(dir);
98
+ if (result)
99
+ return result;
100
+ if (existsSync(join(dir, "cancelled"))) {
101
+ const cancelled = { ...failedResult("Task cancelled", run.sessionId), kind: "cancelled" };
102
+ writeJson(join(dir, "result.json"), cancelled);
103
+ return cancelled;
104
+ }
105
+ if (alive)
106
+ return null;
107
+ if (started) {
108
+ const failed = failedResult("Agent wrapper stopped without publishing a result", run.sessionId);
109
+ writeJson(join(dir, "result.json"), failed);
110
+ return failed;
111
+ }
112
+ if (Date.now() - Date.parse(run.createdAt) < 30_000)
113
+ return null;
114
+ const failed = failedResult("Task startup was interrupted before the wrapper started", run.sessionId);
115
+ writeJson(join(dir, "result.json"), failed);
116
+ return failed;
117
+ }
118
+ function tail(dir, offset, limit = MAX_LOG_READ_BYTES) {
119
+ const file = join(dir, "run.log");
120
+ if (!existsSync(file))
121
+ return "";
122
+ const bytes = readFileSync(file);
123
+ const bounded = Number.isFinite(limit) ? Math.max(0, Math.min(Math.floor(limit), MAX_LOG_READ_BYTES)) : MAX_LOG_READ_BYTES;
124
+ const start = offset ?? Math.max(0, bytes.length - bounded);
125
+ const from = Math.max(0, Math.min(Math.floor(start), bytes.length));
126
+ return bytes.subarray(from, from + bounded).toString("utf8");
127
+ }
128
+ function inbox(threadId) {
129
+ try {
130
+ const thread = loadThread(threadId);
131
+ const { dir, run } = current(thread);
132
+ const result = resultFor(thread);
133
+ if (!result) {
134
+ const age = Math.max(0, Date.now() - Date.parse(run.createdAt));
135
+ const retry = ctx.retryAfterSeconds ?? Math.min(300, 30 * 2 ** Math.floor(age / 120_000));
136
+ return { ok: true, status: "working", retry_after_seconds: retry, run_id: run.id, progress: tail(dir).trim().split("\n").slice(-4).join("\n") };
137
+ }
138
+ if (result.kind === "needs_input")
139
+ return {
140
+ ok: true, status: "needs_input", retry_after_seconds: 0, run_id: run.id,
141
+ message: result.message, question_id: run.id, session_id: result.sessionId,
142
+ };
143
+ return {
144
+ ok: true, status: result.kind === "completed" ? "succeeded" : result.kind,
145
+ run_id: run.id, message: result.message, branch: thread.branch, worktree: thread.worktree,
146
+ diffstat: { raw: result.diffstat }, exit_code: result.exitCode,
147
+ session_id: result.sessionId, findings: result.findings, commit: result.commit,
148
+ };
149
+ }
150
+ catch (error) {
151
+ return { ok: false, error: errorMessage(error) };
152
+ }
153
+ }
154
+ function replayRequest(file, requestFingerprint) {
155
+ const previous = RequestSchema.parse(readJson(file));
156
+ if (previous.fingerprint !== requestFingerprint)
157
+ throw new Error("request_id was already used for a different message");
158
+ const directory = threadDir(ctx.home, previous.threadId);
159
+ const savedRunDir = storedRunDir(previous.threadId, previous.runId);
160
+ const savedThreadFile = join(directory, "thread.json");
161
+ const savedThread = existsSync(savedThreadFile) ? ThreadSchema.parse(readJson(savedThreadFile)) : null;
162
+ const linkPending = previous.preparation?.previousRunId !== undefined && savedThread?.latestRunId === previous.preparation.previousRunId;
163
+ if (!savedThread || !existsSync(join(savedRunDir, "run.json")) || linkPending) {
164
+ const preparation = previous.preparation;
165
+ if (!preparation)
166
+ throw new Error("request startup was interrupted; its earlier storage format needs manual recovery");
167
+ if (pidAlive(preparation.ownerPid))
168
+ throw new Error("request is still preparing; retry the same request_id");
169
+ mkdirSync(savedRunDir, { recursive: true, mode: 0o700 });
170
+ try {
171
+ chmodSync(savedRunDir, 0o700);
172
+ }
173
+ catch { }
174
+ writeJson(join(savedRunDir, "run.json"), preparation.run);
175
+ writeJson(join(savedRunDir, "result.json"), failedResult("Submission was interrupted before the wrapper started; retry with a new request_id", preparation.run.sessionId));
176
+ const threadFile = join(directory, "thread.json");
177
+ const existing = existsSync(threadFile) ? ThreadSchema.parse(readJson(threadFile)) : null;
178
+ if (!existing || existing.latestRunId === preparation.previousRunId) {
179
+ writeJson(threadFile, preparation.thread);
180
+ }
181
+ }
182
+ return { ok: true, delivered: true, thread_id: previous.threadId, run_id: previous.runId };
183
+ }
184
+ async function send(input) {
185
+ let requestFile = null;
186
+ let lockedThread = null;
187
+ let runDir = null;
188
+ let thread = null;
189
+ let createdThreadDirectory = null;
190
+ try {
191
+ if (!input.message.trim())
192
+ throw new Error("message is required");
193
+ if (process.env[JOB_MARKER])
194
+ throw new Error("outsrc jobs cannot send new work; only the caller that started the job can");
195
+ const { maxJobs, maxRunMinutes } = resolveLimits(ctx.config.limits);
196
+ const active = threads().threads.filter((item) => item.status === "working").length;
197
+ if (maxJobs !== null && active >= maxJobs)
198
+ throw new Error(`active job limit reached (${maxJobs}); the owner can change it with outsrc config set limits.max_jobs <n>`);
199
+ const requestFingerprint = fingerprint(input);
200
+ if (input.request_id) {
201
+ const requests = join(ctx.home, "requests");
202
+ mkdirSync(requests, { recursive: true, mode: 0o700 });
203
+ try {
204
+ chmodSync(requests, 0o700);
205
+ }
206
+ catch { }
207
+ requestFile = join(requests, requestFileName(ctx.caller, input.request_id));
208
+ if (existsSync(requestFile)) {
209
+ return replayRequest(requestFile, requestFingerprint);
210
+ }
211
+ }
212
+ let sessionId = null;
213
+ let first = false;
214
+ if (input.thread_id) {
215
+ thread = loadThread(input.thread_id);
216
+ if (existsSync(join(threadDir(ctx.home, thread.id), "discarded")))
217
+ throw new Error("thread was discarded; start a new task");
218
+ if (input.repo !== undefined || input.target !== undefined || input.model !== undefined || input.effort !== undefined || input.kind !== undefined || input.base !== undefined || input.ref !== undefined)
219
+ throw new Error("continuations retain their repository, target and options; start a new task to change them");
220
+ const result = resultFor(thread);
221
+ if (!result)
222
+ throw new Error("thread is still working");
223
+ if (!result.sessionId)
224
+ throw new Error("thread has no resumable session ID; start a new task");
225
+ sessionId = result.sessionId;
226
+ }
227
+ else {
228
+ if (!input.repo)
229
+ throw new Error("repo is required");
230
+ if (!input.target)
231
+ throw new Error("target is required");
232
+ const repo = parseAlias(input.repo);
233
+ const target = parseTargetName(input.target);
234
+ if (!ctx.config.repos.some((item) => item.alias === repo))
235
+ throw new Error(`unknown repo: ${repo}`);
236
+ const targetConfig = ctx.config.targets[target];
237
+ if (!targetConfig)
238
+ throw new Error(`unknown target: ${target}`);
239
+ if (input.model && targetConfig.models && !targetConfig.models.allowed.includes(input.model))
240
+ throw new Error(`model not allowed: ${input.model}`);
241
+ if (input.effort && targetConfig.effort && !targetConfig.effort.allowed.includes(input.effort))
242
+ throw new Error(`effort not allowed: ${input.effort}`);
243
+ const repoPath = ctx.config.repos.find((item) => item.alias === repo)?.path ?? "";
244
+ if (input.base !== undefined) {
245
+ if ((input.kind ?? "task") === "task")
246
+ throw new Error("base applies only to review and adversarial_review");
247
+ verifyRef(repoPath, input.base, "base");
248
+ }
249
+ if (input.ref !== undefined)
250
+ verifyRef(repoPath, input.ref, "ref");
251
+ const threadId = parseThreadId(id());
252
+ thread = {
253
+ version: 2, id: threadId, caller: ctx.caller ?? DEFAULT_CALLER, repo, target,
254
+ model: input.model ?? targetConfig.models?.default ?? null,
255
+ effort: input.effort ?? targetConfig.effort?.default ?? null,
256
+ taskKind: input.kind ?? "task", base: input.base?.trim() ?? null, worktree: worktreePath(ctx.home, repo, threadId),
257
+ branch: branchName(threadId), baseCommit: "", createdAt: new Date().toISOString(), latestRunId: "",
258
+ };
259
+ first = true;
260
+ }
261
+ const selectedRepo = thread.repo;
262
+ const repo = ctx.config.repos.find((item) => item.alias === selectedRepo);
263
+ const target = ctx.config.targets[thread.target];
264
+ if (!repo || !target)
265
+ throw new Error("thread repository or target is no longer configured");
266
+ if (!commandExists(target.command))
267
+ throw new Error(`command not found: ${target.command}`);
268
+ const runId = id();
269
+ const run = { id: runId, threadId: thread.id, requestId: input.request_id ?? null, message: input.message.trim(), createdAt: new Date().toISOString(), sessionId };
270
+ const directory = threadDir(ctx.home, thread.id);
271
+ runDir = storedRunDir(thread.id, runId);
272
+ const spec = {
273
+ threadId: thread.id, runId, runDir, worktree: thread.worktree, baseCommit: thread.baseCommit,
274
+ target: adapter(target, thread.target), model: thread.model, effort: thread.effort,
275
+ permissions: target.permissions ?? "auto", sessionId,
276
+ prompt: wrapMessage(input.message, thread.taskKind, isPluginAdapter(adapter(target, thread.target).adapter) ? "text" : "json"),
277
+ // Setup commands run the checkout's own scripts (npm install hooks, for example); a review must not run them.
278
+ setup: first && thread.taskKind === "task" ? repo.setup ?? [] : [], autoCommit: thread.taskKind === "task" && (repo.autoCommit ?? false),
279
+ kind: thread.taskKind, base: thread.base,
280
+ deadlineMs: maxRunMinutes === null ? null : maxRunMinutes * 60_000,
281
+ };
282
+ const selected = spec.target.adapter;
283
+ buildInvocation({ ...spec, schemaPath: join(runDir, "output-schema.json"), dataDir: isPluginAdapter(selected) ? engineDir(directory, selected) : "" });
284
+ ensureOutsrcHome(directory);
285
+ if (first)
286
+ createdThreadDirectory = directory;
287
+ const lock = join(directory, "launch.lock");
288
+ if (existsSync(lock)) {
289
+ const owner = Number(readFileSync(lock, "utf8"));
290
+ if (!pidAlive(owner))
291
+ rmSync(lock, { force: true });
292
+ }
293
+ writeHomeFile(lock, String(process.pid), { flag: "wx" });
294
+ lockedThread = lock;
295
+ if (!first && resultFor(loadThread(thread.id)) === null)
296
+ throw new Error("thread is still working");
297
+ if (requestFile) {
298
+ try {
299
+ writeHomeFile(requestFile, JSON.stringify({ fingerprint: requestFingerprint, threadId: thread.id, runId, preparation: { ownerPid: process.pid, previousRunId: thread.latestRunId, thread: { ...thread, latestRunId: runId }, run } }), { flag: "wx" });
300
+ }
301
+ catch (error) {
302
+ if (!existsSync(requestFile))
303
+ throw error;
304
+ return replayRequest(requestFile, requestFingerprint);
305
+ }
306
+ }
307
+ if (first) {
308
+ addWorktree({ repo: repo.path, worktree: thread.worktree, branch: thread.branch, ...(input.ref !== undefined ? { ref: input.ref.trim() } : {}) });
309
+ if (thread.taskKind !== "task")
310
+ hideAgentConfig(thread.worktree);
311
+ thread.baseCommit = getBaseCommit(thread.worktree);
312
+ spec.baseCommit = thread.baseCommit;
313
+ }
314
+ mkdirSync(runDir, { recursive: true, mode: 0o700 });
315
+ try {
316
+ chmodSync(runDir, 0o700);
317
+ }
318
+ catch { }
319
+ writeJson(join(runDir, "run.json"), run);
320
+ writeJson(join(runDir, "spec.json"), spec);
321
+ thread.latestRunId = runId;
322
+ writeJson(join(directory, "thread.json"), thread);
323
+ const recorded = await startWrapper({ ctx, specFile: join(runDir, "spec.json") });
324
+ if (recorded && !existsSync(join(runDir, "process.json")))
325
+ writeJson(join(runDir, "process.json"), recorded);
326
+ return { ok: true, delivered: true, thread_id: thread.id, run_id: runId };
327
+ }
328
+ catch (error) {
329
+ const message = errorMessage(error);
330
+ if (runDir && thread && existsSync(join(runDir, "run.json"))) {
331
+ const savedRun = RunSchema.parse(readJson(join(runDir, "run.json")));
332
+ writeJson(join(runDir, "result.json"), failedResult(message, savedRun.sessionId));
333
+ return { ok: true, delivered: true, thread_id: thread.id, run_id: savedRun.id };
334
+ }
335
+ else if (requestFile && thread && existsSync(requestFile)) {
336
+ const previous = RequestSchema.parse(readJson(requestFile));
337
+ if (previous.threadId === thread.id)
338
+ rmSync(requestFile, { force: true });
339
+ }
340
+ if (createdThreadDirectory && !existsSync(join(createdThreadDirectory, "thread.json"))) {
341
+ rmSync(createdThreadDirectory, { recursive: true, force: true });
342
+ try {
343
+ rmdirSync(threadsDir(ctx.home));
344
+ }
345
+ catch { }
346
+ }
347
+ return { ok: false, error: message };
348
+ }
349
+ finally {
350
+ if (lockedThread)
351
+ rmSync(lockedThread, { force: true });
352
+ }
353
+ }
354
+ function threads() {
355
+ if (!existsSync(threadsDir(ctx.home)))
356
+ return { threads: [] };
357
+ return { threads: readdirSync(threadsDir(ctx.home)).flatMap((name) => {
358
+ try {
359
+ const thread = loadThread(name);
360
+ const status = inbox(name);
361
+ if (!status.ok)
362
+ return [];
363
+ return [{ thread_id: thread.id, repo: thread.repo, target: thread.target, kind: thread.taskKind,
364
+ status: status.status, discarded: existsSync(join(threadDir(ctx.home, thread.id), "discarded")), created_at: thread.createdAt, run_id: thread.latestRunId,
365
+ run_count: readdirSync(join(threadDir(ctx.home, thread.id), "runs")).length,
366
+ message: status.status === "working" ? status.progress : status.message }];
367
+ }
368
+ catch {
369
+ return [];
370
+ }
371
+ }).sort((a, b) => b.created_at.localeCompare(a.created_at)) };
372
+ }
373
+ function discard(threadId) {
374
+ try {
375
+ const thread = loadThread(threadId);
376
+ const result = resultFor(thread);
377
+ if (!result || result.kind === "needs_input")
378
+ throw new Error("only finished threads can be discarded");
379
+ const repo = ctx.config.repos.find((item) => item.alias === thread.repo);
380
+ if (!repo)
381
+ throw new Error(`unknown repo: ${thread.repo}`);
382
+ const registered = execFileSync("git", ["worktree", "list", "--porcelain", "-z"], { cwd: repo.path, encoding: "utf8" }).split("\0").includes(`worktree ${filesystemPath(thread.worktree)}`);
383
+ if (registered)
384
+ execFileSync("git", ["worktree", "remove", "--force", thread.worktree], { cwd: repo.path, stdio: "pipe" });
385
+ else if (existsSync(thread.worktree))
386
+ rmSync(thread.worktree, { recursive: true });
387
+ const branch = execFileSync("git", ["branch", "--list", thread.branch], { cwd: repo.path, encoding: "utf8" });
388
+ if (branch.trim())
389
+ execFileSync("git", ["branch", "-D", thread.branch], { cwd: repo.path, stdio: "pipe" });
390
+ writeHomeFile(join(threadDir(ctx.home, thread.id), "discarded"), new Date().toISOString());
391
+ return { ok: true, discarded: true, thread_id: thread.id };
392
+ }
393
+ catch (error) {
394
+ return { ok: false, error: errorMessage(error) };
395
+ }
396
+ }
397
+ return {
398
+ listRepos: () => ({ repos: ctx.config.repos.map(({ alias, path }) => ({ alias, path })) }),
399
+ listTargets: () => ({ targets: Object.entries(ctx.config.targets).map(([name, target]) => ({
400
+ name, adapter: adapter(target, name).adapter, available: commandExists(target.command),
401
+ models: target.models ?? null, effort: target.effort ?? null,
402
+ description: target.description ?? "", cost_note: target.costNote ?? "Not configured",
403
+ resume: adapter(target, name).adapter !== "custom" || target.args.some((arg) => arg.includes("{session_id}")),
404
+ ...pluginStatus(adapter(target, name)),
405
+ })) }),
406
+ send, inbox, threads, discard,
407
+ history(threadId) {
408
+ try {
409
+ const thread = loadThread(threadId);
410
+ const root = join(threadDir(ctx.home, thread.id), "runs");
411
+ return { ok: true, thread_id: thread.id, runs: readdirSync(root).flatMap((runId) => {
412
+ let directory;
413
+ try {
414
+ directory = storedRunDir(thread.id, runId);
415
+ }
416
+ catch {
417
+ return [];
418
+ }
419
+ const run = RunSchema.parse(readJson(join(directory, "run.json")));
420
+ const result = readResult(directory);
421
+ return [{ run_id: run.id, request_id: run.requestId, created_at: run.createdAt, message: run.message,
422
+ status: result ? (result.kind === "completed" ? "succeeded" : result.kind) : "working",
423
+ session_id: result?.sessionId ?? run.sessionId, final_message: result?.message ?? null,
424
+ findings: result?.findings ?? [], exit_code: result?.exitCode ?? null }];
425
+ }).sort((a, b) => a.created_at.localeCompare(b.created_at)) };
426
+ }
427
+ catch (error) {
428
+ return { ok: false, error: errorMessage(error) };
429
+ }
430
+ },
431
+ log(input) {
432
+ try {
433
+ if (input.run_id !== undefined)
434
+ parseRunId(input.run_id);
435
+ const thread = loadThread(input.thread_id);
436
+ const dir = storedRunDir(thread.id, input.run_id ?? thread.latestRunId);
437
+ return { ok: true, text: tail(dir, input.offset, input.limit) };
438
+ }
439
+ catch (error) {
440
+ return { ok: false, error: errorMessage(error) };
441
+ }
442
+ },
443
+ diff(input) {
444
+ try {
445
+ const thread = loadThread(input.thread_id);
446
+ if (!thread.baseCommit)
447
+ throw new Error("No starting commit was recorded because task setup did not finish");
448
+ return { ok: true, ...collectWorkspaceDiff({ workspace: thread.worktree, baseCommit: thread.baseCommit, ...(input.limit !== undefined ? { maxBytes: input.limit } : {}) }) };
449
+ }
450
+ catch (error) {
451
+ return { ok: false, error: errorMessage(error) };
452
+ }
453
+ },
454
+ stop(threadId) {
455
+ try {
456
+ const thread = loadThread(threadId);
457
+ const { dir } = current(thread);
458
+ if (readResult(dir))
459
+ return { ok: true, stopped: true };
460
+ const file = join(dir, "process.json");
461
+ const recorded = readProcess(file);
462
+ if (existsSync(file) && (!recorded || !sameProcess(recorded))) {
463
+ throw new Error("refusing to signal a process that does not match the recorded wrapper");
464
+ }
465
+ writeHomeFile(join(dir, "cancelled"), "1");
466
+ const target = ctx.config.targets[thread.target];
467
+ const selected = target ? adapter(target, thread.target).adapter : "custom";
468
+ if (target && isPluginAdapter(selected)) {
469
+ // The plugin knows the vendor CLI's pids, including children that left the wrapper's process group.
470
+ const data = engineDir(threadDir(ctx.home, thread.id), selected);
471
+ for (const call of [cancelInvocation(selected, target.command, thread.worktree, data, thread.id), teardownInvocation(selected, target.command, thread.worktree, data)]) {
472
+ if (!call)
473
+ continue;
474
+ spawnSync(call.command, call.args, { cwd: thread.worktree, input: call.input ?? "", env: { ...jobEnv(), ...call.env }, stdio: ["pipe", "ignore", "ignore"], timeout: 15_000 });
475
+ }
476
+ }
477
+ if (recorded)
478
+ stopOwnedProcess(recorded);
479
+ return { ok: true, stopped: true };
480
+ }
481
+ catch (error) {
482
+ return { ok: false, error: errorMessage(error) };
483
+ }
484
+ },
485
+ prune() {
486
+ const removed = [];
487
+ for (const item of threads().threads) {
488
+ const repo = ctx.config.repos.find((entry) => entry.alias === item.repo);
489
+ if (!repo?.retentionDays || item.status === "working" || item.status === "needs_input")
490
+ continue;
491
+ const thread = loadThread(item.thread_id);
492
+ const result = resultFor(thread);
493
+ if (!result || Date.now() - Date.parse(result.finishedAt) < repo.retentionDays * 86400_000)
494
+ continue;
495
+ if (existsSync(join(threadDir(ctx.home, thread.id), "discarded")))
496
+ continue;
497
+ if (discard(thread.id).ok)
498
+ removed.push(thread.id);
499
+ }
500
+ return { removed };
501
+ },
502
+ };
503
+ }
@@ -0,0 +1,11 @@
1
+ import { type Config } from "./types.js";
2
+ export declare function migrateLegacy(input: {
3
+ home: string;
4
+ config: Config;
5
+ }): {
6
+ migrated: string[];
7
+ skipped: {
8
+ thread_id: string;
9
+ reason: string;
10
+ }[];
11
+ };
@@ -0,0 +1,122 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { pidAlive } from "./job.js";
6
+ import { threadsDir } from "./paths.js";
7
+ import { readJson, ResultSchema, RunSchema, ThreadSchema, writeJson } from "./state.js";
8
+ import { parseAlias, parseTargetName, parseThreadId } from "./types.js";
9
+ const LegacyThreadSchema = z.object({
10
+ id: z.string().transform(parseThreadId),
11
+ repo: z.string().transform(parseAlias),
12
+ target: z.string().transform(parseTargetName),
13
+ model: z.string().nullable(),
14
+ effort: z.string().nullable(),
15
+ status: z.enum(["working", "needs_input", "succeeded", "failed", "cancelled"]),
16
+ worktree: z.string(),
17
+ branch: z.string(),
18
+ pid: z.number(),
19
+ pgid: z.number(),
20
+ createdAt: z.string(),
21
+ });
22
+ const QuestionSchema = z.object({ id: z.string(), text: z.string() });
23
+ function readText(file) {
24
+ return existsSync(file) ? readFileSync(file, "utf8") : "";
25
+ }
26
+ function unansweredQuestion(directory) {
27
+ const questionFile = join(directory, "question.json");
28
+ if (!existsSync(questionFile))
29
+ return false;
30
+ const question = QuestionSchema.safeParse(readJson(questionFile));
31
+ if (!question.success)
32
+ return false;
33
+ const answerFile = join(directory, "answer.json");
34
+ if (!existsSync(answerFile))
35
+ return true;
36
+ const answer = QuestionSchema.safeParse(readJson(answerFile));
37
+ return !answer.success || answer.data.id !== question.data.id;
38
+ }
39
+ export function migrateLegacy(input) {
40
+ const report = { migrated: [], skipped: [] };
41
+ const root = threadsDir(input.home);
42
+ if (!existsSync(root))
43
+ return report;
44
+ for (const name of readdirSync(root)) {
45
+ const directory = join(root, name);
46
+ const file = join(directory, "thread.json");
47
+ if (!existsSync(file))
48
+ continue;
49
+ try {
50
+ const value = readJson(file);
51
+ if (ThreadSchema.safeParse(value).success)
52
+ continue;
53
+ const legacy = LegacyThreadSchema.parse(value);
54
+ const exitFile = join(directory, "exit_code");
55
+ const cancelledFile = join(directory, "cancelled");
56
+ const hasExit = existsSync(exitFile);
57
+ const cancelled = existsSync(cancelledFile);
58
+ if (!hasExit && !cancelled) {
59
+ if (legacy.status === "needs_input" || unansweredQuestion(directory))
60
+ throw new Error("legacy job is waiting for input; finish it with the previous harness before migrating");
61
+ if (pidAlive(legacy.pid))
62
+ throw new Error("legacy job is still active; finish it before migrating");
63
+ }
64
+ const repo = input.config.repos.find((entry) => entry.alias === legacy.repo);
65
+ if (!repo)
66
+ throw new Error(`repository is no longer configured: ${legacy.repo}`);
67
+ let baseCommit;
68
+ try {
69
+ const oldest = execFileSync("git", ["reflog", "show", "--format=%H%x00%gs", `refs/heads/${legacy.branch}`], {
70
+ cwd: repo.path, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
71
+ }).trim().split("\n").filter(Boolean).at(-1);
72
+ const [commit, event] = oldest?.split("\0") ?? [];
73
+ if (event?.startsWith("branch: Created from "))
74
+ baseCommit = commit;
75
+ }
76
+ catch {
77
+ baseCommit = undefined;
78
+ }
79
+ if (!baseCommit)
80
+ throw new Error("starting commit is unavailable in the branch reflog; no base was guessed");
81
+ const logFile = join(directory, "run.log");
82
+ const summaryFile = existsSync(join(directory, "summary.md"))
83
+ ? join(directory, "summary.md") : join(legacy.worktree, "SUMMARY.md");
84
+ const exitCode = hasExit ? z.number().finite().parse(Number(readText(exitFile).trim())) : null;
85
+ const message = readText(summaryFile).trim() || readText(logFile).trim().slice(-500) || `(no summary, exit ${exitCode})`;
86
+ const finishedFile = cancelled ? cancelledFile : hasExit ? exitFile : existsSync(logFile) ? logFile : file;
87
+ const runId = "legacy";
88
+ const run = RunSchema.parse({
89
+ id: runId, threadId: legacy.id, requestId: null,
90
+ message: readText(join(directory, "prompt.md")).trim(),
91
+ createdAt: legacy.createdAt, sessionId: null,
92
+ });
93
+ const result = ResultSchema.parse({
94
+ kind: cancelled ? "cancelled" : exitCode === 0 ? "completed" : "failed",
95
+ message, sessionId: null, findings: [], exitCode,
96
+ finishedAt: statSync(finishedFile).mtime.toISOString(),
97
+ diffstat: readText(join(directory, "diffstat")), commit: null,
98
+ });
99
+ const thread = ThreadSchema.parse({
100
+ version: 2, id: legacy.id, repo: legacy.repo, target: legacy.target,
101
+ model: legacy.model, effort: legacy.effort, taskKind: "task",
102
+ worktree: legacy.worktree, branch: legacy.branch, baseCommit,
103
+ createdAt: legacy.createdAt, latestRunId: runId,
104
+ });
105
+ const backup = join(directory, "thread.legacy.json");
106
+ if (!existsSync(backup))
107
+ copyFileSync(file, backup);
108
+ const runDir = join(directory, "runs", runId);
109
+ mkdirSync(runDir, { recursive: true });
110
+ if (existsSync(logFile))
111
+ copyFileSync(logFile, join(runDir, "run.log"));
112
+ writeJson(join(runDir, "run.json"), run);
113
+ writeJson(join(runDir, "result.json"), result);
114
+ writeJson(file, thread);
115
+ report.migrated.push(legacy.id);
116
+ }
117
+ catch (error) {
118
+ report.skipped.push({ thread_id: name, reason: error instanceof Error ? error.message : String(error) });
119
+ }
120
+ }
121
+ return report;
122
+ }
@@ -0,0 +1,7 @@
1
+ import { type Alias, type ThreadId } from "./types.js";
2
+ export declare function threadsDir(home: string): string;
3
+ export declare function filesystemPath(path: string): string;
4
+ export declare function resolveInside(root: string, ...segments: string[]): string;
5
+ export declare function threadDir(home: string, id: ThreadId): string;
6
+ export declare function worktreePath(home: string, alias: Alias, id: ThreadId): string;
7
+ export declare function branchName(id: ThreadId): string;