pi-roundtable-coding 0.7.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.
@@ -0,0 +1,477 @@
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ existsSync,
5
+ fstatSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ readSync,
11
+ realpathSync,
12
+ } from "node:fs";
13
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
14
+ import { AgentError, checkRepoName } from "pi-roundtable/kit";
15
+
16
+ /** What a clone would push to its default branch (repos-and-skills spec behavior 10). */
17
+ export interface ChangeReport {
18
+ repo: string;
19
+ /** The default branch the push goes to. */
20
+ branch: string;
21
+ /** HEAD's full SHA, the exact commit to push. */
22
+ sha: string;
23
+ /** `<short sha> <subject>`, newest first. */
24
+ commits: string[];
25
+ diffstat: string;
26
+ /** The credential-free configured push destination shown on the approval card. */
27
+ target: string;
28
+ }
29
+
30
+ /** One managed repository as repo_list shows it (behavior 3). */
31
+ export interface RepoSummary {
32
+ repo: string;
33
+ dir: string;
34
+ branch: string;
35
+ /** Commits ahead of and behind its upstream, as of the last fetch; absent without one. */
36
+ upstream?: { ahead: number; behind: number };
37
+ uncommitted: number;
38
+ /** `<short sha> <subject> (<date>)`, absent in an empty repository. */
39
+ lastCommit?: string;
40
+ summary?: string;
41
+ ciOnPush: boolean;
42
+ /** Set when git could not describe it. */
43
+ error?: string;
44
+ }
45
+
46
+ /** A clone's state, measured around a coding worker's run (behavior 8). */
47
+ export interface RepoState {
48
+ branch: string;
49
+ head: string;
50
+ /** `git status --porcelain` lines. */
51
+ uncommitted: string[];
52
+ }
53
+
54
+ export type CloneCommand = (repo: string, dir: string) => Promise<void>;
55
+
56
+ const GIT_TIMEOUT_MS = 60_000;
57
+ const MAX_STAT_FILES = 40;
58
+ const MAX_SUMMARY_CHARS = 120;
59
+
60
+ async function run(
61
+ command: string[],
62
+ cwd?: string,
63
+ ): Promise<{ out: string; err: string; code: number }> {
64
+ const safeCommand =
65
+ command[0] === "git"
66
+ ? [
67
+ "git",
68
+ "-c",
69
+ "core.fsmonitor=false",
70
+ "-c",
71
+ "core.hooksPath=/dev/null",
72
+ ...command.slice(1),
73
+ ]
74
+ : command;
75
+ const child = Bun.spawn(safeCommand, {
76
+ ...(cwd ? { cwd } : {}),
77
+ stdout: "pipe",
78
+ stderr: "pipe",
79
+ timeout: GIT_TIMEOUT_MS,
80
+ });
81
+ const [out, err, code] = await Promise.all([
82
+ new Response(child.stdout).text(),
83
+ new Response(child.stderr).text(),
84
+ child.exited,
85
+ ]);
86
+ return { out: out.trimEnd(), err: err.trim(), code };
87
+ }
88
+
89
+ /** Clones with gh, which authenticates with the host's GH_TOKEN. */
90
+ export const ghClone: CloneCommand = async (repo, dir) => {
91
+ checkRepoName(repo);
92
+ if (repo.startsWith("-"))
93
+ throw new AgentError("Repository owner cannot start with a dash.");
94
+ const { code } = await run([
95
+ "gh",
96
+ "repo",
97
+ "clone",
98
+ repo,
99
+ dir,
100
+ "--",
101
+ "--quiet",
102
+ ]);
103
+ if (code !== 0)
104
+ throw new AgentError(
105
+ `Cloning ${repo} failed (exit ${code}); check the host's Git login.`,
106
+ );
107
+ };
108
+
109
+ const MAX_METADATA_BYTES = 65_536;
110
+
111
+ /** Metadata discovery never follows links or opens devices/FIFOs; each file is bounded. */
112
+ function metadataText(path: string): string | undefined {
113
+ let fd: number | undefined;
114
+ try {
115
+ if (!lstatSync(path).isFile()) return undefined;
116
+ fd = openSync(
117
+ path,
118
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
119
+ );
120
+ if (!fstatSync(fd).isFile()) return undefined;
121
+ const bytes = Buffer.alloc(MAX_METADATA_BYTES);
122
+ const count = readSync(fd, bytes, 0, bytes.length, 0);
123
+ return bytes.subarray(0, count).toString("utf8");
124
+ } catch {
125
+ return undefined;
126
+ } finally {
127
+ if (fd !== undefined) closeSync(fd);
128
+ }
129
+ }
130
+
131
+ /** The first prose line of a README or AGENTS.md: no frontmatter, heading, badge, or markup. */
132
+ function summaryOf(dir: string): string | undefined {
133
+ for (const name of ["README.md", "AGENTS.md"]) {
134
+ const file = join(dir, name);
135
+ const content = metadataText(file);
136
+ if (!content) continue;
137
+ const text = content.replace(/^---\n[\s\S]*?\n---\n/, "");
138
+ const line = text
139
+ .split("\n")
140
+ .map((l) => l.trim())
141
+ .find((l) => /\p{L}/u.test(l) && !/^([#!<>|`[\-*=]|\d+\.)/.test(l));
142
+ if (line)
143
+ return line.length > MAX_SUMMARY_CHARS
144
+ ? `${line.slice(0, MAX_SUMMARY_CHARS - 1)}…`
145
+ : line;
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ function ciOnPush(dir: string): boolean {
151
+ const workflows = join(dir, ".github", "workflows");
152
+ try {
153
+ if (
154
+ !lstatSync(join(dir, ".github")).isDirectory() ||
155
+ !lstatSync(workflows).isDirectory()
156
+ )
157
+ return false;
158
+ return readdirSync(workflows)
159
+ .filter((name) => /\.ya?ml$/.test(name))
160
+ .some((name) =>
161
+ /^\s*(on:\s*\[?[^\n]*\bpush\b|push\s*:)/m.test(
162
+ metadataText(join(workflows, name)) ?? "",
163
+ ),
164
+ );
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /** The managed repositories under one folder, read and pushed through git. */
171
+ export class RepoShelf {
172
+ readonly dir: string;
173
+ readonly #clone: CloneCommand;
174
+ readonly #reports = new Map<string, ChangeReport>();
175
+ readonly #adding = new Set<string>();
176
+
177
+ constructor(dir: string, clone: CloneCommand = ghClone) {
178
+ this.dir = resolve(dir);
179
+ this.#clone = clone;
180
+ }
181
+
182
+ /** Every clone as `<owner>/<repo>`, sorted. */
183
+ repos(): string[] {
184
+ if (!existsSync(this.dir)) return [];
185
+ // pi-lens-ignore: no-sort-without-comparator — the names are strings, and code-unit order gives the same list on every machine
186
+ return readdirSync(this.dir, { withFileTypes: true })
187
+ .filter((owner) => owner.isDirectory())
188
+ .flatMap((owner) =>
189
+ readdirSync(join(this.dir, owner.name), {
190
+ withFileTypes: true,
191
+ }).flatMap((repo) =>
192
+ repo.isDirectory() &&
193
+ existsSync(join(this.dir, owner.name, repo.name, ".git"))
194
+ ? [`${owner.name}/${repo.name}`]
195
+ : [],
196
+ ),
197
+ )
198
+ .sort();
199
+ }
200
+
201
+ /** A managed repository's folder, or an error naming the managed ones. */
202
+ dirOf(repo: string): string {
203
+ checkRepoName(repo);
204
+ const dir = join(this.dir, repo);
205
+ if (!existsSync(join(dir, ".git")))
206
+ throw new AgentError(
207
+ `${repo} is not a managed repository. Managed: ${this.repos().join(", ") || "none"}; add one with repo_add.`,
208
+ );
209
+ const root = realpathSync(this.dir);
210
+ const path = realpathSync(dir);
211
+ const rel = relative(root, path);
212
+ if (rel.startsWith("..") || isAbsolute(rel))
213
+ throw new AgentError("Repository path escapes the shelf.");
214
+ if (!lstatSync(join(dir, ".git")).isDirectory())
215
+ throw new AgentError(
216
+ "Managed repositories must have a local Git directory, not a linked worktree.",
217
+ );
218
+ const gitRel = relative(path, realpathSync(join(dir, ".git")));
219
+ if (gitRel.startsWith("..") || isAbsolute(gitRel))
220
+ throw new AgentError("Repository Git directory escapes the clone.");
221
+ return path;
222
+ }
223
+
224
+ async list(fetch: boolean): Promise<RepoSummary[]> {
225
+ return Promise.all(this.repos().map((repo) => this.#summary(repo, fetch)));
226
+ }
227
+
228
+ /** Clones a repository into the shelf (behavior 2). */
229
+ async add(repo: string): Promise<string> {
230
+ checkRepoName(repo);
231
+ const dir = join(this.dir, repo);
232
+ if (existsSync(dir) || this.#adding.has(repo))
233
+ throw new AgentError(`${repo} is already present or being cloned.`);
234
+ mkdirSync(this.dir, { recursive: true });
235
+ mkdirSync(dirname(dir), { recursive: true });
236
+ const rel = relative(realpathSync(this.dir), realpathSync(dirname(dir)));
237
+ if (rel.startsWith("..") || isAbsolute(rel))
238
+ throw new AgentError("Repository owner directory escapes the shelf.");
239
+ this.#adding.add(repo);
240
+ try {
241
+ await this.#clone(repo, dir);
242
+ return this.dirOf(repo);
243
+ } finally {
244
+ this.#adding.delete(repo);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Fetches and describes what HEAD adds to the default branch. Refuses a dirty tree, a HEAD
250
+ * with nothing to push, and a HEAD missing commits of the default branch.
251
+ */
252
+ async report(repo: string): Promise<ChangeReport> {
253
+ const dir = this.dirOf(repo);
254
+ this.#reports.delete(repo);
255
+ const target = await this.#pushTarget(dir);
256
+ await this.#git(dir, "fetch", "--quiet", "origin");
257
+ const branch = await this.#defaultBranch(dir);
258
+ const base = `origin/${branch}`;
259
+ const dirty = await this.#git(dir, "status", "--porcelain");
260
+ if (dirty)
261
+ throw new AgentError(
262
+ `${repo} has uncommitted changes; commit or discard them first:\n${dirty}`,
263
+ );
264
+ const behind = Number(
265
+ await this.#git(dir, "rev-list", "--count", `HEAD..${base}`),
266
+ );
267
+ if (behind > 0)
268
+ throw new AgentError(
269
+ `${base} has ${behind} commits HEAD lacks; run \`git -C ${dir} rebase ${base}\`, check again, and report again.`,
270
+ );
271
+ const log = await this.#git(
272
+ dir,
273
+ "log",
274
+ "--format=%h %s",
275
+ "--abbrev=12",
276
+ `${base}..HEAD`,
277
+ );
278
+ if (!log)
279
+ throw new AgentError(`HEAD adds nothing to ${base}; commit first.`);
280
+ const stat = (
281
+ await this.#git(dir, "diff", "--stat=100", `${base}...HEAD`)
282
+ ).split("\n");
283
+ const files = stat.slice(0, -1);
284
+ const diffstat =
285
+ files.length > MAX_STAT_FILES
286
+ ? [
287
+ ...files.slice(0, MAX_STAT_FILES),
288
+ ` … ${files.length - MAX_STAT_FILES} more files`,
289
+ stat.at(-1) ?? "",
290
+ ].join("\n")
291
+ : stat.join("\n");
292
+ const report = {
293
+ repo,
294
+ branch,
295
+ sha: await this.#git(dir, "rev-parse", "HEAD"),
296
+ commits: log.split("\n"),
297
+ diffstat,
298
+ target,
299
+ };
300
+ this.#reports.set(repo, report);
301
+ return report;
302
+ }
303
+
304
+ /** A synchronous hold description from the latest report, safe to show on an owner card. */
305
+ pushDescription(repo: string, sha: string): string {
306
+ const report = this.#reports.get(repo);
307
+ if (!report || report.sha !== sha)
308
+ return `Push ${repo} commit ${sha}; request a fresh change report first`;
309
+ return `Push ${repo} commit ${sha} to ${report.target}, branch ${report.branch}`;
310
+ }
311
+
312
+ /** Pushes exactly HEAD, named by its sha, to the reported destination and default branch. */
313
+ async push(repo: string, sha: string): Promise<string> {
314
+ const dir = this.dirOf(repo);
315
+ const head = await this.#git(dir, "rev-parse", "HEAD");
316
+ const report = this.#reports.get(repo);
317
+ if (!report || report.sha !== sha || head !== sha)
318
+ throw new AgentError(
319
+ `${sha} is not HEAD of ${repo} (HEAD is ${head.slice(0, 12)}); report again with repo_change_report.`,
320
+ );
321
+ if ((await this.#pushTarget(dir)) !== report.target)
322
+ throw new AgentError(
323
+ "The push destination changed; request a new report.",
324
+ );
325
+ const branch = await this.#defaultBranch(dir);
326
+ if (branch !== report.branch)
327
+ throw new AgentError("The default branch changed; request a new report.");
328
+ if (await this.#git(dir, "status", "--porcelain"))
329
+ throw new AgentError("The clone is dirty; request a new report.");
330
+ await this.#git(
331
+ dir,
332
+ "push",
333
+ "--quiet",
334
+ report.target,
335
+ `${head}:refs/heads/${branch}`,
336
+ );
337
+ this.#reports.delete(repo);
338
+ return branch;
339
+ }
340
+
341
+ async state(repo: string): Promise<RepoState> {
342
+ const dir = this.dirOf(repo);
343
+ const status = await this.#git(dir, "status", "--porcelain");
344
+ return {
345
+ branch: await this.#git(dir, "rev-parse", "--abbrev-ref", "HEAD"),
346
+ head: await this.#git(dir, "rev-parse", "--short=12", "HEAD"),
347
+ uncommitted: status ? status.split("\n") : [],
348
+ };
349
+ }
350
+
351
+ /** `<short sha> <subject>` of each commit after `from`, newest first. */
352
+ async commitsSince(repo: string, from: string): Promise<string[]> {
353
+ const log = await this.#git(
354
+ this.dirOf(repo),
355
+ "log",
356
+ "--format=%h %s",
357
+ "--abbrev=12",
358
+ `${from}..HEAD`,
359
+ );
360
+ return log ? log.split("\n") : [];
361
+ }
362
+
363
+ async #summary(repo: string, fetch: boolean): Promise<RepoSummary> {
364
+ const dir = this.dirOf(repo);
365
+ const summary = summaryOf(dir);
366
+ const base = {
367
+ repo,
368
+ dir,
369
+ ciOnPush: ciOnPush(dir),
370
+ ...(summary ? { summary } : {}),
371
+ };
372
+ try {
373
+ if (fetch) await this.#git(dir, "fetch", "--quiet", "origin");
374
+ const branch = await this.#git(dir, "rev-parse", "--abbrev-ref", "HEAD");
375
+ const status = await this.#git(dir, "status", "--porcelain");
376
+ const last = await run([
377
+ "git",
378
+ "-C",
379
+ dir,
380
+ "log",
381
+ "-1",
382
+ "--format=%h %s (%cs)",
383
+ "--abbrev=12",
384
+ ]);
385
+ const counts = await run([
386
+ "git",
387
+ "-C",
388
+ dir,
389
+ "rev-list",
390
+ "--left-right",
391
+ "--count",
392
+ "HEAD...@{upstream}",
393
+ ]);
394
+ const [ahead, behind] = counts.out.split(/\s+/).map(Number);
395
+ return {
396
+ ...base,
397
+ branch,
398
+ uncommitted: status ? status.split("\n").length : 0,
399
+ ...(last.code === 0 && last.out ? { lastCommit: last.out } : {}),
400
+ ...(counts.code === 0
401
+ ? { upstream: { ahead: ahead ?? 0, behind: behind ?? 0 } }
402
+ : {}),
403
+ };
404
+ } catch (error) {
405
+ return {
406
+ ...base,
407
+ branch: "?",
408
+ uncommitted: 0,
409
+ error: error instanceof Error ? error.message : String(error),
410
+ };
411
+ }
412
+ }
413
+
414
+ async #pushTarget(dir: string): Promise<string> {
415
+ const targets = (
416
+ await this.#git(dir, "remote", "get-url", "--push", "--all", "origin")
417
+ ).split("\n");
418
+ const target = targets[0];
419
+ if (!target || targets.length !== 1 || target.startsWith("-"))
420
+ throw new AgentError("origin must have exactly one push destination.");
421
+ if (/^https?:\/\//i.test(target)) {
422
+ let url: URL;
423
+ try {
424
+ url = new URL(target);
425
+ } catch {
426
+ throw new AgentError("origin has an invalid remote URL.");
427
+ }
428
+ if (url.username || url.password || url.search || url.hash)
429
+ throw new AgentError(
430
+ "Use the host's credential helper, not a credential-bearing remote URL.",
431
+ );
432
+ }
433
+ return target;
434
+ }
435
+
436
+ /** Refresh origin/HEAD from the remote, including default-branch changes during approval. */
437
+ async #defaultBranch(dir: string): Promise<string> {
438
+ await this.#git(dir, "remote", "set-head", "origin", "--auto");
439
+ const read = () =>
440
+ run([
441
+ "git",
442
+ "-C",
443
+ dir,
444
+ "symbolic-ref",
445
+ "--short",
446
+ "refs/remotes/origin/HEAD",
447
+ ]);
448
+ const head = await read();
449
+ if (head.code !== 0 || !head.out.startsWith("origin/"))
450
+ throw new AgentError(`${dir} has no default branch on origin.`);
451
+ return head.out.slice("origin/".length);
452
+ }
453
+
454
+ async #git(dir: string, ...args: string[]): Promise<string> {
455
+ const { out, code } = await run(["git", "-C", dir, ...args]);
456
+ if (code !== 0)
457
+ throw new AgentError(
458
+ `git ${args[0]} failed (exit ${code}); check the clone and the host's Git login.`,
459
+ );
460
+ return out;
461
+ }
462
+ }
463
+
464
+ /** The report as posted for the owner. */
465
+ export function reportPost(report: ChangeReport, direct = false): string {
466
+ return [
467
+ `**Change report**: \`${report.repo}\`, commit \`${report.sha}\`, default branch \`${report.branch}\`.`,
468
+ `**Push destination**: \`${report.target}\``,
469
+ `**Commits (${report.commits.length})**`,
470
+ ...report.commits.map((c) => `- \`${c.slice(0, 12)}\`${c.slice(12)}`),
471
+ "**Changed files**",
472
+ `\`\`\`\n${report.diffstat}\n\`\`\``,
473
+ direct
474
+ ? "This repository is explicitly owner-owned; repo_push does not need an approval card."
475
+ : `After approval: repo_push ${report.repo} ${report.sha}`,
476
+ ].join("\n");
477
+ }
@@ -0,0 +1,8 @@
1
+ /** Include these names in a restricted owner claim's tool selection. */
2
+ export const REPO_TOOLS = [
3
+ "repo_list",
4
+ "repo_add",
5
+ "repo_change_report",
6
+ "repo_push",
7
+ "repo_task",
8
+ ] as const;
@@ -0,0 +1,138 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import {
4
+ createAgentSession,
5
+ DefaultResourceLoader,
6
+ ModelRuntime,
7
+ SessionManager,
8
+ SettingsManager,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ activeToolsExtension,
12
+ runWorkerTask,
13
+ SHELL_TOOLS,
14
+ } from "pi-roundtable/kit";
15
+ import type { CodingJob, HeldCallAnswer } from "./coding-desk.ts";
16
+
17
+ interface StartMessage {
18
+ type: "start";
19
+ job: CodingJob & { dir: string };
20
+ packages: string[];
21
+ agentDir: string;
22
+ }
23
+ export function codingWorkerPrompt(dir: string): string {
24
+ return [
25
+ `You are a coding worker in ${dir}. The next message is your approved task. Read this repository's AGENTS.md.`,
26
+ "Work with the shell and file tools available. Complete and verify the contract, stage files by name and commit using repository conventions, without tool attribution.",
27
+ "Do not push or ship. The calling agent reviews your commits and requests the owner's approval through repo_change_report and repo_push.",
28
+ "Risky calls wait for the owner's approval. An approved call runs; a declined or held call must not be retried or worked around.",
29
+ "Resolve decisions settled by the task or repository. Put remaining owner decisions and held actions in your report instead of guessing.",
30
+ "Report changes and reasons, commits, checks and outcomes, held actions, and remaining work.",
31
+ ].join("\n\n");
32
+ }
33
+ const answers = new Map<number, (answer: HeldCallAnswer) => void>();
34
+ let nextId = 1;
35
+ let started = false;
36
+ async function run({ job, agentDir, packages }: StartMessage): Promise<string> {
37
+ const modelRuntime = await ModelRuntime.create({
38
+ authPath: join(agentDir, "auth.json"),
39
+ modelsPath: join(agentDir, "models.json"),
40
+ allowModelNetwork: false,
41
+ refreshOnCreate: false,
42
+ });
43
+ const loader = new DefaultResourceLoader({
44
+ cwd: job.dir,
45
+ agentDir,
46
+ noExtensions: true,
47
+ additionalExtensionPaths: packages,
48
+ noSkills: true,
49
+ additionalSkillPaths: job.skillFiles,
50
+ noPromptTemplates: true,
51
+ noThemes: true,
52
+ agentsFilesOverride: ({ agentsFiles }) => ({
53
+ agentsFiles: agentsFiles.filter((file) => {
54
+ try {
55
+ if (!lstatSync(file.path).isFile()) return false;
56
+ const path = relative(realpathSync(job.dir), realpathSync(file.path));
57
+ return !path.startsWith("..") && !isAbsolute(path);
58
+ } catch {
59
+ return false;
60
+ }
61
+ }),
62
+ }),
63
+ extensionFactories: [
64
+ {
65
+ name: "coding-shell",
66
+ factory: activeToolsExtension(() => SHELL_TOOLS),
67
+ },
68
+ {
69
+ name: "coding-approvals",
70
+ factory: (pi) => {
71
+ pi.on("tool_call", async (event) => {
72
+ const id = nextId++;
73
+ const answer = await new Promise<HeldCallAnswer>((resolve) => {
74
+ answers.set(id, resolve);
75
+ process.send?.({
76
+ type: "call",
77
+ id,
78
+ tool: event.toolName,
79
+ input: event.input,
80
+ });
81
+ });
82
+ if (answer !== "approved")
83
+ return {
84
+ block: true,
85
+ reason: `The owner ${answer === "declined" ? "declined" : "has not approved"} this call. Do not retry it or work around it; list it under Held in your report.`,
86
+ };
87
+ return undefined;
88
+ });
89
+ },
90
+ },
91
+ ],
92
+ appendSystemPrompt: [codingWorkerPrompt(job.dir)],
93
+ });
94
+ await loader.reload();
95
+ const { session } = await createAgentSession({
96
+ cwd: job.dir,
97
+ agentDir,
98
+ modelRuntime,
99
+ thinkingLevel: job.thinking,
100
+ resourceLoader: loader,
101
+ sessionManager: SessionManager.inMemory(job.dir),
102
+ settingsManager: SettingsManager.inMemory({}),
103
+ });
104
+ return runWorkerTask(session, {
105
+ modelRuntime,
106
+ model: job.model,
107
+ task: job.task,
108
+ signal: new AbortController().signal,
109
+ aborted: "The worker was stopped.",
110
+ });
111
+ }
112
+ if (process.send) {
113
+ process.on("message", (message: unknown) => {
114
+ if (typeof message !== "object" || message === null || !("type" in message))
115
+ return;
116
+ if (
117
+ message.type === "answer" &&
118
+ "id" in message &&
119
+ typeof message.id === "number" &&
120
+ "answer" in message &&
121
+ (message.answer === "approved" ||
122
+ message.answer === "declined" ||
123
+ message.answer === "held")
124
+ ) {
125
+ answers.get(message.id)?.(message.answer);
126
+ answers.delete(message.id);
127
+ } else if (message.type === "start" && !started) {
128
+ started = true;
129
+ void run(message as StartMessage).then(
130
+ (report) => {
131
+ process.send?.({ type: "report", report }, () => process.exit(0));
132
+ },
133
+ () => process.exit(1),
134
+ );
135
+ }
136
+ });
137
+ process.send({ type: "ready" });
138
+ }
@@ -0,0 +1,16 @@
1
+ import { AgentError } from "pi-roundtable/kit";
2
+
3
+ /** Structured diagnostics never carry provider responses, stderr or credentials. */
4
+ export class CodingWorkerFailure extends AgentError {
5
+ constructor(
6
+ readonly category: "stopped" | "exit" | "missing-report",
7
+ readonly exitCode?: number,
8
+ ) {
9
+ let message = "The coding worker was stopped.";
10
+ if (category === "exit")
11
+ message = `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
12
+ if (category === "missing-report")
13
+ message = "The coding worker returned no report.";
14
+ super(message);
15
+ }
16
+ }