pi-roundtable-coding 0.7.3 → 0.7.5

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.5] - 2026-10-03
6
+
7
+ - Add `workerBlockText`, `limits` and `diagnosticChars`; git, gh and worker error text and the report and held-action bounds can be raised or lifted by the host.
8
+
9
+ ## [0.7.4] - 2026-10-03
10
+
11
+ - Add `toolText` and `presentation.list`, accept a 7–40 character sha prefix in `repo_push`, name git and gh stderr (scrubbed) in failures, carry a worker's own error text into the report, restore the earlier timeout, stop and refusal wording, stop listing declined calls as held, and report the channels the service works for.
12
+
5
13
  ## [0.7.3] - 2026-10-03
6
14
 
7
15
  - Hook caller model/thinking/origin/channel, owner policy, clone adoption, threaded approvals and report formatting through options, and add `pushHoldText` for the held `repo_push` card.
package/README.md CHANGED
@@ -121,7 +121,7 @@ Without `threads`, the resolved report channel's prompts are used.
121
121
  `timeoutMs` must be positive and finite, and at most 2,147,483,647 ms to fit the host timer.
122
122
  An approved call runs; a declined, expired, missing or failed card blocks it and instructs the worker not to retry or work around the refusal.
123
123
  Unapproved actions appear in the report rather than running later automatically.
124
- Worker answers are capped at 20,000 characters; the Held list keeps ten entries of at most 1,000 characters each, with explicit truncation and omission notices.
124
+ By default worker answers are capped at 20,000 characters and the Held list keeps ten entries of at most 1,000 characters each, with explicit truncation and omission notices; `limits` changes each bound.
125
125
  Worker failures expose only a structured exit category and numeric code, never stderr or provider diagnostics.
126
126
  Card expiration is determined by the host's surface implementation, not this package.
127
127
 
@@ -166,11 +166,15 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
166
166
  | `adoptClones` | `[]` | Startup `{ from, repo }` moves of standalone clones; existing shelf destinations are never replaced. |
167
167
  | `resolveRun` | configured model/thinking, caller channel | Per-caller model, thinking, report channel and optional thread origin. |
168
168
  | `postChangeReport` | caller surface reply | Post the record using the calling identity and channel. |
169
- | `presentation` | English package text | Separate change-report post/result text and task-start/omitted-skill wording. |
169
+ | `presentation` | English package text | Separate change-report post/result text, task-start/omitted-skill wording, and the `repo_list` result (`list(repos, { shelfDir, fetched })`). |
170
+ | `toolText` | package wording | Trusted description and argument descriptions for each repository tool, as `{ repo_task: { description, parameters: { task: "…" } } }`; it never changes a tool's arguments or approval rules. |
170
171
  | `threads` | none | Public `DispatchThreads`-compatible progress and approval thread port. |
171
172
  | `threadText` | English package text | Thread introduction, held-action notice, approval title and final report. |
172
173
  | `workerWorkspace` | individual clone | Trusted shell-policy write boundary; not an OS sandbox. |
173
174
  | `workerPrompt` | generic worker instructions | Trusted standing prompt replacement; it cannot bypass approval or cleanup. |
175
+ | `workerBlockText` | "The owner declined / has not approved this call. Do not retry it or work around it; list it under Held in your report." | Trusted wording of what the worker reads when a call is declined or held, from `(answer, action)`; it never changes who is held, and the desk lists only unanswered calls as held. |
176
+ | `limits` | 20,000 report characters, 10 held entries of 1,000 characters | `{ reportChars?, heldEntries?, heldChars? }`: what a report keeps of a long run; each is a whole number of at least 1, or `Infinity` for no bound. |
177
+ | `diagnosticChars` | `600` | Longest git, gh and worker error text kept in a failure; a whole number of at least 1, or `Infinity`. |
174
178
  | `skipUnavailableCarriedSkills` | `false` | Skip and disclose unavailable implicit skills; explicit skill requests still refuse them. |
175
179
  | `workerPackages` | `[]` | Absolute installed extension paths. |
176
180
  | `agentDir` | Pi host directory | Pi credentials and model configuration. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-coding",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Repository shelves and owner-approved Pi coding workers for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "@earendil-works/pi-ai": ">=1.0.0 <2",
45
45
  "@biomejs/biome": "2.5.15",
46
46
  "@types/bun": "1.4.2",
47
- "pi-roundtable": "0.7.3",
47
+ "pi-roundtable": "0.7.5",
48
48
  "typescript": "7.0.2"
49
49
  }
50
50
  }
@@ -64,6 +64,26 @@ export interface CodingDeskOptions {
64
64
  deliver(result: CodingResult): Promise<void>;
65
65
  logger: Logger;
66
66
  timeoutMs?: number;
67
+ limits?: CodingLimits;
68
+ }
69
+ /** What a coding report keeps of a long run; each is a count of characters or entries, or `Infinity` for no bound. */
70
+ export interface CodingLimits {
71
+ /** Longest worker report, and longest thread report; default 20,000 characters. */
72
+ reportChars?: number;
73
+ /** Held actions listed in the report, the rest counted; default 10. */
74
+ heldEntries?: number;
75
+ /** Longest held-action entry; default 1,000 characters. */
76
+ heldChars?: number;
77
+ }
78
+ export function checkLimit(name: string, value: number | undefined): void {
79
+ if (
80
+ value !== undefined &&
81
+ value !== Number.POSITIVE_INFINITY &&
82
+ (!Number.isSafeInteger(value) || value < 1)
83
+ )
84
+ throw new Error(
85
+ `${name} must be a whole number of at least 1, or Infinity; got ${value}.`,
86
+ );
67
87
  }
68
88
  export const MAX_CODING_TASK_CHARS = 8_000;
69
89
  const MAX_REPORT_CHARS = 20_000;
@@ -121,18 +141,26 @@ export class CodingDesk {
121
141
  ): Promise<CodingJob> {
122
142
  if (this.#stopped) throw new AgentError("The coding desk is stopped.");
123
143
  const task = request.task.trim();
124
- if (!task || task.length > MAX_CODING_TASK_CHARS)
144
+ if (!task) throw new AgentError("The task is required.");
145
+ if (task.length > MAX_CODING_TASK_CHARS)
125
146
  throw new AgentError(
126
- `The task must contain 1–${MAX_CODING_TASK_CHARS} characters.`,
147
+ `The task is ${task.length} characters; keep it within ${MAX_CODING_TASK_CHARS}.`,
127
148
  );
128
149
  const dir = this.#options.shelf.dirOf(request.repo);
129
- this.checkIdle(request.repo);
130
- if (
131
- [...this.#running.values()].filter(
132
- ({ job }) => job.channel === request.channel,
133
- ).length >= 3
134
- )
135
- throw new AgentError("This channel already has three coding workers.");
150
+ const busy = [...this.#running.values()].find(
151
+ ({ job }) => job.repo === request.repo,
152
+ );
153
+ if (busy)
154
+ throw new AgentError(
155
+ `Coding task #${busy.job.id} is still working in ${request.repo}; one worker runs per repository.`,
156
+ );
157
+ const inChannel = [...this.#running.values()].filter(
158
+ ({ job }) => job.channel === request.channel,
159
+ ).length;
160
+ if (inChannel >= 3)
161
+ throw new AgentError(
162
+ `This channel already has ${inChannel} coding workers running; wait for one to report back.`,
163
+ );
136
164
  const job: CodingJob = {
137
165
  ...request,
138
166
  task,
@@ -174,13 +202,17 @@ export class CodingDesk {
174
202
  const slot = promptSlot();
175
203
  const held: string[] = [];
176
204
  let omittedHeld = 0;
205
+ const maxReport = this.#options.limits?.reportChars ?? MAX_REPORT_CHARS;
206
+ const maxHeldEntries =
207
+ this.#options.limits?.heldEntries ?? MAX_HELD_ENTRIES;
208
+ const maxHeldChars = this.#options.limits?.heldChars ?? MAX_HELD_CHARS;
177
209
  let cancel = () => {};
178
210
  let timedOut = false;
179
211
  let outcome: CodingResult["outcome"];
180
212
  try {
181
213
  job.startHead = (await shelf.state(job.repo)).head;
182
214
  if (controller.signal.aborted)
183
- throw new AgentError("The worker was stopped.");
215
+ throw new AgentError("the worker was stopped");
184
216
  if (threads) {
185
217
  try {
186
218
  const thread = await threads.open(
@@ -197,7 +229,7 @@ export class CodingDesk {
197
229
  }
198
230
  }
199
231
  if (controller.signal.aborted)
200
- throw new AgentError("The worker was stopped.");
232
+ throw new AgentError("the worker was stopped");
201
233
  slot.bind(
202
234
  threads
203
235
  ? job.thread && prompts?.(job.thread.channel)
@@ -222,11 +254,12 @@ export class CodingDesk {
222
254
  } catch {
223
255
  /* A failed card never authorizes the call. */
224
256
  }
225
- if (answer !== "approved") {
226
- if (held.length < MAX_HELD_ENTRIES) {
257
+ // A call the owner declined is settled, not held for the report.
258
+ if (answer === "held") {
259
+ if (held.length < maxHeldEntries) {
227
260
  const entry = bounded(
228
- `${answer}: ${call.action}: ${call.tool} ${call.input}`,
229
- MAX_HELD_CHARS,
261
+ `${call.action}: ${call.tool} ${call.input}`,
262
+ maxHeldChars,
230
263
  );
231
264
  held.push(entry);
232
265
  try {
@@ -246,22 +279,22 @@ export class CodingDesk {
246
279
  review,
247
280
  );
248
281
  if (controller.signal.aborted)
249
- throw new AgentError("The worker was stopped.");
250
- outcome = { ok: true, report: bounded(report, MAX_REPORT_CHARS) };
282
+ throw new AgentError("the worker was stopped");
283
+ outcome = { ok: true, report: bounded(report, maxReport) };
251
284
  } catch (error) {
252
- // Only structured, fixed diagnostics can cross the worker boundary.
285
+ // The host's own refusals and the worker's scrubbed, bounded error text reach the report.
253
286
  let message =
254
287
  "The worker failed; inspect the clone and worker configuration on the host.";
288
+ if (error instanceof AgentError) message = error.message;
255
289
  if (error instanceof CodingWorkerFailure) {
256
- message = error.message;
257
290
  this.#options.logger.warn(
258
291
  { job: job.id, category: error.category, exitCode: error.exitCode },
259
292
  "Coding worker failed.",
260
293
  );
261
294
  }
262
- if (controller.signal.aborted) message = "The worker was stopped.";
295
+ if (controller.signal.aborted) message = "the worker was stopped";
263
296
  if (timedOut)
264
- message = `Work timeout after ${timeoutMs} ms (owner wait excluded).`;
297
+ message = `the worker ran out of time (${Math.round(timeoutMs / 60_000)} minutes)`;
265
298
  outcome = { ok: false, error: message };
266
299
  } finally {
267
300
  cancel();
@@ -293,7 +326,7 @@ export class CodingDesk {
293
326
  bounded(
294
327
  threadText?.report?.(result) ??
295
328
  codingReport(result, job.startedAt.toISOString()),
296
- MAX_REPORT_CHARS,
329
+ maxReport,
297
330
  ),
298
331
  );
299
332
  } catch {
@@ -21,9 +21,11 @@ import { Type } from "typebox";
21
21
  import {
22
22
  CodingDesk,
23
23
  type CodingJob,
24
+ type CodingLimits,
24
25
  type CodingResult,
25
26
  type CodingThreadText,
26
27
  type CodingWorker,
28
+ checkLimit,
27
29
  codingReport,
28
30
  MAX_CODING_TASK_CHARS,
29
31
  } from "./coding-desk.ts";
@@ -32,6 +34,7 @@ import {
32
34
  type ChangeReport,
33
35
  type CloneCommand,
34
36
  RepoShelf,
37
+ type RepoSummary,
35
38
  reportPost,
36
39
  } from "./repo-shelf.ts";
37
40
 
@@ -46,7 +49,24 @@ export interface CodingRun {
46
49
  channel: ChannelKey;
47
50
  origin?: ChannelKey;
48
51
  }
52
+ export type RepoToolName =
53
+ | "repo_list"
54
+ | "repo_add"
55
+ | "repo_change_report"
56
+ | "repo_push"
57
+ | "repo_task";
58
+ /** Trusted wording of one repository tool, as the model reads it; unset fields keep the defaults. */
59
+ export interface CodingToolText {
60
+ description?: string;
61
+ /** Argument descriptions by argument name. */
62
+ parameters?: Record<string, string>;
63
+ }
49
64
  export interface CodingPresentation {
65
+ /** The repo_list result; unset: the managed clones as JSON. */
66
+ list?(
67
+ repos: (RepoSummary & { skills: string[] })[],
68
+ context: { shelfDir: string; fetched: boolean },
69
+ ): string;
50
70
  /** Separate the owner's posted record from the model's shipping instructions. */
51
71
  changeReport?(
52
72
  report: ChangeReport,
@@ -73,10 +93,18 @@ export interface CodingOptions {
73
93
  /** Post change reports in the caller's identity/channel rather than the default surface. */
74
94
  postChangeReport?: (turn: ToolTurn, text: string) => Promise<void>;
75
95
  presentation?: CodingPresentation;
96
+ /** Trusted wording of the repository tools' descriptions and arguments. */
97
+ toolText?: Partial<Record<RepoToolName, CodingToolText>>;
76
98
  threads?: Pick<DispatchThreads, "open">;
77
99
  threadText?: CodingThreadText;
78
100
  workerWorkspace?: string;
79
101
  workerPrompt?: (dir: string) => string;
102
+ /** How much of a long run a report keeps; see `CodingLimits`. */
103
+ limits?: CodingLimits;
104
+ /** Longest git, gh and worker error text kept in a failure, in characters; default 600. */
105
+ diagnosticChars?: number;
106
+ /** Trusted wording of what the worker reads when a call is declined or held; see `PiCodingWorkerOptions.blockText`. */
107
+ workerBlockText?: (answer: "declined" | "held", action: string) => string;
80
108
  /** Opt in to skipping unavailable implicit skills; explicit requests always fail closed. */
81
109
  skipUnavailableCarriedSkills?: boolean;
82
110
  /** Absolute installed extension package paths. Default: none. */
@@ -106,6 +134,16 @@ export function coding(options: CodingOptions) {
106
134
  if (!options.shelfDir.trim()) throw new PluginError("shelfDir is required.");
107
135
  if (!/^[^/\s]+\/\S+$/.test(options.model))
108
136
  throw new PluginError("model must be provider/model-id.");
137
+ try {
138
+ checkLimit("limits.reportChars", options.limits?.reportChars);
139
+ checkLimit("limits.heldEntries", options.limits?.heldEntries);
140
+ checkLimit("limits.heldChars", options.limits?.heldChars);
141
+ checkLimit("diagnosticChars", options.diagnosticChars);
142
+ } catch (error) {
143
+ throw new PluginError(
144
+ error instanceof Error ? error.message : String(error),
145
+ );
146
+ }
109
147
  const ownerRepos = new Set(options.ownerRepos ?? []);
110
148
  for (const repo of ownerRepos) checkRepoName(repo);
111
149
  const ownerOwned = (repo: string): boolean => {
@@ -116,7 +154,9 @@ export function coding(options: CodingOptions) {
116
154
  name: "coding",
117
155
  provides: [CODING],
118
156
  setup(context) {
119
- const shelf = new RepoShelf(options.shelfDir, options.clone);
157
+ const shelf = new RepoShelf(options.shelfDir, options.clone, {
158
+ diagnosticChars: options.diagnosticChars,
159
+ });
120
160
  for (const adoption of options.adoptClones ?? [])
121
161
  shelf.adopt(adoption.from, adoption.repo);
122
162
  const skills = context.services.find(SKILLS);
@@ -127,6 +167,8 @@ export function coding(options: CodingOptions) {
127
167
  agentDir: options.agentDir,
128
168
  workspace: options.workerWorkspace,
129
169
  prompt: options.workerPrompt,
170
+ blockText: options.workerBlockText,
171
+ diagnosticChars: options.diagnosticChars,
130
172
  holds: (tool, input, scope) =>
131
173
  options.holds?.(tool, input, scope) ??
132
174
  context.sessions().holds(tool, input, scope),
@@ -135,6 +177,7 @@ export function coding(options: CodingOptions) {
135
177
  shelf,
136
178
  worker,
137
179
  timeoutMs: options.timeoutMs,
180
+ limits: options.limits,
138
181
  logger: context.logger,
139
182
  threads: options.threads,
140
183
  threadText: options.threadText,
@@ -170,14 +213,21 @@ export function coding(options: CodingOptions) {
170
213
  }
171
214
  }
172
215
  context.services.provide(CODING, { shelf, desk });
173
- const repoSchema = Type.String({
174
- description: "A managed repository, owner/repo.",
175
- });
216
+ const textOf = (tool: RepoToolName) => options.toolText?.[tool];
217
+ const describe = (tool: RepoToolName, fallback: string) =>
218
+ textOf(tool)?.description ?? fallback;
219
+ const about = (tool: RepoToolName, name: string, fallback?: string) => {
220
+ const description = textOf(tool)?.parameters?.[name] ?? fallback;
221
+ return description ? { description } : {};
222
+ };
223
+ const repoSchema = (tool: RepoToolName) =>
224
+ Type.String(about(tool, "repo", "A managed repository, owner/repo."));
176
225
  return {
177
226
  services: [
178
227
  {
179
228
  name: "coding-desk",
180
- busy: () => desk.busy(),
229
+ // The host names the channels whose work a shutdown waits for or aborts.
230
+ busy: () => desk.runningChannels(),
181
231
  stop: () => desk.stop(),
182
232
  },
183
233
  ],
@@ -200,30 +250,39 @@ export function coding(options: CodingOptions) {
200
250
  defineTool({
201
251
  name: "repo_list",
202
252
  minTier: "owner",
203
- description:
253
+ description: describe(
254
+ "repo_list",
204
255
  "List managed clones, branch, upstream counts, dirty files, last commit, summary, CI hints and linked skills. Fetch first only when requested.",
205
- parameters: Type.Object({ fetch: Type.Optional(Type.Boolean()) }),
256
+ ),
257
+ parameters: Type.Object({
258
+ fetch: Type.Optional(Type.Boolean(about("repo_list", "fetch"))),
259
+ }),
206
260
  run: ({ fetch }) =>
207
261
  refusal(async () => {
208
- const repos = await shelf.list(fetch === true);
262
+ const repos = (await shelf.list(fetch === true)).map(
263
+ (repo) => ({
264
+ ...repo,
265
+ skills: skills?.linkedFrom(repo.repo) ?? [],
266
+ }),
267
+ );
268
+ if (options.presentation?.list)
269
+ return options.presentation.list(repos, {
270
+ shelfDir: shelf.dir,
271
+ fetched: fetch === true,
272
+ });
209
273
  return repos.length
210
- ? JSON.stringify(
211
- repos.map((repo) => ({
212
- ...repo,
213
- skills: skills?.linkedFrom(repo.repo) ?? [],
214
- })),
215
- null,
216
- 2,
217
- )
274
+ ? JSON.stringify(repos, null, 2)
218
275
  : `No managed repositories in ${shelf.dir}; use repo_add.`;
219
276
  }),
220
277
  }),
221
278
  defineTool({
222
279
  name: "repo_add",
223
280
  minTier: "owner",
224
- description:
281
+ description: describe(
282
+ "repo_add",
225
283
  "Clone owner/repo into the shelf using the host's Git login (GitHub CLI by default).",
226
- parameters: Type.Object({ repo: repoSchema }),
284
+ ),
285
+ parameters: Type.Object({ repo: repoSchema("repo_add") }),
227
286
  run: ({ repo }) =>
228
287
  refusal(
229
288
  async () => `Cloned ${repo} into ${await shelf.add(repo)}.`,
@@ -232,9 +291,13 @@ export function coding(options: CodingOptions) {
232
291
  defineTool({
233
292
  name: "repo_change_report",
234
293
  minTier: "owner",
235
- description:
294
+ description: describe(
295
+ "repo_change_report",
236
296
  "Fetch and report commits and changed files for the default branch. Requires a clean, ahead-only clone. Review checks before requesting this report.",
237
- parameters: Type.Object({ repo: repoSchema }),
297
+ ),
298
+ parameters: Type.Object({
299
+ repo: repoSchema("repo_change_report"),
300
+ }),
238
301
  run: ({ repo }, turn) =>
239
302
  refusal(() =>
240
303
  ship(repo, async () => {
@@ -260,11 +323,16 @@ export function coding(options: CodingOptions) {
260
323
  defineTool({
261
324
  name: "repo_push",
262
325
  minTier: "owner",
263
- description:
264
- "Push the exact full SHA from the latest change report to its default branch, without force. Held for owner approval unless the trusted host policy explicitly marks the clone owner-owned.",
326
+ description: describe(
327
+ "repo_push",
328
+ "Push the SHA from the latest change report to its default branch, without force. Held for owner approval unless the trusted host policy explicitly marks the clone owner-owned.",
329
+ ),
265
330
  parameters: Type.Object({
266
- repo: repoSchema,
267
- sha: Type.String({ pattern: "^[0-9a-f]{40}$" }),
331
+ repo: repoSchema("repo_push"),
332
+ sha: Type.String({
333
+ pattern: "^[0-9a-f]{7,40}$",
334
+ ...about("repo_push", "sha"),
335
+ }),
268
336
  }),
269
337
  hold: ({ repo, sha }) =>
270
338
  ownerOwned(repo)
@@ -283,15 +351,20 @@ export function coding(options: CodingOptions) {
283
351
  defineTool({
284
352
  name: "repo_task",
285
353
  minTier: "owner",
286
- description:
354
+ description: describe(
355
+ "repo_task",
287
356
  "Start one background Pi coding worker in a clone. The worker reads repository instructions, edits, checks and commits; shipping uses a separate report and approval. Report returns to this channel.",
357
+ ),
288
358
  parameters: Type.Object({
289
- repo: repoSchema,
359
+ repo: repoSchema("repo_task"),
290
360
  task: Type.String({
291
361
  minLength: 1,
292
362
  maxLength: MAX_CODING_TASK_CHARS,
363
+ ...about("repo_task", "task"),
293
364
  }),
294
- skills: Type.Optional(Type.Array(Type.String())),
365
+ skills: Type.Optional(
366
+ Type.Array(Type.String(), about("repo_task", "skills")),
367
+ ),
295
368
  }),
296
369
  run: ({ repo, task, skills: names }, turn) =>
297
370
  refusal(async () => {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type {
2
2
  CodingDeskOptions,
3
3
  CodingJob,
4
+ CodingLimits,
4
5
  CodingResult,
5
6
  CodingThreadText,
6
7
  CodingWorker,
@@ -12,6 +13,8 @@ export type {
12
13
  CodingPresentation,
13
14
  CodingRun,
14
15
  CodingService,
16
+ CodingToolText,
17
+ RepoToolName,
15
18
  } from "./coding-plugin.ts";
16
19
  export { CODING, coding } from "./coding-plugin.ts";
17
20
  export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
@@ -16,6 +16,13 @@ export interface PiCodingWorkerOptions {
16
16
  workspace?: string;
17
17
  /** Trusted host standing prompt, replacing the generic worker instructions. */
18
18
  prompt?: (dir: string) => string;
19
+ /**
20
+ * Trusted wording of what the worker reads when a call is not approved, given what the call
21
+ * would do. Default: the owner declined or has not approved it; list it under Held in the report.
22
+ */
23
+ blockText?: (answer: "declined" | "held", action: string) => string;
24
+ /** Longest worker error text kept for the report, in characters; default 600. */
25
+ diagnosticChars?: number;
19
26
  }
20
27
  interface WorkerMessage {
21
28
  type: string;
@@ -23,6 +30,7 @@ interface WorkerMessage {
23
30
  tool?: string;
24
31
  input?: Record<string, unknown>;
25
32
  report?: string;
33
+ message?: string;
26
34
  }
27
35
  function isMessage(value: unknown): value is WorkerMessage {
28
36
  return (
@@ -46,9 +54,10 @@ export class PiCodingWorker implements CodingWorker {
46
54
  ): Promise<string> {
47
55
  if (process.platform === "win32")
48
56
  throw new AgentError("Coding workers require a POSIX host.");
49
- if (signal.aborted) throw new AgentError("The worker was stopped.");
57
+ if (signal.aborted) throw new AgentError("the worker was stopped");
50
58
  const prompt = this.#options.prompt?.(job.dir);
51
59
  let report: string | undefined;
60
+ let failure: string | undefined;
52
61
  const child = Bun.spawn(
53
62
  [
54
63
  process.execPath,
@@ -76,6 +85,16 @@ export class PiCodingWorker implements CodingWorker {
76
85
  typeof value.report === "string"
77
86
  ) {
78
87
  report = value.report;
88
+ } else if (
89
+ value.type === "failure" &&
90
+ typeof value.message === "string"
91
+ ) {
92
+ failure = value.message.slice(
93
+ 0,
94
+ this.#options.diagnosticChars === undefined
95
+ ? 2_000
96
+ : this.#options.diagnosticChars * 4,
97
+ );
79
98
  } else if (
80
99
  value.type === "call" &&
81
100
  Number.isSafeInteger(value.id) &&
@@ -86,6 +105,7 @@ export class PiCodingWorker implements CodingWorker {
86
105
  const { id, tool, input } = value;
87
106
  void (async () => {
88
107
  let answer: HeldCallAnswer = "held";
108
+ let reason: string | undefined;
89
109
  try {
90
110
  const context = {
91
111
  workspace: this.#options.workspace ?? job.dir,
@@ -101,12 +121,19 @@ export class PiCodingWorker implements CodingWorker {
101
121
  action,
102
122
  })
103
123
  : "approved";
124
+ if (action && answer !== "approved")
125
+ reason = this.#options.blockText?.(answer, action);
104
126
  }
105
127
  } catch {
106
128
  /* Fail closed. */
107
129
  }
108
130
  if (!signal.aborted && child.exitCode === null)
109
- proc.send({ type: "answer", id, answer });
131
+ proc.send({
132
+ type: "answer",
133
+ id,
134
+ answer,
135
+ ...(reason ? { reason } : {}),
136
+ });
110
137
  })();
111
138
  }
112
139
  },
@@ -124,7 +151,13 @@ export class PiCodingWorker implements CodingWorker {
124
151
  if (signal.aborted) kill();
125
152
  const code = await child.exited;
126
153
  if (signal.aborted) throw new CodingWorkerFailure("stopped");
127
- if (code !== 0) throw new CodingWorkerFailure("exit", code);
154
+ if (code !== 0)
155
+ throw new CodingWorkerFailure(
156
+ "exit",
157
+ code,
158
+ failure,
159
+ this.#options.diagnosticChars,
160
+ );
128
161
  if (!report?.trim()) throw new CodingWorkerFailure("missing-report");
129
162
  return report;
130
163
  } finally {
package/src/repo-shelf.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  renameSync,
13
13
  } from "node:fs";
14
14
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
15
- import { AgentError, checkRepoName } from "pi-roundtable/kit";
15
+ import { AgentError, checkRepoName, scrubDiagnostic } from "pi-roundtable/kit";
16
16
 
17
17
  /** What a clone would push to its default branch (repos-and-skills spec behavior 10). */
18
18
  export interface ChangeReport {
@@ -87,25 +87,28 @@ async function run(
87
87
  return { out: out.trimEnd(), err: err.trim(), code };
88
88
  }
89
89
 
90
- /** Clones with gh, which authenticates with the host's GH_TOKEN. */
91
- export const ghClone: CloneCommand = async (repo, dir) => {
92
- checkRepoName(repo);
93
- if (repo.startsWith("-"))
94
- throw new AgentError("Repository owner cannot start with a dash.");
95
- const { code } = await run([
96
- "gh",
97
- "repo",
98
- "clone",
99
- repo,
100
- dir,
101
- "--",
102
- "--quiet",
103
- ]);
104
- if (code !== 0)
105
- throw new AgentError(
106
- `Cloning ${repo} failed (exit ${code}); check the host's Git login.`,
107
- );
108
- };
90
+ /** Clones with gh, which authenticates with the host's GH_TOKEN; a failure's stderr is cut at `diagnosticChars`. */
91
+ const ghCloneWith =
92
+ (diagnosticChars?: number): CloneCommand =>
93
+ async (repo, dir) => {
94
+ checkRepoName(repo);
95
+ if (repo.startsWith("-"))
96
+ throw new AgentError("Repository owner cannot start with a dash.");
97
+ const { err, code } = await run([
98
+ "gh",
99
+ "repo",
100
+ "clone",
101
+ repo,
102
+ dir,
103
+ "--",
104
+ "--quiet",
105
+ ]);
106
+ if (code !== 0)
107
+ throw new AgentError(
108
+ `Cloning ${repo} failed: ${scrubDiagnostic(err, diagnosticChars) || `exit ${code}`}`,
109
+ );
110
+ };
111
+ export const ghClone: CloneCommand = ghCloneWith();
109
112
 
110
113
  const MAX_METADATA_BYTES = 65_536;
111
114
 
@@ -175,9 +178,16 @@ export class RepoShelf {
175
178
  readonly #reports = new Map<string, ChangeReport>();
176
179
  readonly #adding = new Set<string>();
177
180
 
178
- constructor(dir: string, clone: CloneCommand = ghClone) {
181
+ readonly #diagnosticChars: number | undefined;
182
+
183
+ constructor(
184
+ dir: string,
185
+ clone?: CloneCommand,
186
+ options: { diagnosticChars?: number | undefined } = {},
187
+ ) {
179
188
  this.dir = resolve(dir);
180
- this.#clone = clone;
189
+ this.#diagnosticChars = options.diagnosticChars;
190
+ this.#clone = clone ?? ghCloneWith(options.diagnosticChars);
181
191
  }
182
192
 
183
193
  /** Adopt an existing standalone clone at startup; an existing destination is never replaced. */
@@ -328,7 +338,7 @@ export class RepoShelf {
328
338
  /** A synchronous hold description from the latest report, safe to show on an owner card. */
329
339
  pushDescription(repo: string, sha: string): string {
330
340
  const report = this.#reports.get(repo);
331
- if (!report || report.sha !== sha)
341
+ if (!report || !/^[0-9a-f]{7,40}$/.test(sha) || !report.sha.startsWith(sha))
332
342
  return `Push ${repo} commit ${sha}; request a fresh change report first`;
333
343
  return `Push ${repo} commit ${sha} to ${report.target}, branch ${report.branch}`;
334
344
  }
@@ -338,7 +348,11 @@ export class RepoShelf {
338
348
  const dir = this.dirOf(repo);
339
349
  const head = await this.#git(dir, "rev-parse", "HEAD");
340
350
  const report = this.#reports.get(repo);
341
- if (!report || report.sha !== sha || head !== sha)
351
+ if (
352
+ !/^[0-9a-f]{7,40}$/.test(sha) ||
353
+ !report?.sha.startsWith(sha) ||
354
+ head !== report.sha
355
+ )
342
356
  throw new AgentError(
343
357
  `${sha} is not HEAD of ${repo} (HEAD is ${head.slice(0, 12)}); report again with repo_change_report.`,
344
358
  );
@@ -476,10 +490,10 @@ export class RepoShelf {
476
490
  }
477
491
 
478
492
  async #git(dir: string, ...args: string[]): Promise<string> {
479
- const { out, code } = await run(["git", "-C", dir, ...args]);
493
+ const { out, err, code } = await run(["git", "-C", dir, ...args]);
480
494
  if (code !== 0)
481
495
  throw new AgentError(
482
- `git ${args[0]} failed (exit ${code}); check the clone and the host's Git login.`,
496
+ `git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err, this.#diagnosticChars) || `exit ${code}`}`,
483
497
  );
484
498
  return out;
485
499
  }
@@ -31,7 +31,10 @@ export function codingWorkerPrompt(dir: string): string {
31
31
  "Report changes and reasons, commits, checks and outcomes, held actions, and remaining work.",
32
32
  ].join("\n\n");
33
33
  }
34
- const answers = new Map<number, (answer: HeldCallAnswer) => void>();
34
+ const answers = new Map<
35
+ number,
36
+ (answer: HeldCallAnswer, reason?: string) => void
37
+ >();
35
38
  let nextId = 1;
36
39
  let started = false;
37
40
  async function run({
@@ -76,8 +79,11 @@ async function run({
76
79
  factory: (pi) => {
77
80
  pi.on("tool_call", async (event) => {
78
81
  const id = nextId++;
79
- const answer = await new Promise<HeldCallAnswer>((resolve) => {
80
- answers.set(id, resolve);
82
+ const { answer, reason } = await new Promise<{
83
+ answer: HeldCallAnswer;
84
+ reason?: string | undefined;
85
+ }>((resolve) => {
86
+ answers.set(id, (answer, reason) => resolve({ answer, reason }));
81
87
  process.send?.({
82
88
  type: "call",
83
89
  id,
@@ -88,7 +94,9 @@ async function run({
88
94
  if (answer !== "approved")
89
95
  return {
90
96
  block: true,
91
- 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.`,
97
+ reason:
98
+ reason ??
99
+ `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.`,
92
100
  };
93
101
  return undefined;
94
102
  });
@@ -112,7 +120,7 @@ async function run({
112
120
  model: job.model,
113
121
  task: job.task,
114
122
  signal: new AbortController().signal,
115
- aborted: "The worker was stopped.",
123
+ aborted: "the worker was stopped",
116
124
  });
117
125
  }
118
126
  if (process.send) {
@@ -128,7 +136,12 @@ if (process.send) {
128
136
  message.answer === "declined" ||
129
137
  message.answer === "held")
130
138
  ) {
131
- answers.get(message.id)?.(message.answer);
139
+ answers.get(message.id)?.(
140
+ message.answer,
141
+ "reason" in message && typeof message.reason === "string"
142
+ ? message.reason
143
+ : undefined,
144
+ );
132
145
  answers.delete(message.id);
133
146
  } else if (message.type === "start" && !started) {
134
147
  started = true;
@@ -136,7 +149,14 @@ if (process.send) {
136
149
  (report) => {
137
150
  process.send?.({ type: "report", report }, () => process.exit(0));
138
151
  },
139
- () => process.exit(1),
152
+ (error: unknown) =>
153
+ process.send?.(
154
+ {
155
+ type: "failure",
156
+ message: error instanceof Error ? error.message : String(error),
157
+ },
158
+ () => process.exit(1),
159
+ ),
140
160
  );
141
161
  }
142
162
  });
@@ -1,14 +1,19 @@
1
- import { AgentError } from "pi-roundtable/kit";
1
+ import { AgentError, scrubDiagnostic } from "pi-roundtable/kit";
2
2
 
3
- /** Structured diagnostics never carry provider responses, stderr or credentials. */
3
+ /** Worker diagnostics reach the owner scrubbed of credentials and bounded; stderr and raw responses never do. */
4
4
  export class CodingWorkerFailure extends AgentError {
5
5
  constructor(
6
6
  readonly category: "stopped" | "exit" | "missing-report",
7
7
  readonly exitCode?: number,
8
+ detail?: string,
9
+ diagnosticChars?: number,
8
10
  ) {
9
- let message = "The coding worker was stopped.";
11
+ const reason = detail ? scrubDiagnostic(detail, diagnosticChars) : "";
12
+ let message = "the worker was stopped";
10
13
  if (category === "exit")
11
- message = `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
14
+ message =
15
+ reason ||
16
+ `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
12
17
  if (category === "missing-report")
13
18
  message = "The coding worker returned no report.";
14
19
  super(message);