pi-roundtable-coding 0.7.3 → 0.7.4

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,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.4] - 2026-10-03
6
+
7
+ - 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.
8
+
5
9
  ## [0.7.3] - 2026-10-03
6
10
 
7
11
  - 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
@@ -166,7 +166,8 @@ 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. |
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.4",
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.4",
48
48
  "typescript": "7.0.2"
49
49
  }
50
50
  }
@@ -121,18 +121,26 @@ export class CodingDesk {
121
121
  ): Promise<CodingJob> {
122
122
  if (this.#stopped) throw new AgentError("The coding desk is stopped.");
123
123
  const task = request.task.trim();
124
- if (!task || task.length > MAX_CODING_TASK_CHARS)
124
+ if (!task) throw new AgentError("The task is required.");
125
+ if (task.length > MAX_CODING_TASK_CHARS)
125
126
  throw new AgentError(
126
- `The task must contain 1–${MAX_CODING_TASK_CHARS} characters.`,
127
+ `The task is ${task.length} characters; keep it within ${MAX_CODING_TASK_CHARS}.`,
127
128
  );
128
129
  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.");
130
+ const busy = [...this.#running.values()].find(
131
+ ({ job }) => job.repo === request.repo,
132
+ );
133
+ if (busy)
134
+ throw new AgentError(
135
+ `Coding task #${busy.job.id} is still working in ${request.repo}; one worker runs per repository.`,
136
+ );
137
+ const inChannel = [...this.#running.values()].filter(
138
+ ({ job }) => job.channel === request.channel,
139
+ ).length;
140
+ if (inChannel >= 3)
141
+ throw new AgentError(
142
+ `This channel already has ${inChannel} coding workers running; wait for one to report back.`,
143
+ );
136
144
  const job: CodingJob = {
137
145
  ...request,
138
146
  task,
@@ -180,7 +188,7 @@ export class CodingDesk {
180
188
  try {
181
189
  job.startHead = (await shelf.state(job.repo)).head;
182
190
  if (controller.signal.aborted)
183
- throw new AgentError("The worker was stopped.");
191
+ throw new AgentError("the worker was stopped");
184
192
  if (threads) {
185
193
  try {
186
194
  const thread = await threads.open(
@@ -197,7 +205,7 @@ export class CodingDesk {
197
205
  }
198
206
  }
199
207
  if (controller.signal.aborted)
200
- throw new AgentError("The worker was stopped.");
208
+ throw new AgentError("the worker was stopped");
201
209
  slot.bind(
202
210
  threads
203
211
  ? job.thread && prompts?.(job.thread.channel)
@@ -222,10 +230,11 @@ export class CodingDesk {
222
230
  } catch {
223
231
  /* A failed card never authorizes the call. */
224
232
  }
225
- if (answer !== "approved") {
233
+ // A call the owner declined is settled, not held for the report.
234
+ if (answer === "held") {
226
235
  if (held.length < MAX_HELD_ENTRIES) {
227
236
  const entry = bounded(
228
- `${answer}: ${call.action}: ${call.tool} ${call.input}`,
237
+ `${call.action}: ${call.tool} ${call.input}`,
229
238
  MAX_HELD_CHARS,
230
239
  );
231
240
  held.push(entry);
@@ -246,22 +255,22 @@ export class CodingDesk {
246
255
  review,
247
256
  );
248
257
  if (controller.signal.aborted)
249
- throw new AgentError("The worker was stopped.");
258
+ throw new AgentError("the worker was stopped");
250
259
  outcome = { ok: true, report: bounded(report, MAX_REPORT_CHARS) };
251
260
  } catch (error) {
252
- // Only structured, fixed diagnostics can cross the worker boundary.
261
+ // The host's own refusals and the worker's scrubbed, bounded error text reach the report.
253
262
  let message =
254
263
  "The worker failed; inspect the clone and worker configuration on the host.";
264
+ if (error instanceof AgentError) message = error.message;
255
265
  if (error instanceof CodingWorkerFailure) {
256
- message = error.message;
257
266
  this.#options.logger.warn(
258
267
  { job: job.id, category: error.category, exitCode: error.exitCode },
259
268
  "Coding worker failed.",
260
269
  );
261
270
  }
262
- if (controller.signal.aborted) message = "The worker was stopped.";
271
+ if (controller.signal.aborted) message = "the worker was stopped";
263
272
  if (timedOut)
264
- message = `Work timeout after ${timeoutMs} ms (owner wait excluded).`;
273
+ message = `the worker ran out of time (${Math.round(timeoutMs / 60_000)} minutes)`;
265
274
  outcome = { ok: false, error: message };
266
275
  } finally {
267
276
  cancel();
@@ -32,6 +32,7 @@ import {
32
32
  type ChangeReport,
33
33
  type CloneCommand,
34
34
  RepoShelf,
35
+ type RepoSummary,
35
36
  reportPost,
36
37
  } from "./repo-shelf.ts";
37
38
 
@@ -46,7 +47,24 @@ export interface CodingRun {
46
47
  channel: ChannelKey;
47
48
  origin?: ChannelKey;
48
49
  }
50
+ export type RepoToolName =
51
+ | "repo_list"
52
+ | "repo_add"
53
+ | "repo_change_report"
54
+ | "repo_push"
55
+ | "repo_task";
56
+ /** Trusted wording of one repository tool, as the model reads it; unset fields keep the defaults. */
57
+ export interface CodingToolText {
58
+ description?: string;
59
+ /** Argument descriptions by argument name. */
60
+ parameters?: Record<string, string>;
61
+ }
49
62
  export interface CodingPresentation {
63
+ /** The repo_list result; unset: the managed clones as JSON. */
64
+ list?(
65
+ repos: (RepoSummary & { skills: string[] })[],
66
+ context: { shelfDir: string; fetched: boolean },
67
+ ): string;
50
68
  /** Separate the owner's posted record from the model's shipping instructions. */
51
69
  changeReport?(
52
70
  report: ChangeReport,
@@ -73,6 +91,8 @@ export interface CodingOptions {
73
91
  /** Post change reports in the caller's identity/channel rather than the default surface. */
74
92
  postChangeReport?: (turn: ToolTurn, text: string) => Promise<void>;
75
93
  presentation?: CodingPresentation;
94
+ /** Trusted wording of the repository tools' descriptions and arguments. */
95
+ toolText?: Partial<Record<RepoToolName, CodingToolText>>;
76
96
  threads?: Pick<DispatchThreads, "open">;
77
97
  threadText?: CodingThreadText;
78
98
  workerWorkspace?: string;
@@ -170,14 +190,21 @@ export function coding(options: CodingOptions) {
170
190
  }
171
191
  }
172
192
  context.services.provide(CODING, { shelf, desk });
173
- const repoSchema = Type.String({
174
- description: "A managed repository, owner/repo.",
175
- });
193
+ const textOf = (tool: RepoToolName) => options.toolText?.[tool];
194
+ const describe = (tool: RepoToolName, fallback: string) =>
195
+ textOf(tool)?.description ?? fallback;
196
+ const about = (tool: RepoToolName, name: string, fallback?: string) => {
197
+ const description = textOf(tool)?.parameters?.[name] ?? fallback;
198
+ return description ? { description } : {};
199
+ };
200
+ const repoSchema = (tool: RepoToolName) =>
201
+ Type.String(about(tool, "repo", "A managed repository, owner/repo."));
176
202
  return {
177
203
  services: [
178
204
  {
179
205
  name: "coding-desk",
180
- busy: () => desk.busy(),
206
+ // The host names the channels whose work a shutdown waits for or aborts.
207
+ busy: () => desk.runningChannels(),
181
208
  stop: () => desk.stop(),
182
209
  },
183
210
  ],
@@ -200,30 +227,39 @@ export function coding(options: CodingOptions) {
200
227
  defineTool({
201
228
  name: "repo_list",
202
229
  minTier: "owner",
203
- description:
230
+ description: describe(
231
+ "repo_list",
204
232
  "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()) }),
233
+ ),
234
+ parameters: Type.Object({
235
+ fetch: Type.Optional(Type.Boolean(about("repo_list", "fetch"))),
236
+ }),
206
237
  run: ({ fetch }) =>
207
238
  refusal(async () => {
208
- const repos = await shelf.list(fetch === true);
239
+ const repos = (await shelf.list(fetch === true)).map(
240
+ (repo) => ({
241
+ ...repo,
242
+ skills: skills?.linkedFrom(repo.repo) ?? [],
243
+ }),
244
+ );
245
+ if (options.presentation?.list)
246
+ return options.presentation.list(repos, {
247
+ shelfDir: shelf.dir,
248
+ fetched: fetch === true,
249
+ });
209
250
  return repos.length
210
- ? JSON.stringify(
211
- repos.map((repo) => ({
212
- ...repo,
213
- skills: skills?.linkedFrom(repo.repo) ?? [],
214
- })),
215
- null,
216
- 2,
217
- )
251
+ ? JSON.stringify(repos, null, 2)
218
252
  : `No managed repositories in ${shelf.dir}; use repo_add.`;
219
253
  }),
220
254
  }),
221
255
  defineTool({
222
256
  name: "repo_add",
223
257
  minTier: "owner",
224
- description:
258
+ description: describe(
259
+ "repo_add",
225
260
  "Clone owner/repo into the shelf using the host's Git login (GitHub CLI by default).",
226
- parameters: Type.Object({ repo: repoSchema }),
261
+ ),
262
+ parameters: Type.Object({ repo: repoSchema("repo_add") }),
227
263
  run: ({ repo }) =>
228
264
  refusal(
229
265
  async () => `Cloned ${repo} into ${await shelf.add(repo)}.`,
@@ -232,9 +268,13 @@ export function coding(options: CodingOptions) {
232
268
  defineTool({
233
269
  name: "repo_change_report",
234
270
  minTier: "owner",
235
- description:
271
+ description: describe(
272
+ "repo_change_report",
236
273
  "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 }),
274
+ ),
275
+ parameters: Type.Object({
276
+ repo: repoSchema("repo_change_report"),
277
+ }),
238
278
  run: ({ repo }, turn) =>
239
279
  refusal(() =>
240
280
  ship(repo, async () => {
@@ -260,11 +300,16 @@ export function coding(options: CodingOptions) {
260
300
  defineTool({
261
301
  name: "repo_push",
262
302
  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.",
303
+ description: describe(
304
+ "repo_push",
305
+ "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.",
306
+ ),
265
307
  parameters: Type.Object({
266
- repo: repoSchema,
267
- sha: Type.String({ pattern: "^[0-9a-f]{40}$" }),
308
+ repo: repoSchema("repo_push"),
309
+ sha: Type.String({
310
+ pattern: "^[0-9a-f]{7,40}$",
311
+ ...about("repo_push", "sha"),
312
+ }),
268
313
  }),
269
314
  hold: ({ repo, sha }) =>
270
315
  ownerOwned(repo)
@@ -283,15 +328,20 @@ export function coding(options: CodingOptions) {
283
328
  defineTool({
284
329
  name: "repo_task",
285
330
  minTier: "owner",
286
- description:
331
+ description: describe(
332
+ "repo_task",
287
333
  "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.",
334
+ ),
288
335
  parameters: Type.Object({
289
- repo: repoSchema,
336
+ repo: repoSchema("repo_task"),
290
337
  task: Type.String({
291
338
  minLength: 1,
292
339
  maxLength: MAX_CODING_TASK_CHARS,
340
+ ...about("repo_task", "task"),
293
341
  }),
294
- skills: Type.Optional(Type.Array(Type.String())),
342
+ skills: Type.Optional(
343
+ Type.Array(Type.String(), about("repo_task", "skills")),
344
+ ),
295
345
  }),
296
346
  run: ({ repo, task, skills: names }, turn) =>
297
347
  refusal(async () => {
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export type {
12
12
  CodingPresentation,
13
13
  CodingRun,
14
14
  CodingService,
15
+ CodingToolText,
16
+ RepoToolName,
15
17
  } from "./coding-plugin.ts";
16
18
  export { CODING, coding } from "./coding-plugin.ts";
17
19
  export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
@@ -23,6 +23,7 @@ interface WorkerMessage {
23
23
  tool?: string;
24
24
  input?: Record<string, unknown>;
25
25
  report?: string;
26
+ message?: string;
26
27
  }
27
28
  function isMessage(value: unknown): value is WorkerMessage {
28
29
  return (
@@ -46,9 +47,10 @@ export class PiCodingWorker implements CodingWorker {
46
47
  ): Promise<string> {
47
48
  if (process.platform === "win32")
48
49
  throw new AgentError("Coding workers require a POSIX host.");
49
- if (signal.aborted) throw new AgentError("The worker was stopped.");
50
+ if (signal.aborted) throw new AgentError("the worker was stopped");
50
51
  const prompt = this.#options.prompt?.(job.dir);
51
52
  let report: string | undefined;
53
+ let failure: string | undefined;
52
54
  const child = Bun.spawn(
53
55
  [
54
56
  process.execPath,
@@ -76,6 +78,11 @@ export class PiCodingWorker implements CodingWorker {
76
78
  typeof value.report === "string"
77
79
  ) {
78
80
  report = value.report;
81
+ } else if (
82
+ value.type === "failure" &&
83
+ typeof value.message === "string"
84
+ ) {
85
+ failure = value.message.slice(0, 2_000);
79
86
  } else if (
80
87
  value.type === "call" &&
81
88
  Number.isSafeInteger(value.id) &&
@@ -124,7 +131,7 @@ export class PiCodingWorker implements CodingWorker {
124
131
  if (signal.aborted) kill();
125
132
  const code = await child.exited;
126
133
  if (signal.aborted) throw new CodingWorkerFailure("stopped");
127
- if (code !== 0) throw new CodingWorkerFailure("exit", code);
134
+ if (code !== 0) throw new CodingWorkerFailure("exit", code, failure);
128
135
  if (!report?.trim()) throw new CodingWorkerFailure("missing-report");
129
136
  return report;
130
137
  } 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 {
@@ -92,7 +92,7 @@ export const ghClone: CloneCommand = async (repo, dir) => {
92
92
  checkRepoName(repo);
93
93
  if (repo.startsWith("-"))
94
94
  throw new AgentError("Repository owner cannot start with a dash.");
95
- const { code } = await run([
95
+ const { err, code } = await run([
96
96
  "gh",
97
97
  "repo",
98
98
  "clone",
@@ -103,7 +103,7 @@ export const ghClone: CloneCommand = async (repo, dir) => {
103
103
  ]);
104
104
  if (code !== 0)
105
105
  throw new AgentError(
106
- `Cloning ${repo} failed (exit ${code}); check the host's Git login.`,
106
+ `Cloning ${repo} failed: ${scrubDiagnostic(err) || `exit ${code}`}`,
107
107
  );
108
108
  };
109
109
 
@@ -328,7 +328,7 @@ export class RepoShelf {
328
328
  /** A synchronous hold description from the latest report, safe to show on an owner card. */
329
329
  pushDescription(repo: string, sha: string): string {
330
330
  const report = this.#reports.get(repo);
331
- if (!report || report.sha !== sha)
331
+ if (!report || !/^[0-9a-f]{7,40}$/.test(sha) || !report.sha.startsWith(sha))
332
332
  return `Push ${repo} commit ${sha}; request a fresh change report first`;
333
333
  return `Push ${repo} commit ${sha} to ${report.target}, branch ${report.branch}`;
334
334
  }
@@ -338,7 +338,11 @@ export class RepoShelf {
338
338
  const dir = this.dirOf(repo);
339
339
  const head = await this.#git(dir, "rev-parse", "HEAD");
340
340
  const report = this.#reports.get(repo);
341
- if (!report || report.sha !== sha || head !== sha)
341
+ if (
342
+ !/^[0-9a-f]{7,40}$/.test(sha) ||
343
+ !report?.sha.startsWith(sha) ||
344
+ head !== report.sha
345
+ )
342
346
  throw new AgentError(
343
347
  `${sha} is not HEAD of ${repo} (HEAD is ${head.slice(0, 12)}); report again with repo_change_report.`,
344
348
  );
@@ -476,10 +480,10 @@ export class RepoShelf {
476
480
  }
477
481
 
478
482
  async #git(dir: string, ...args: string[]): Promise<string> {
479
- const { out, code } = await run(["git", "-C", dir, ...args]);
483
+ const { out, err, code } = await run(["git", "-C", dir, ...args]);
480
484
  if (code !== 0)
481
485
  throw new AgentError(
482
- `git ${args[0]} failed (exit ${code}); check the clone and the host's Git login.`,
486
+ `git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err) || `exit ${code}`}`,
483
487
  );
484
488
  return out;
485
489
  }
@@ -112,7 +112,7 @@ async function run({
112
112
  model: job.model,
113
113
  task: job.task,
114
114
  signal: new AbortController().signal,
115
- aborted: "The worker was stopped.",
115
+ aborted: "the worker was stopped",
116
116
  });
117
117
  }
118
118
  if (process.send) {
@@ -136,7 +136,14 @@ if (process.send) {
136
136
  (report) => {
137
137
  process.send?.({ type: "report", report }, () => process.exit(0));
138
138
  },
139
- () => process.exit(1),
139
+ (error: unknown) =>
140
+ process.send?.(
141
+ {
142
+ type: "failure",
143
+ message: error instanceof Error ? error.message : String(error),
144
+ },
145
+ () => process.exit(1),
146
+ ),
140
147
  );
141
148
  }
142
149
  });
@@ -1,14 +1,18 @@
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,
8
9
  ) {
9
- let message = "The coding worker was stopped.";
10
+ const reason = detail ? scrubDiagnostic(detail) : "";
11
+ let message = "the worker was stopped";
10
12
  if (category === "exit")
11
- message = `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
13
+ message =
14
+ reason ||
15
+ `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
12
16
  if (category === "missing-report")
13
17
  message = "The coding worker returned no report.";
14
18
  super(message);