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
package/dist/init.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { type PluginAdapter } from "./plugins.js";
2
+ export type Cli = "claude" | "codex" | "grok";
3
+ export type Run = {
4
+ status: number | null;
5
+ stdout: string;
6
+ stderr: string;
7
+ };
8
+ export type InitEnv = {
9
+ configPath: string;
10
+ serverCommand: string[];
11
+ which: (command: string) => string | null;
12
+ run: (command: string, args: string[]) => Run;
13
+ runInteractive: (command: string, args: string[]) => number | null;
14
+ runStreaming: (command: string, args: string[], onLine: (line: string) => void) => Promise<number | null>;
15
+ emit: (event: Record<string, unknown>) => void;
16
+ confirm: (question: string, fallback: boolean) => Promise<boolean>;
17
+ ask: (question: string) => Promise<string>;
18
+ print: (line: string) => void;
19
+ engineScript: (adapter: PluginAdapter) => string | null;
20
+ claudeEngine: (adapter: PluginAdapter) => string | null;
21
+ installEngine: (adapter: PluginAdapter) => {
22
+ version: string;
23
+ commit: string;
24
+ };
25
+ };
26
+ export type InitOptions = {
27
+ repos: string[];
28
+ yes: boolean;
29
+ json: boolean;
30
+ plugins: boolean | null;
31
+ local: Cli[] | null;
32
+ limits: {
33
+ max_jobs?: string;
34
+ max_run_minutes?: string;
35
+ };
36
+ };
37
+ export declare const NEEDS_HUMAN = 3;
38
+ export declare function parseInitArgs(argv: string[]): InitOptions;
39
+ type RepoEntry = {
40
+ alias: string;
41
+ path: string;
42
+ };
43
+ type TargetEntry = Record<string, unknown>;
44
+ export declare function targetFor(cli: Cli, path: string, useEngine: boolean): TargetEntry;
45
+ export declare function mergeConfig(existing: string | null, repos: RepoEntry[], targets: Record<string, TargetEntry>): {
46
+ toml: string;
47
+ addedRepos: string[];
48
+ addedTargets: string[];
49
+ };
50
+ export declare function runInit(env: InitEnv, options: InitOptions): Promise<number>;
51
+ export declare function defaultInitEnv(configPath: string, json?: boolean): InitEnv & {
52
+ close: () => void;
53
+ };
54
+ export {};
package/dist/init.js ADDED
@@ -0,0 +1,322 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { ensureOutsrcHome, writeHomeFile } from "./fs-home.js";
4
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parse, stringify } from "smol-toml";
8
+ import { z } from "zod";
9
+ import { loadConfigFile } from "./config.js";
10
+ import { changeSetting, listSettings, RISKS } from "./settings.js";
11
+ import { createMailbox } from "./mailbox.js";
12
+ import { checkPluginContract } from "./plugin-contract.js";
13
+ import { installEngine, managedScript, readLock } from "./engines.js";
14
+ import { isPluginAdapter, resolvePluginScript } from "./plugins.js";
15
+ const CLIS = ["claude", "codex", "grok"];
16
+ // Exit code for "stopped for a person": the caller should relay the needs_human events and rerun init.
17
+ export const NEEDS_HUMAN = 3;
18
+ export function parseInitArgs(argv) {
19
+ const options = { repos: [], yes: false, json: false, plugins: null, local: null, limits: {} };
20
+ for (let index = 0; index < argv.length; index++) {
21
+ const arg = argv[index];
22
+ const next = () => { const value = argv[++index]; if (value === undefined)
23
+ throw new Error(`${arg} needs a value`); return value; };
24
+ if (arg === "--repo")
25
+ options.repos.push(next());
26
+ else if (arg === "--yes" || arg === "-y")
27
+ options.yes = true;
28
+ else if (arg === "--json") {
29
+ options.json = true;
30
+ options.yes = true;
31
+ }
32
+ else if (arg === "--plugins")
33
+ options.plugins = true;
34
+ else if (arg === "--no-plugins")
35
+ options.plugins = false;
36
+ else if (arg === "--local")
37
+ options.local = z.array(z.enum(["claude", "codex", "grok"])).parse(next().split(",").filter(Boolean));
38
+ else if (arg === "--max-jobs")
39
+ options.limits.max_jobs = next();
40
+ else if (arg === "--max-run-minutes")
41
+ options.limits.max_run_minutes = next();
42
+ else
43
+ throw new Error(`unknown init option: ${arg}`);
44
+ }
45
+ return options;
46
+ }
47
+ export function targetFor(cli, path, useEngine) {
48
+ const description = {
49
+ claude: "Coding tasks and review through the Claude Code CLI.",
50
+ codex: "Coding tasks and code review through Codex, including its built-in and adversarial reviewers.",
51
+ grok: "Coding tasks and critique through Grok Build.",
52
+ }[cli];
53
+ const effort = cli === "grok" ? ["low", "medium", "high"] : cli === "codex" ? ["low", "medium", "high", "xhigh"] : ["low", "medium", "high", "xhigh", "max"];
54
+ // Engine targets carry no command: it resolves at load to the engine pinned in engines.lock.json.
55
+ const engine = useEngine && cli !== "claude" ? { adapter: `${cli}-plugin` } : { adapter: cli, command: path, args: [] };
56
+ return { ...engine, permissions: "auto", description, cost_note: `Uses this machine's ${cli} account.`, effort: { default: "medium", allowed: effort } };
57
+ }
58
+ const ExistingSchema = z.object({
59
+ repos: z.array(z.object({ alias: z.string(), path: z.string() }).passthrough()).default([]),
60
+ targets: z.record(z.string(), z.record(z.string(), z.unknown())).default({}),
61
+ }).passthrough();
62
+ // Existing repos and targets win; the wizard only adds what is missing, so reruns change nothing.
63
+ export function mergeConfig(existing, repos, targets) {
64
+ const current = ExistingSchema.parse(existing ? parse(existing) : {});
65
+ const addedRepos = [];
66
+ const aliases = new Set(current.repos.map((repo) => repo.alias));
67
+ for (const repo of repos) {
68
+ if (current.repos.some((item) => resolve(item.path) === resolve(repo.path)))
69
+ continue;
70
+ let alias = repo.alias;
71
+ for (let n = 2; aliases.has(alias); n++)
72
+ alias = `${repo.alias}-${n}`;
73
+ aliases.add(alias);
74
+ current.repos.push({ alias, path: repo.path });
75
+ addedRepos.push(alias);
76
+ }
77
+ const addedTargets = Object.keys(targets).filter((name) => !(name in current.targets));
78
+ for (const name of addedTargets)
79
+ current.targets[name] = targets[name] ?? {};
80
+ return { toml: stringify(current), addedRepos, addedTargets };
81
+ }
82
+ function repoEntry(env, input) {
83
+ const top = env.run("git", ["-C", resolve(input), "rev-parse", "--show-toplevel"]);
84
+ if (top.status !== 0)
85
+ throw new Error(`not a git repository: ${input}`);
86
+ const path = top.stdout.trim();
87
+ const alias = basename(path).toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "repo";
88
+ return { alias, path };
89
+ }
90
+ const LocalCommands = {
91
+ claude: { registered: ["mcp", "get", "outsrc"], add: (server) => ["mcp", "add", "--scope", "user", "outsrc", "--", ...server] },
92
+ codex: { registered: ["mcp", "get", "outsrc"], add: (server) => ["mcp", "add", "outsrc", "--", ...server] },
93
+ grok: { registered: null, add: (server) => ["mcp", "add", "--scope", "user", "outsrc", "--", ...server] },
94
+ };
95
+ const LOGIN = {
96
+ claude: { check: ["auth", "status", "--json"], loggedIn: (r) => r.status === 0 && /"loggedIn":\s*true/.test(r.stdout), interactive: ["auth", "login"], headless: "claude auth login" },
97
+ codex: { check: ["login", "status"], loggedIn: (r) => r.status === 0, interactive: ["login"], headless: "codex login --device-auth" },
98
+ grok: { check: ["models"], loggedIn: (r) => r.status === 0, interactive: ["login"], headless: "grok login --device-auth" },
99
+ };
100
+ const PLUGINS = {
101
+ codex: { adapter: "codex-plugin", repo: "openai/codex-plugin-cc", adds: "Codex's own reviewer and adversarial review" },
102
+ grok: { adapter: "grok-plugin", repo: "xai-org/grok-build-plugin-cc", adds: "Grok's review and critique commands" },
103
+ };
104
+ export async function runInit(env, options) {
105
+ const say = env.print;
106
+ say("outsrc setup. It is safe to run again; existing settings are kept.\n");
107
+ const pending = [];
108
+ function needsHuman(step) {
109
+ pending.push(step);
110
+ env.emit({ event: "needs_human", ...step });
111
+ say(` ACTION FOR A PERSON: ${step.instructions}`);
112
+ }
113
+ function finish(status, extra = {}) {
114
+ const code = status === 0 && pending.length ? NEEDS_HUMAN : status;
115
+ env.emit({ event: "done", status: code === 0 ? "ok" : code === NEEDS_HUMAN ? "needs_human" : "error", config: env.configPath, pending, ...extra });
116
+ if (code === NEEDS_HUMAN)
117
+ say("\nFinish the actions above, then run outsrc init again. It picks up where it stopped.");
118
+ return code;
119
+ }
120
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
121
+ if (!((major === 22 && minor >= 12) || major === 24 || major >= 26)) {
122
+ say(`Node ${process.version} is not supported. Use 22.12+, 24 or 26+.`);
123
+ return finish(1, { error: "unsupported node" });
124
+ }
125
+ if (!env.which("git")) {
126
+ say("git is required.");
127
+ return finish(1, { error: "git not found" });
128
+ }
129
+ const found = Object.fromEntries(CLIS.map((cli) => [cli, env.which(cli)]));
130
+ say("Agent CLIs on this machine:");
131
+ for (const cli of CLIS)
132
+ say(` ${cli}: ${found[cli] ?? "not found"}`);
133
+ if (!CLIS.some((cli) => found[cli])) {
134
+ say("\nInstall at least one of claude, codex or grok, log in, then run outsrc init again.");
135
+ return finish(1, { error: "no agent CLI found" });
136
+ }
137
+ for (const cli of CLIS) {
138
+ if (!found[cli])
139
+ continue;
140
+ const login = LOGIN[cli];
141
+ if (login.loggedIn(env.run(cli, login.check)))
142
+ continue;
143
+ if (!options.yes && await env.confirm(` ${cli} is not logged in. Log in now? This opens a browser.`, true)) {
144
+ env.runInteractive(cli, login.interactive);
145
+ if (login.loggedIn(env.run(cli, login.check)))
146
+ continue;
147
+ }
148
+ needsHuman({ step: "login", cli, command: login.headless, instructions: `Log in to ${cli}: run \`${login.headless}\` on this machine and finish the sign-in.` });
149
+ }
150
+ const engines = new Set();
151
+ for (const cli of ["codex", "grok"]) {
152
+ const plugin = PLUGINS[cli];
153
+ if (!found[cli])
154
+ continue;
155
+ if (env.engineScript(plugin.adapter)) {
156
+ engines.add(cli);
157
+ continue;
158
+ }
159
+ const wanted = options.plugins ?? (options.yes || await env.confirm(` Download the ${plugin.repo} engine into outsrc? It adds ${plugin.adds}. Claude Code is not needed.`, true));
160
+ const fallback = env.claudeEngine(plugin.adapter) ? "using Claude Code's copy of the plugin, which is not pinned" : "calling the CLI directly";
161
+ if (!wanted) {
162
+ say(` ${cli}: ${fallback}`);
163
+ }
164
+ else {
165
+ try {
166
+ const pinned = env.installEngine(plugin.adapter);
167
+ engines.add(cli);
168
+ say(` ${cli}: downloaded ${plugin.repo} ${pinned.version} (${pinned.commit.slice(0, 7)})`);
169
+ continue;
170
+ }
171
+ catch (error) {
172
+ say(` ${cli}: could not download ${plugin.repo}, ${fallback}: ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ }
175
+ if (env.claudeEngine(plugin.adapter))
176
+ engines.add(cli);
177
+ }
178
+ const targets = {};
179
+ for (const cli of CLIS) {
180
+ const path = found[cli];
181
+ if (path)
182
+ targets[cli] = targetFor(cli, path, engines.has(cli));
183
+ }
184
+ say("\nRepositories agents may start work in. outsrc refuses any other repository, but the agent CLIs are not sandboxed to these folders.");
185
+ const repos = [];
186
+ for (const path of options.repos)
187
+ repos.push(repoEntry(env, path));
188
+ if (!options.yes) {
189
+ for (;;) {
190
+ const answer = (await env.ask(" Repository path (blank when done): ")).trim();
191
+ if (!answer)
192
+ break;
193
+ try {
194
+ const entry = repoEntry(env, answer);
195
+ repos.push(entry);
196
+ say(` added ${entry.alias} -> ${entry.path}`);
197
+ }
198
+ catch (error) {
199
+ say(` ${error instanceof Error ? error.message : String(error)}`);
200
+ }
201
+ }
202
+ }
203
+ ensureOutsrcHome(dirname(env.configPath));
204
+ const merged = mergeConfig(existsSync(env.configPath) ? readFileSync(env.configPath, "utf8") : null, repos, targets);
205
+ writeHomeFile(env.configPath, merged.toml);
206
+ say(`\nWrote ${env.configPath}. New repositories: ${merged.addedRepos.join(", ") || "none"}. New targets: ${merged.addedTargets.join(", ") || "none"}.`);
207
+ const LIMIT_QUESTIONS = {
208
+ max_jobs: "Most jobs working at once (a number, or unlimited)",
209
+ max_run_minutes: "Longest a run may take, in minutes (a number, or unlimited)",
210
+ };
211
+ for (const name of ["max_jobs", "max_run_minutes"]) {
212
+ let answer = options.limits[name];
213
+ if (answer === undefined && !options.yes) {
214
+ const current = listSettings(env.configPath).limits[name];
215
+ answer = (await env.ask(` ${LIMIT_QUESTIONS[name]} [${current}]: `)).trim() || undefined;
216
+ if (answer === "unlimited" && !await env.confirm(` ${RISKS[`limits.${name}`]} Use unlimited?`, false))
217
+ answer = undefined;
218
+ }
219
+ if (answer === undefined)
220
+ continue;
221
+ const change = changeSetting(env.configPath, `limits.${name}`, answer);
222
+ if (!change.ok)
223
+ throw new Error(change.error);
224
+ say(` limits.${name} = ${answer}${change.warning ? `. ${change.warning}` : ""}`);
225
+ }
226
+ const config = loadConfigFile(env.configPath);
227
+ const settings = listSettings(env.configPath);
228
+ env.emit({ event: "settings", limits: settings.limits, repos: settings.repos.map((repo) => repo.alias), targets: Object.keys(settings.targets),
229
+ instructions: "Review these with the owner: run outsrc config for every setting and its meaning, and outsrc config set <key> <value> for each change they ask for." });
230
+ say(`\nLimits: ${settings.limits.max_jobs} jobs at once, ${settings.limits.max_run_minutes} minutes per run. See every setting with outsrc config.`);
231
+ if (config.repos.length === 0)
232
+ say("No repositories are configured yet. Run outsrc init again with --repo <path>.");
233
+ const box = createMailbox({ home: dirname(env.configPath), config });
234
+ for (const target of box.listTargets().targets) {
235
+ const warning = "warning" in target ? ` (${String(target.warning)})` : "";
236
+ say(` target ${target.name}: ${target.available ? "available" : "NOT available"}${warning}`);
237
+ }
238
+ for (const target of Object.values(config.targets)) {
239
+ if (target.adapter && isPluginAdapter(target.adapter) && target.command) {
240
+ const report = checkPluginContract(target.adapter, target.command);
241
+ if (report.status === "broken")
242
+ say(` ${target.adapter} ${report.version ?? ""} is incompatible: ${report.problems.join("; ")}`);
243
+ }
244
+ }
245
+ const ephemeral = env.serverCommand.some((part) => part.includes("/_npx/"));
246
+ if (ephemeral) {
247
+ say("\nYou ran outsrc through npx, whose files live in a temporary cache. Install it with `npm i -g outsrc` and run `outsrc init` again to connect agents to a stable copy.");
248
+ return finish(0, { error: "npx copy not registered" });
249
+ }
250
+ say("\nLocal agents can call outsrc as an MCP server to hand work to the other CLIs.");
251
+ for (const cli of CLIS) {
252
+ if (!found[cli])
253
+ continue;
254
+ const wanted = options.local ? options.local.includes(cli) : options.yes ? false : await env.confirm(` Add outsrc to ${cli}?`, true);
255
+ if (!wanted)
256
+ continue;
257
+ const commands = LocalCommands[cli];
258
+ if (commands.registered && env.run(cli, commands.registered).status === 0) {
259
+ say(` ${cli}: already has outsrc`);
260
+ continue;
261
+ }
262
+ const added = env.run(cli, commands.add([...env.serverCommand, "--caller", cli]));
263
+ say(added.status === 0 ? ` ${cli}: added outsrc` : ` ${cli}: could not add outsrc: ${added.stderr.trim() || added.stdout.trim()}`);
264
+ }
265
+ say("\nDone. Run `outsrc doctor` any time to recheck.");
266
+ return finish(0);
267
+ }
268
+ function lookup(command) {
269
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
270
+ const candidate = join(directory, command);
271
+ // Keep the PATH entry, not its target: ~/.local/bin/claude points at a versioned binary that updates replace.
272
+ if (directory && existsSync(candidate))
273
+ return candidate;
274
+ }
275
+ return null;
276
+ }
277
+ export function defaultInitEnv(configPath, json = false) {
278
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
279
+ const server = join(dirname(fileURLToPath(import.meta.url)), "server.js");
280
+ return {
281
+ configPath,
282
+ serverCommand: [process.execPath, existsSync(server) ? realpathSync(server) : server],
283
+ which: lookup,
284
+ run: (command, args) => {
285
+ const result = spawnSync(command, args, { encoding: "utf8", timeout: 120_000 });
286
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? String(result.error ?? "") };
287
+ },
288
+ runInteractive: (command, args) => spawnSync(command, args, { stdio: "inherit" }).status,
289
+ runStreaming: (command, args, onLine) => new Promise((done) => {
290
+ const child = spawn(command, args, { stdio: ["inherit", "pipe", "inherit"] });
291
+ let buffer = "";
292
+ child.stdout.on("data", (chunk) => {
293
+ buffer += chunk.toString("utf8");
294
+ for (let index = buffer.indexOf("\n"); index >= 0; index = buffer.indexOf("\n")) {
295
+ onLine(buffer.slice(0, index));
296
+ buffer = buffer.slice(index + 1);
297
+ }
298
+ });
299
+ child.once("error", () => done(null));
300
+ child.once("close", (code) => { if (buffer)
301
+ onLine(buffer); done(code); });
302
+ }),
303
+ emit: (event) => { if (json)
304
+ process.stdout.write(`${JSON.stringify(event)}\n`); },
305
+ confirm: async (question, fallback) => {
306
+ const answer = (await rl.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
307
+ return answer ? answer.startsWith("y") : fallback;
308
+ },
309
+ ask: (question) => rl.question(question),
310
+ print: (line) => (json ? process.stderr : process.stdout).write(`${line}\n`),
311
+ engineScript: (adapter) => managedScript(dirname(configPath), adapter),
312
+ claudeEngine: (adapter) => resolvePluginScript(adapter),
313
+ installEngine: (adapter) => {
314
+ const entry = readLock()[adapter];
315
+ if (!entry)
316
+ throw new Error(`engines.lock.json has no ${adapter} entry`);
317
+ installEngine(dirname(configPath), adapter, entry);
318
+ return { version: entry.version, commit: entry.commit };
319
+ },
320
+ close: () => rl.close(),
321
+ };
322
+ }
package/dist/job.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { MailboxContext } from "./types.js";
2
+ export type ProcessRecord = {
3
+ pid: number;
4
+ pgid: number;
5
+ startedAt: string;
6
+ };
7
+ export declare const DEFAULT_WRAPPER: string;
8
+ export declare const JOB_MARKER = "OUTSRC_JOB";
9
+ export declare function jobEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
10
+ export declare function commandExists(command: string): boolean;
11
+ export declare function captureProcess(pid: number): ProcessRecord | null;
12
+ export declare function processIdentity(record: ProcessRecord): "match" | "mismatch" | "unknown";
13
+ export declare function sameProcess(record: ProcessRecord): boolean;
14
+ export declare function startWrapper(input: {
15
+ ctx: MailboxContext;
16
+ specFile: string;
17
+ }): Promise<ProcessRecord | null>;
18
+ export declare function pidAlive(pid: number): boolean;
19
+ export declare function stopOwnedProcess(record: ProcessRecord): boolean;
package/dist/job.js ADDED
@@ -0,0 +1,112 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const source = fileURLToPath(import.meta.url).endsWith(".ts");
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ export const DEFAULT_WRAPPER = join(here, source ? "wrapper.ts" : "wrapper.js");
8
+ const BASE_ENV = ["PATH", "HOME", "USER", "LANG", "TMPDIR", "TERM"];
9
+ // Set in every job's environment. An outsrc server started by an agent inside a job sees it and refuses to send,
10
+ // so a delegated agent cannot delegate onward.
11
+ export const JOB_MARKER = "OUTSRC_JOB";
12
+ export function jobEnv(extra = {}) {
13
+ const env = {};
14
+ for (const key of BASE_ENV) {
15
+ const value = process.env[key];
16
+ if (value)
17
+ env[key] = value;
18
+ }
19
+ return { ...env, [JOB_MARKER]: "1", ...extra };
20
+ }
21
+ export function commandExists(command) {
22
+ if (!command)
23
+ return false;
24
+ if (command.includes("/") || command.startsWith("."))
25
+ return existsSync(command);
26
+ return (process.env.PATH ?? "").split(":").some((dir) => existsSync(join(dir, command)));
27
+ }
28
+ // lstart is formatted by the caller's locale and time zone; pin both so the recording and checking processes agree.
29
+ function startTime(pid) {
30
+ return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC0" } }).trim();
31
+ }
32
+ export function captureProcess(pid) {
33
+ if (!Number.isInteger(pid) || pid < 2)
34
+ return null;
35
+ try {
36
+ const startedAt = startTime(pid);
37
+ if (!startedAt)
38
+ return null;
39
+ return { pid, pgid: pid, startedAt };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export function processIdentity(record) {
46
+ if (!Number.isInteger(record.pid) || record.pid < 2 || record.pgid !== record.pid || record.startedAt.length === 0)
47
+ return "mismatch";
48
+ try {
49
+ const startedAt = startTime(record.pid);
50
+ if (!startedAt)
51
+ return "unknown";
52
+ return startedAt === record.startedAt ? "match" : "mismatch";
53
+ }
54
+ catch {
55
+ return "unknown";
56
+ }
57
+ }
58
+ export function sameProcess(record) {
59
+ return processIdentity(record) === "match";
60
+ }
61
+ export async function startWrapper(input) {
62
+ const node = input.ctx.nodeExecutable ?? process.execPath;
63
+ const wrapper = input.ctx.wrapperCommand ?? [node, ...(source ? ["--import", import.meta.resolve("tsx")] : []), DEFAULT_WRAPPER];
64
+ const command = wrapper[0];
65
+ if (!command)
66
+ throw new Error("wrapper command is empty");
67
+ const env = jobEnv();
68
+ const child = spawn(command, [...wrapper.slice(1), "--spec", input.specFile], {
69
+ detached: true,
70
+ stdio: "ignore",
71
+ env,
72
+ });
73
+ await new Promise((resolve, reject) => {
74
+ child.once("error", reject);
75
+ child.once("spawn", resolve);
76
+ });
77
+ child.unref();
78
+ if (child.pid === undefined)
79
+ throw new Error("failed to spawn wrapper");
80
+ return captureProcess(child.pid);
81
+ }
82
+ export function pidAlive(pid) {
83
+ try {
84
+ process.kill(pid, 0);
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export function stopOwnedProcess(record) {
92
+ if (!sameProcess(record))
93
+ return false;
94
+ try {
95
+ process.kill(-record.pgid, "SIGTERM");
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ const killer = setTimeout(() => {
101
+ if (!sameProcess(record))
102
+ return;
103
+ try {
104
+ process.kill(-record.pgid, "SIGKILL");
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ }, 2000);
110
+ killer.unref();
111
+ return true;
112
+ }
@@ -0,0 +1,13 @@
1
+ export declare const DEFAULT_LIMITS: {
2
+ readonly max_jobs: 4;
3
+ readonly max_run_minutes: 120;
4
+ };
5
+ export type Limits = {
6
+ maxJobs: number | null;
7
+ maxRunMinutes: number | null;
8
+ };
9
+ export declare function resolveLimits(limits: Limits | undefined): Limits;
10
+ export declare const MAX_LOG_READ_BYTES = 8192;
11
+ export declare const MAX_LOG_BYTES = 1048576;
12
+ export declare const MAX_RUN_MS: number;
13
+ export declare const LOG_TRUNCATED = "\n[outsrc log truncated]\n";
package/dist/limits.js ADDED
@@ -0,0 +1,10 @@
1
+ // Owner-configurable in config.toml [limits]; "unlimited" is stored as null.
2
+ export const DEFAULT_LIMITS = { max_jobs: 4, max_run_minutes: 120 };
3
+ export function resolveLimits(limits) {
4
+ return limits ?? { maxJobs: DEFAULT_LIMITS.max_jobs, maxRunMinutes: DEFAULT_LIMITS.max_run_minutes };
5
+ }
6
+ export const MAX_LOG_READ_BYTES = 8192;
7
+ export const MAX_LOG_BYTES = 1_048_576;
8
+ // Deadline for a run whose spec predates configurable limits.
9
+ export const MAX_RUN_MS = DEFAULT_LIMITS.max_run_minutes * 60 * 1000;
10
+ export const LOG_TRUNCATED = "\n[outsrc log truncated]\n";
@@ -0,0 +1,122 @@
1
+ import { type InboxResult, type MailboxContext, type SendInput, type SendResult, type ThreadId } from "./types.js";
2
+ export declare function requestFileName(caller: string | undefined, requestId: string): string;
3
+ export declare function createMailbox(ctx: MailboxContext): {
4
+ listRepos: () => {
5
+ repos: {
6
+ alias: string;
7
+ path: string;
8
+ }[];
9
+ };
10
+ listTargets: () => {
11
+ targets: {
12
+ name: string;
13
+ adapter: import("./adapters.js").Adapter;
14
+ available: boolean;
15
+ models: {
16
+ default: string;
17
+ allowed: string[];
18
+ } | null;
19
+ effort: {
20
+ default: string;
21
+ allowed: string[];
22
+ } | null;
23
+ description: string;
24
+ cost_note: string;
25
+ resume: boolean;
26
+ }[];
27
+ };
28
+ send: (input: SendInput) => Promise<SendResult>;
29
+ inbox: (threadId: string) => InboxResult;
30
+ threads: () => {
31
+ threads: {
32
+ thread_id: ThreadId;
33
+ repo: import("./types.js").Alias;
34
+ target: import("./types.js").TargetName;
35
+ kind: import("./types.js").TaskKind;
36
+ status: "cancelled" | "failed" | "needs_input" | "succeeded" | "working";
37
+ discarded: boolean;
38
+ created_at: string;
39
+ run_id: string;
40
+ run_count: number;
41
+ message: string;
42
+ }[];
43
+ };
44
+ discard: (threadId: string) => {
45
+ ok: boolean;
46
+ discarded: boolean;
47
+ thread_id: ThreadId;
48
+ error?: never;
49
+ } | {
50
+ discarded?: never;
51
+ thread_id?: never;
52
+ ok: boolean;
53
+ error: string;
54
+ };
55
+ history(threadId: string): {
56
+ error?: never;
57
+ ok: boolean;
58
+ thread_id: ThreadId;
59
+ runs: {
60
+ run_id: import("./types.js").RunId;
61
+ request_id: string | null;
62
+ created_at: string;
63
+ message: string;
64
+ status: string;
65
+ session_id: string | null;
66
+ final_message: string | null;
67
+ findings: {
68
+ priority: "P0" | "P1" | "P2" | "P3";
69
+ title: string;
70
+ body: string;
71
+ path: string | null;
72
+ line: number | null;
73
+ }[];
74
+ exit_code: number | null;
75
+ }[];
76
+ } | {
77
+ thread_id?: never;
78
+ runs?: never;
79
+ ok: boolean;
80
+ error: string;
81
+ };
82
+ log(input: {
83
+ thread_id: string;
84
+ run_id?: string;
85
+ offset?: number;
86
+ limit?: number;
87
+ }): {
88
+ error?: never;
89
+ ok: boolean;
90
+ text: string;
91
+ } | {
92
+ text?: never;
93
+ ok: boolean;
94
+ error: string;
95
+ };
96
+ diff(input: {
97
+ thread_id: string;
98
+ limit?: number;
99
+ }): {
100
+ patch: string;
101
+ stat: string;
102
+ truncated: boolean;
103
+ error?: never;
104
+ ok: boolean;
105
+ } | {
106
+ ok: boolean;
107
+ error: string;
108
+ };
109
+ stop(threadId: string): {
110
+ error?: never;
111
+ ok: boolean;
112
+ stopped: boolean;
113
+ } | {
114
+ stopped?: never;
115
+ ok: boolean;
116
+ error: string;
117
+ };
118
+ prune(): {
119
+ removed: string[];
120
+ };
121
+ };
122
+ export type Mailbox = ReturnType<typeof createMailbox>;