@proagentstore/cli 0.4.77 → 0.4.79

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.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Local artifact generation on the runner (#956) — the Application Tailor's hands.
3
+ *
4
+ * One run = one Codex or Claude Code process, signed in on this machine, that turns ONE approved
5
+ * job lead plus the owner's own source files into a tailored résumé and cover letter. No browser,
6
+ * no repository, no provider API key. The runtime, not the CLI, does every read and every write:
7
+ *
8
+ * - reads: only the configured source files, each resolved on the real disk inside the workspace
9
+ * (`..`, absolute paths and symlinks out are refused), and never under `applications/`, where
10
+ * generated material — this instance's or another's — lives;
11
+ * - writes: only new files in a fresh `<workspace>/applications/<leadId>/<runId>/`, created
12
+ * exclusively (`wx`), so a master source is never rewritten and two runs never share a folder;
13
+ * - the trace carries handles (role, owner-visible path, sha256, byte count), never content.
14
+ *
15
+ * A missing source, an unusable workspace, a CLI that is not signed in, an API key in the env, a
16
+ * malformed lead or a draft whose claims do not check out ends the run `needs_human` — a pause
17
+ * the owner resolves — never a plausible guess written to disk.
18
+ *
19
+ * Runs live in memory, like local browser runs: a restart loses them and `status` answers 404,
20
+ * which PAGS reads as "the runner lost this run". Generated folders carry a manifest with their
21
+ * retention date; a later run on the same workspace removes those past it.
22
+ */
23
+ import { spawn as nodeSpawn } from "node:child_process";
24
+ import { createHash } from "node:crypto";
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join, resolve, sep } from "node:path";
28
+ import { RunnerInputError } from "../errors.js";
29
+ import { redactText, secretEnvValues } from "../local-browser/contract.js";
30
+ import { resolveWorkspacePath } from "../local-browser/runtime.js";
31
+ import { LOCAL_ARTIFACT_AUTH_MODES, LOCAL_ARTIFACT_CAPS, LOCAL_ARTIFACT_ENGINES, LOCAL_ARTIFACT_SOURCE_ROLES, LOCAL_ARTIFACT_TASK_TYPE, PATH_SEGMENT, REQUIRED_SOURCE_ROLES, isHomeRelative, isWorkspaceRelative, parseLocalArtifactLead, } from "./contract.js";
32
+ import { buildArtifactEngineSpec, checkDraft, finalText, missingLogin, observedArtifactAuth, parseDraft, tailorPrompt } from "./engine.js";
33
+ /** The manifest in every generated folder — what retention reads, and proof the folder is ours. */
34
+ export const ARTIFACT_MANIFEST = ".pags-artifact.json";
35
+ const MAX_EVENTS = 200;
36
+ const OUTPUT_LINES = 400;
37
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
38
+ const DAY_MS = 86_400_000;
39
+ const intIn = (v, min, max) => (typeof v === "number" && Number.isInteger(v) && v >= min && v <= max ? v : null);
40
+ /** The envelope, validated at the runner's boundary. A bad LEAD is not thrown: it pauses the run. */
41
+ export function parseArtifactEnvelope(raw) {
42
+ const o = (raw && typeof raw === "object" ? raw : {});
43
+ const bad = (what) => new RunnerInputError(`Invalid local artifact task: ${what}`);
44
+ if (o.type !== LOCAL_ARTIFACT_TASK_TYPE)
45
+ throw bad(`type must be ${LOCAL_ARTIFACT_TASK_TYPE}`);
46
+ if (typeof o.runId !== "string" || !PATH_SEGMENT.test(o.runId))
47
+ throw bad("runId is required");
48
+ for (const k of ["requestId", "instanceId"])
49
+ if (typeof o[k] !== "string" || !SAFE_ID.test(o[k]))
50
+ throw bad(`${k} is required`);
51
+ if (!LOCAL_ARTIFACT_ENGINES.includes(o.engine))
52
+ throw bad("engine must be claude or codex");
53
+ if (!LOCAL_ARTIFACT_AUTH_MODES.includes(o.authMode))
54
+ throw bad("authMode must be machine or subscription — a provider API key is never used");
55
+ if (typeof o.workspace !== "string" || !isHomeRelative(o.workspace))
56
+ throw bad('workspace must be a folder written "~/…"');
57
+ if (!Array.isArray(o.sources) || o.sources.length > LOCAL_ARTIFACT_CAPS.sources)
58
+ throw bad("sources must be a list");
59
+ const sources = [];
60
+ for (const s of o.sources) {
61
+ const r = (s && typeof s === "object" ? s : {});
62
+ if (!LOCAL_ARTIFACT_SOURCE_ROLES.includes(r.role) || typeof r.path !== "string" || !isWorkspaceRelative(r.path))
63
+ throw bad("each source needs a role and a path inside the workspace");
64
+ if (r.path.split("/")[0] === "applications")
65
+ throw bad("a source may not be inside applications/, where generated material lives");
66
+ if (sources.some((x) => x.role === r.role))
67
+ throw bad(`source ${String(r.role)} is listed twice`);
68
+ sources.push({ role: r.role, path: r.path });
69
+ }
70
+ const p = (o.policy && typeof o.policy === "object" ? o.policy : {});
71
+ const retainDays = intIn(p.retainDays, 0, 3650);
72
+ const maxMinutes = intIn(p.maxMinutes, 1, 60);
73
+ const maxConcurrent = intIn(p.maxConcurrent, 1, 3);
74
+ if (retainDays === null || maxMinutes === null || maxConcurrent === null)
75
+ throw bad("policy limits are out of range");
76
+ const lead = parseLocalArtifactLead(o.lead);
77
+ return {
78
+ type: LOCAL_ARTIFACT_TASK_TYPE,
79
+ runId: o.runId,
80
+ requestId: o.requestId,
81
+ instanceId: o.instanceId,
82
+ engine: o.engine,
83
+ authMode: o.authMode,
84
+ workspace: o.workspace,
85
+ sources,
86
+ policy: { retainDays, maxMinutes, maxConcurrent },
87
+ ...("lead" in lead ? { lead: lead.lead } : { lead: null, leadError: lead.error }),
88
+ };
89
+ }
90
+ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
91
+ /** A source file resolved on the real disk, strictly inside the workspace. */
92
+ export function resolveSource(workspace, rel) {
93
+ const real = realpathSync(resolve(workspace, rel));
94
+ if (!real.startsWith(workspace + sep))
95
+ throw new Error("outside the workspace");
96
+ if (real.slice(workspace.length + 1).split(sep)[0] === "applications")
97
+ throw new Error("inside applications/");
98
+ if (!statSync(real).isFile())
99
+ throw new Error("not a file");
100
+ return real;
101
+ }
102
+ class Pause extends Error {
103
+ reason;
104
+ questions;
105
+ constructor(reason, questions) {
106
+ super(reason);
107
+ this.reason = reason;
108
+ this.questions = questions;
109
+ }
110
+ }
111
+ export class LocalArtifactRuntime {
112
+ deps;
113
+ runs = new Map();
114
+ root;
115
+ now;
116
+ spawn;
117
+ retentionMs;
118
+ constructor(deps) {
119
+ this.deps = deps;
120
+ this.root = join(deps.dataDir, "local-artifact");
121
+ this.now = deps.now ?? Date.now;
122
+ this.spawn = deps.spawn ?? nodeSpawn;
123
+ this.retentionMs = deps.retentionMs ?? DAY_MS;
124
+ }
125
+ /** Start a run. A requestId already held for the instance returns that run instead. */
126
+ start(raw) {
127
+ const envelope = parseArtifactEnvelope(raw);
128
+ this.sweep();
129
+ const same = [...this.runs.values()].find((r) => r.envelope.instanceId === envelope.instanceId && r.envelope.requestId === envelope.requestId);
130
+ if (same)
131
+ return { runId: same.envelope.runId, taskId: same.envelope.runId, status: same.state, existing: true };
132
+ if (this.runs.has(envelope.runId))
133
+ throw new RunnerInputError(`Run ${envelope.runId} already exists with another requestId`, 409);
134
+ const active = [...this.runs.values()].filter((r) => r.envelope.instanceId === envelope.instanceId && r.state !== "ended").length;
135
+ if (active >= envelope.policy.maxConcurrent)
136
+ throw new RunnerInputError(`This machine is already tailoring ${active} application(s) for this agent (limit ${envelope.policy.maxConcurrent}).`, 409);
137
+ const scratch = join(this.root, envelope.instanceId.replace(/[^A-Za-z0-9_-]/g, "_"), envelope.runId);
138
+ mkdirSync(scratch, { recursive: true });
139
+ const run = { envelope, scratch, state: "running", events: [], seq: 0, output: [], secrets: secretEnvValues(process.env), engineAuth: "unknown", cancelled: false, timedOut: false };
140
+ this.runs.set(envelope.runId, run);
141
+ void this.launch(run).catch((err) => {
142
+ if (err instanceof Pause)
143
+ this.pauseEnd(run, err.reason, err.questions);
144
+ else
145
+ this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) });
146
+ });
147
+ return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
148
+ }
149
+ async launch(run) {
150
+ const e = run.envelope;
151
+ if (!e.lead)
152
+ throw new Pause("malformed_lead", [`The approved lead could not be read (${e.leadError ?? "malformed"}). Re-approve it from the Scout.`]);
153
+ const lead = e.lead;
154
+ let workspace;
155
+ try {
156
+ workspace = resolveWorkspacePath(e.workspace, this.deps.homeDir ?? homedir());
157
+ }
158
+ catch (err) {
159
+ throw new Pause("workspace_unavailable", [`The workspace ${e.workspace} cannot be used: ${err instanceof Error ? err.message : String(err)}`]);
160
+ }
161
+ this.sweepArtifacts(run, workspace);
162
+ // Sources: read by the runtime, hashed, handed to the CLI in its prompt. Content never leaves this function except into the prompt.
163
+ const texts = [];
164
+ const hashes = [];
165
+ const missing = [];
166
+ for (const s of e.sources) {
167
+ const shown = `${e.workspace}/${s.path}`;
168
+ try {
169
+ const file = resolveSource(workspace, s.path);
170
+ const buf = readFileSync(file);
171
+ if (buf.length > LOCAL_ARTIFACT_CAPS.sourceBytes)
172
+ throw new Error(`larger than ${LOCAL_ARTIFACT_CAPS.sourceBytes / 1024} KB`);
173
+ if (!buf.length)
174
+ throw new Error("empty");
175
+ texts.push({ role: s.role, text: buf.toString("utf8") });
176
+ const hash = { role: s.role, path: shown, sha256: sha256(buf), bytes: buf.length };
177
+ hashes.push(hash);
178
+ this.emit(run, { type: "source.read", detail: hash });
179
+ }
180
+ catch (err) {
181
+ this.emit(run, { type: "source.missing", detail: { role: s.role, path: shown } });
182
+ missing.push(`Your ${s.role} at ${shown} could not be read (${err instanceof Error && !/ENOENT/.test(err.message) ? err.message : "not found"}). Add it, or choose another file in the Application Tailor settings.`);
183
+ }
184
+ }
185
+ for (const role of REQUIRED_SOURCE_ROLES) {
186
+ if (!e.sources.some((s) => s.role === role))
187
+ missing.push(`No ${role} source is configured. Choose one in the Application Tailor settings.`);
188
+ }
189
+ if (missing.length)
190
+ throw new Pause("missing_source", missing);
191
+ const outRel = `applications/${lead.leadId}/${e.runId}`;
192
+ const outDir = join(workspace, "applications", lead.leadId, e.runId);
193
+ try {
194
+ mkdirSync(join(workspace, "applications", lead.leadId), { recursive: true });
195
+ mkdirSync(outDir); // exclusive: a run never writes into a folder it did not create
196
+ if (!realpathSync(outDir).startsWith(workspace + sep))
197
+ throw new Error("the output folder resolves outside the workspace");
198
+ }
199
+ catch (err) {
200
+ throw new Pause("workspace_unavailable", [`Cannot create ${e.workspace}/${outRel}: ${err instanceof Error ? err.message : String(err)}`]);
201
+ }
202
+ const spec = buildArtifactEngineSpec({ engine: e.engine, authMode: e.authMode, prompt: tailorPrompt(lead, texts) });
203
+ run.secrets = secretEnvValues({ ...process.env, ...spec.env });
204
+ run.engineAuth = observedArtifactAuth(e.engine, spec.env);
205
+ this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
206
+ if (run.engineAuth === "api-key") {
207
+ rmdirSync(outDir);
208
+ throw new Pause("api_key_refused", ["A provider API key would reach the CLI. The Application Tailor runs on your machine's sign-in only — remove the key from the runner's environment."]);
209
+ }
210
+ const child = this.spawn(spec.command, spec.args, { cwd: run.scratch, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
211
+ run.child = child;
212
+ this.emit(run, { type: "engine.started", detail: { engine: e.engine } });
213
+ let buf = "";
214
+ const keep = (chunk) => {
215
+ buf += chunk.toString();
216
+ const lines = buf.split("\n");
217
+ buf = lines.pop() ?? "";
218
+ for (const line of lines)
219
+ if (line.trim())
220
+ run.output.push(line.slice(0, 200_000));
221
+ if (run.output.length > OUTPUT_LINES)
222
+ run.output.splice(0, run.output.length - OUTPUT_LINES);
223
+ };
224
+ child.stdout?.on("data", keep);
225
+ child.stderr?.on("data", keep);
226
+ child.on("error", (err) => {
227
+ this.removeIfEmpty(outDir);
228
+ this.end(run, { outcome: "failed", error: err.code === "ENOENT" ? `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).` : err.message });
229
+ });
230
+ child.on("close", (code) => {
231
+ if (buf.trim())
232
+ run.output.push(buf);
233
+ this.emit(run, { type: "engine.ended", detail: { exitCode: code ?? -1 } });
234
+ try {
235
+ this.finish(run, code ?? -1, { workspace, outDir, outRel, lead, hashes, texts });
236
+ }
237
+ catch (err) {
238
+ this.removeIfEmpty(outDir);
239
+ this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) });
240
+ }
241
+ });
242
+ run.timer = setTimeout(() => {
243
+ run.timedOut = true;
244
+ this.kill(run);
245
+ }, e.policy.maxMinutes * 60_000);
246
+ run.timer.unref?.();
247
+ }
248
+ /** The CLI exited: check its draft against the sources, and only then write the artifacts. */
249
+ finish(run, code, ctx) {
250
+ if (run.state === "ended")
251
+ return;
252
+ const e = run.envelope;
253
+ const output = run.output.join("\n");
254
+ if (run.cancelled || run.timedOut) {
255
+ this.removeIfEmpty(ctx.outDir);
256
+ this.end(run, { outcome: "failed", error: run.cancelled ? "Cancelled by the owner" : `Stopped at the ${e.policy.maxMinutes}-minute limit.` });
257
+ return;
258
+ }
259
+ const text = finalText(e.engine, run.output);
260
+ if (!text && missingLogin(e.engine, output)) {
261
+ this.removeIfEmpty(ctx.outDir);
262
+ run.engineAuth = "missing_login";
263
+ this.pauseEnd(run, "engine_not_signed_in", [e.engine === "claude" ? "Sign in to Claude Code on this machine (run `claude`, then /login), then retry." : "Sign in to Codex on this machine (`codex login`), then retry."]);
264
+ return;
265
+ }
266
+ if (code !== 0 && !text) {
267
+ this.removeIfEmpty(ctx.outDir);
268
+ this.end(run, { outcome: "failed", error: `The ${e.engine} CLI exited with code ${code}: ${run.output.slice(-5).join("\n").slice(-800)}` });
269
+ return;
270
+ }
271
+ const check = checkDraft(parseDraft(text), ctx.texts);
272
+ this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched } });
273
+ if (!check.ok) {
274
+ this.removeIfEmpty(ctx.outDir);
275
+ this.pauseEnd(run, check.reason, check.questions);
276
+ return;
277
+ }
278
+ const at = new Date(this.now()).toISOString();
279
+ const artifacts = [];
280
+ for (const [kind, name, body] of [
281
+ ["resume", "resume.md", check.resume],
282
+ ["cover_letter", "cover-letter.md", check.coverLetter],
283
+ ]) {
284
+ const content = `${body}\n`;
285
+ writeFileSync(join(ctx.outDir, name), content, { flag: "wx", mode: 0o600 });
286
+ const file = { kind, path: `${e.workspace}/${ctx.outRel}/${name}`, sha256: sha256(content), bytes: Buffer.byteLength(content) };
287
+ artifacts.push(file);
288
+ this.emit(run, { type: "artifact.written", detail: { ...file } });
289
+ }
290
+ const profileVersion = sha256(ctx.hashes.map((h) => `${h.role}:${h.sha256}`).sort().join("\n")).slice(0, 16);
291
+ const retainUntil = e.policy.retainDays > 0 ? this.now() + e.policy.retainDays * DAY_MS : null;
292
+ writeFileSync(join(ctx.outDir, ARTIFACT_MANIFEST), JSON.stringify({ runId: e.runId, instanceId: e.instanceId, leadId: ctx.lead.leadId, eventId: ctx.lead.eventId, generatedAt: at, retainUntil, profileVersion, artifacts, sources: ctx.hashes }, null, 2), { flag: "wx", mode: 0o600 });
293
+ this.end(run, { outcome: "completed", artifacts, sourceHashes: ctx.hashes, profileVersion, generatedAt: at });
294
+ }
295
+ /** Retention: remove generated folders whose manifest says they are past their date. Only ours — a folder without a manifest is the owner's. */
296
+ sweepArtifacts(run, workspace) {
297
+ const apps = join(workspace, "applications");
298
+ if (!existsSync(apps))
299
+ return;
300
+ let removed = 0;
301
+ for (const lead of readdirSync(apps)) {
302
+ const leadDir = join(apps, lead);
303
+ if (!statSync(leadDir).isDirectory())
304
+ continue;
305
+ for (const runId of readdirSync(leadDir)) {
306
+ const manifest = join(leadDir, runId, ARTIFACT_MANIFEST);
307
+ if (!existsSync(manifest))
308
+ continue;
309
+ try {
310
+ const m = JSON.parse(readFileSync(manifest, "utf8"));
311
+ if (typeof m.retainUntil === "number" && m.retainUntil < this.now()) {
312
+ rmSync(join(leadDir, runId), { recursive: true, force: true });
313
+ removed++;
314
+ }
315
+ }
316
+ catch {
317
+ // An unreadable manifest is left alone: deleting on a guess is the one thing retention must not do.
318
+ }
319
+ }
320
+ this.removeIfEmpty(leadDir);
321
+ }
322
+ if (removed)
323
+ this.emit(run, { type: "retention.swept", detail: { removed } });
324
+ }
325
+ removeIfEmpty(dir) {
326
+ try {
327
+ if (existsSync(dir) && readdirSync(dir).length === 0)
328
+ rmdirSync(dir);
329
+ }
330
+ catch {
331
+ // best-effort
332
+ }
333
+ }
334
+ emit(run, ev) {
335
+ if (run.events.length >= MAX_EVENTS)
336
+ return;
337
+ run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
338
+ }
339
+ pauseEnd(run, reason, questions) {
340
+ this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length } });
341
+ this.end(run, { outcome: "needs_human", blockReason: reason, questions });
342
+ }
343
+ end(run, r) {
344
+ if (run.state === "ended")
345
+ return;
346
+ if (run.timer)
347
+ clearTimeout(run.timer);
348
+ run.state = "ended";
349
+ run.endedAt = this.now();
350
+ run.result = {
351
+ runId: run.envelope.runId,
352
+ outcome: r.outcome,
353
+ artifacts: r.artifacts ?? [],
354
+ sourceHashes: r.sourceHashes ?? [],
355
+ profileVersion: r.profileVersion ?? null,
356
+ engineAuth: run.engineAuth,
357
+ traceId: run.envelope.runId,
358
+ ...(r.generatedAt ? { generatedAt: r.generatedAt } : {}),
359
+ ...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
360
+ // CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
361
+ ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
362
+ };
363
+ if (run.child && run.child.exitCode === null)
364
+ this.kill(run);
365
+ }
366
+ kill(run) {
367
+ const child = run.child;
368
+ if (!child || child.exitCode !== null)
369
+ return;
370
+ child.kill("SIGTERM");
371
+ setTimeout(() => {
372
+ if (child.exitCode === null)
373
+ child.kill("SIGKILL");
374
+ }, 5_000).unref?.();
375
+ }
376
+ get(runId) {
377
+ const run = this.runs.get(runId);
378
+ if (!run)
379
+ throw new RunnerInputError(`No local artifact run ${runId} on this runner (it may have restarted)`, 404);
380
+ return run;
381
+ }
382
+ status(raw) {
383
+ const o = (raw && typeof raw === "object" ? raw : {});
384
+ const run = this.get(String(o.runId ?? ""));
385
+ const after = typeof o.afterSeq === "number" && o.afterSeq > 0 ? o.afterSeq : 0;
386
+ return { runId: run.envelope.runId, state: run.state, events: run.events.filter((e) => e.seq > after), lastSeq: run.seq, ...(run.result ? { result: run.result } : {}) };
387
+ }
388
+ cancel(raw) {
389
+ const runId = String(raw?.runId ?? "");
390
+ const run = this.get(runId);
391
+ if (run.state !== "ended") {
392
+ run.cancelled = true;
393
+ if (run.child && run.child.exitCode === null)
394
+ this.kill(run);
395
+ else
396
+ this.end(run, { outcome: "failed", error: "Cancelled by the owner" });
397
+ }
398
+ return { runId, state: run.state };
399
+ }
400
+ /** Drop ended runs past retention with their scratch folders. Generated artifacts are swept by their own manifest, not here. */
401
+ sweep() {
402
+ const cutoff = this.now() - this.retentionMs;
403
+ for (const [id, run] of this.runs) {
404
+ if (run.state === "ended" && (run.endedAt ?? 0) < cutoff) {
405
+ rmSync(run.scratch, { recursive: true, force: true });
406
+ this.runs.delete(id);
407
+ }
408
+ }
409
+ }
410
+ closeAll() {
411
+ for (const run of this.runs.values()) {
412
+ if (run.state !== "ended") {
413
+ run.cancelled = true;
414
+ this.end(run, { outcome: "failed", error: "The runner shut down during the run" });
415
+ }
416
+ }
417
+ }
418
+ }
@@ -101,7 +101,7 @@ export function buildEngineSpec(input) {
101
101
  "-c",
102
102
  `${key}.default_tools_approval_mode="approve"`,
103
103
  "-c",
104
- `${key}.enabled_tools=[${BRIDGE_TOOL_NAMES.map(toml).join(", ")}]`,
104
+ `${key}.enabled_tools=[${(input.toolNames ?? BRIDGE_TOOL_NAMES).map(toml).join(", ")}]`,
105
105
  // Two spellings across Codex versions; an unknown key is ignored, so both are set.
106
106
  "-c",
107
107
  'web_search="disabled"',
@@ -14,6 +14,10 @@ import { RunnerStore } from "./store.js";
14
14
  import { CodingRuntime } from "./coding/runtime.js";
15
15
  import { LocalBrowserRuntime } from "./local-browser/runtime.js";
16
16
  import { LOCAL_BROWSER_TASK_TYPE } from "./local-browser/contract.js";
17
+ import { LocalArtifactRuntime } from "./local-artifact/runtime.js";
18
+ import { LOCAL_ARTIFACT_TASK_TYPE } from "./local-artifact/contract.js";
19
+ import { LocalApplyRuntime } from "./local-apply/runtime.js";
20
+ import { LOCAL_APPLY_TASK_TYPE } from "./local-apply/contract.js";
17
21
  import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
18
22
  /** True for a plain object. */
19
23
  function isRecord(value) {
@@ -77,6 +81,10 @@ export class LocalRunner {
77
81
  coding;
78
82
  /** Local CLI browser research (#944): a signed-in Codex / Claude Code CLI researching through the policy bridge. */
79
83
  localBrowser;
84
+ /** Local artifact generation (#956): the Application Tailor's résumé + cover letter, from the owner's own files. */
85
+ localArtifact;
86
+ /** Local application execution (#957): a signed-in CLI filling one application through the apply bridge. */
87
+ localApply;
80
88
  /** This runner's own local URL, once the server listens — the bridge forwarder calls back to it. */
81
89
  selfUrl = null;
82
90
  /** Live human-takeover sessions, keyed by task id (the page is kept alive). */
@@ -86,19 +94,18 @@ export class LocalRunner {
86
94
  mkdirSync(config.dataDir, { recursive: true });
87
95
  this.store = new RunnerStore(config.dataDir);
88
96
  this.coding = new CodingRuntime(join(config.dataDir, "repos"));
89
- this.localBrowser = new LocalBrowserRuntime({
90
- dataDir: config.dataDir,
91
- selfUrl: () => this.selfUrl,
92
- // `default` is the runner's own signed-in browser, shared and never closed by a run;
93
- // `isolated` is a fresh profile inside the run's folder, removed with it.
94
- browserFor: async (profile, runDir) => {
95
- if (profile === "default")
96
- return { tools: await this.getMcp(), stop: async () => undefined };
97
- const mcp = new McpRuntime();
98
- await mcp.start({ userDataDir: join(runDir, "profile"), headless: config.headless });
99
- return { tools: mcp, stop: () => mcp.stop() };
100
- },
101
- });
97
+ // `default` is the runner's own signed-in browser, shared and never closed by a run;
98
+ // `isolated` is a fresh profile inside the run's folder, removed with it.
99
+ const browserFor = async (profile, runDir) => {
100
+ if (profile === "default")
101
+ return { tools: await this.getMcp(), stop: async () => undefined };
102
+ const mcp = new McpRuntime();
103
+ await mcp.start({ userDataDir: join(runDir, "profile"), headless: config.headless });
104
+ return { tools: mcp, stop: () => mcp.stop() };
105
+ };
106
+ this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
107
+ this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
108
+ this.localApply = new LocalApplyRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
102
109
  // Tasks paused/running on a previous process are orphaned now — their
103
110
  // pages and takeover sessions are gone. Fail them so the board is clean.
104
111
  const expired = this.store.expireInFlightTasks();
@@ -113,7 +120,7 @@ export class LocalRunner {
113
120
  runtimePlane: "pags",
114
121
  runnerRole: "tool-executor",
115
122
  capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
116
- taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, ...CodingRuntime.taskTypes()],
123
+ taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, LOCAL_ARTIFACT_TASK_TYPE, LOCAL_APPLY_TASK_TYPE, ...CodingRuntime.taskTypes()],
117
124
  approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
118
125
  };
119
126
  }
@@ -250,6 +257,8 @@ export class LocalRunner {
250
257
  */
251
258
  async close() {
252
259
  this.localBrowser.closeAll();
260
+ this.localArtifact.closeAll();
261
+ this.localApply.closeAll();
253
262
  try {
254
263
  this.coding.closeAll();
255
264
  }
@@ -12,7 +12,11 @@ export function createRunnerServer(runner) {
12
12
  if (!req.headers.origin && req.method === "POST" && (req.url || "").split("?")[0] === "/local-browser/bridge") {
13
13
  const body = await readJson(req);
14
14
  const token = String(req.headers["x-pags-bridge-token"] || "");
15
- if (!runner.localBrowser.authorizeBridge(String(body.runId ?? ""), token))
15
+ const runId = String(body.runId ?? "");
16
+ // The same forwarder serves application runs (#957); each runtime knows only its own tokens.
17
+ if (runner.localApply.authorizeBridge(runId, token))
18
+ return json(res, 200, await runner.localApply.bridge(body));
19
+ if (!runner.localBrowser.authorizeBridge(runId, token))
16
20
  return json(res, 401, { error: "Unauthorized" });
17
21
  return json(res, 200, await runner.localBrowser.bridge(body));
18
22
  }
@@ -177,6 +181,29 @@ async function route(runner, req, res) {
177
181
  if (req.method === "POST" && path === "/local-browser/cancel") {
178
182
  return json(res, 200, runner.localBrowser.cancel(await readJson(req)));
179
183
  }
184
+ // Local artifact generation (#956) — the same PULL shape: run, status, cancel.
185
+ if (req.method === "POST" && path === "/local-artifact/run") {
186
+ return json(res, 202, runner.localArtifact.start(await readJson(req)));
187
+ }
188
+ if (req.method === "POST" && path === "/local-artifact/status") {
189
+ return json(res, 200, runner.localArtifact.status(await readJson(req)));
190
+ }
191
+ if (req.method === "POST" && path === "/local-artifact/cancel") {
192
+ return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
193
+ }
194
+ // Local application execution (#957) — run, status, resume, cancel; PULL like research.
195
+ if (req.method === "POST" && path === "/local-apply/run") {
196
+ return json(res, 202, runner.localApply.start(await readJson(req)));
197
+ }
198
+ if (req.method === "POST" && path === "/local-apply/status") {
199
+ return json(res, 200, runner.localApply.status(await readJson(req)));
200
+ }
201
+ if (req.method === "POST" && path === "/local-apply/resume") {
202
+ return json(res, 200, runner.localApply.resume(await readJson(req)));
203
+ }
204
+ if (req.method === "POST" && path === "/local-apply/cancel") {
205
+ return json(res, 200, runner.localApply.cancel(await readJson(req)));
206
+ }
180
207
  if (req.method === "POST" && path === "/coding/start") {
181
208
  const b = await readJson(req);
182
209
  return json(res, 200, runner.coding.start(b));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.77",
3
+ "version": "0.4.79",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",