pi-roundtable-coding 0.7.4 → 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,10 @@
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
+
5
9
  ## [0.7.4] - 2026-10-03
6
10
 
7
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.
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
 
@@ -172,6 +172,9 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
172
172
  | `threadText` | English package text | Thread introduction, held-action notice, approval title and final report. |
173
173
  | `workerWorkspace` | individual clone | Trusted shell-policy write boundary; not an OS sandbox. |
174
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`. |
175
178
  | `skipUnavailableCarriedSkills` | `false` | Skip and disclose unavailable implicit skills; explicit skill requests still refuse them. |
176
179
  | `workerPackages` | `[]` | Absolute installed extension paths. |
177
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.4",
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.4",
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;
@@ -182,6 +202,10 @@ export class CodingDesk {
182
202
  const slot = promptSlot();
183
203
  const held: string[] = [];
184
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;
185
209
  let cancel = () => {};
186
210
  let timedOut = false;
187
211
  let outcome: CodingResult["outcome"];
@@ -232,10 +256,10 @@ export class CodingDesk {
232
256
  }
233
257
  // A call the owner declined is settled, not held for the report.
234
258
  if (answer === "held") {
235
- if (held.length < MAX_HELD_ENTRIES) {
259
+ if (held.length < maxHeldEntries) {
236
260
  const entry = bounded(
237
261
  `${call.action}: ${call.tool} ${call.input}`,
238
- MAX_HELD_CHARS,
262
+ maxHeldChars,
239
263
  );
240
264
  held.push(entry);
241
265
  try {
@@ -256,7 +280,7 @@ export class CodingDesk {
256
280
  );
257
281
  if (controller.signal.aborted)
258
282
  throw new AgentError("the worker was stopped");
259
- outcome = { ok: true, report: bounded(report, MAX_REPORT_CHARS) };
283
+ outcome = { ok: true, report: bounded(report, maxReport) };
260
284
  } catch (error) {
261
285
  // The host's own refusals and the worker's scrubbed, bounded error text reach the report.
262
286
  let message =
@@ -302,7 +326,7 @@ export class CodingDesk {
302
326
  bounded(
303
327
  threadText?.report?.(result) ??
304
328
  codingReport(result, job.startedAt.toISOString()),
305
- MAX_REPORT_CHARS,
329
+ maxReport,
306
330
  ),
307
331
  );
308
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";
@@ -97,6 +99,12 @@ export interface CodingOptions {
97
99
  threadText?: CodingThreadText;
98
100
  workerWorkspace?: string;
99
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;
100
108
  /** Opt in to skipping unavailable implicit skills; explicit requests always fail closed. */
101
109
  skipUnavailableCarriedSkills?: boolean;
102
110
  /** Absolute installed extension package paths. Default: none. */
@@ -126,6 +134,16 @@ export function coding(options: CodingOptions) {
126
134
  if (!options.shelfDir.trim()) throw new PluginError("shelfDir is required.");
127
135
  if (!/^[^/\s]+\/\S+$/.test(options.model))
128
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
+ }
129
147
  const ownerRepos = new Set(options.ownerRepos ?? []);
130
148
  for (const repo of ownerRepos) checkRepoName(repo);
131
149
  const ownerOwned = (repo: string): boolean => {
@@ -136,7 +154,9 @@ export function coding(options: CodingOptions) {
136
154
  name: "coding",
137
155
  provides: [CODING],
138
156
  setup(context) {
139
- const shelf = new RepoShelf(options.shelfDir, options.clone);
157
+ const shelf = new RepoShelf(options.shelfDir, options.clone, {
158
+ diagnosticChars: options.diagnosticChars,
159
+ });
140
160
  for (const adoption of options.adoptClones ?? [])
141
161
  shelf.adopt(adoption.from, adoption.repo);
142
162
  const skills = context.services.find(SKILLS);
@@ -147,6 +167,8 @@ export function coding(options: CodingOptions) {
147
167
  agentDir: options.agentDir,
148
168
  workspace: options.workerWorkspace,
149
169
  prompt: options.workerPrompt,
170
+ blockText: options.workerBlockText,
171
+ diagnosticChars: options.diagnosticChars,
150
172
  holds: (tool, input, scope) =>
151
173
  options.holds?.(tool, input, scope) ??
152
174
  context.sessions().holds(tool, input, scope),
@@ -155,6 +177,7 @@ export function coding(options: CodingOptions) {
155
177
  shelf,
156
178
  worker,
157
179
  timeoutMs: options.timeoutMs,
180
+ limits: options.limits,
158
181
  logger: context.logger,
159
182
  threads: options.threads,
160
183
  threadText: options.threadText,
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,
@@ -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;
@@ -82,7 +89,12 @@ export class PiCodingWorker implements CodingWorker {
82
89
  value.type === "failure" &&
83
90
  typeof value.message === "string"
84
91
  ) {
85
- failure = value.message.slice(0, 2_000);
92
+ failure = value.message.slice(
93
+ 0,
94
+ this.#options.diagnosticChars === undefined
95
+ ? 2_000
96
+ : this.#options.diagnosticChars * 4,
97
+ );
86
98
  } else if (
87
99
  value.type === "call" &&
88
100
  Number.isSafeInteger(value.id) &&
@@ -93,6 +105,7 @@ export class PiCodingWorker implements CodingWorker {
93
105
  const { id, tool, input } = value;
94
106
  void (async () => {
95
107
  let answer: HeldCallAnswer = "held";
108
+ let reason: string | undefined;
96
109
  try {
97
110
  const context = {
98
111
  workspace: this.#options.workspace ?? job.dir,
@@ -108,12 +121,19 @@ export class PiCodingWorker implements CodingWorker {
108
121
  action,
109
122
  })
110
123
  : "approved";
124
+ if (action && answer !== "approved")
125
+ reason = this.#options.blockText?.(answer, action);
111
126
  }
112
127
  } catch {
113
128
  /* Fail closed. */
114
129
  }
115
130
  if (!signal.aborted && child.exitCode === null)
116
- proc.send({ type: "answer", id, answer });
131
+ proc.send({
132
+ type: "answer",
133
+ id,
134
+ answer,
135
+ ...(reason ? { reason } : {}),
136
+ });
117
137
  })();
118
138
  }
119
139
  },
@@ -131,7 +151,13 @@ export class PiCodingWorker implements CodingWorker {
131
151
  if (signal.aborted) kill();
132
152
  const code = await child.exited;
133
153
  if (signal.aborted) throw new CodingWorkerFailure("stopped");
134
- if (code !== 0) throw new CodingWorkerFailure("exit", code, failure);
154
+ if (code !== 0)
155
+ throw new CodingWorkerFailure(
156
+ "exit",
157
+ code,
158
+ failure,
159
+ this.#options.diagnosticChars,
160
+ );
135
161
  if (!report?.trim()) throw new CodingWorkerFailure("missing-report");
136
162
  return report;
137
163
  } finally {
package/src/repo-shelf.ts CHANGED
@@ -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 { err, 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: ${scrubDiagnostic(err) || `exit ${code}`}`,
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. */
@@ -483,7 +493,7 @@ export class RepoShelf {
483
493
  const { out, err, code } = await run(["git", "-C", dir, ...args]);
484
494
  if (code !== 0)
485
495
  throw new AgentError(
486
- `git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err) || `exit ${code}`}`,
496
+ `git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err, this.#diagnosticChars) || `exit ${code}`}`,
487
497
  );
488
498
  return out;
489
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
  });
@@ -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;
@@ -6,8 +6,9 @@ export class CodingWorkerFailure extends AgentError {
6
6
  readonly category: "stopped" | "exit" | "missing-report",
7
7
  readonly exitCode?: number,
8
8
  detail?: string,
9
+ diagnosticChars?: number,
9
10
  ) {
10
- const reason = detail ? scrubDiagnostic(detail) : "";
11
+ const reason = detail ? scrubDiagnostic(detail, diagnosticChars) : "";
11
12
  let message = "the worker was stopped";
12
13
  if (category === "exit")
13
14
  message =