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 +8 -0
- package/README.md +6 -2
- package/package.json +2 -2
- package/src/coding-desk.ts +55 -22
- package/src/coding-plugin.ts +100 -27
- package/src/index.ts +3 -0
- package/src/pi-coding-worker.ts +36 -3
- package/src/repo-shelf.ts +40 -26
- package/src/worker-entry.ts +27 -7
- package/src/worker-failure.ts +9 -4
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
|
-
|
|
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
|
|
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
|
+
"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.
|
|
47
|
+
"pi-roundtable": "0.7.5",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/coding-desk.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
`${
|
|
229
|
-
|
|
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("
|
|
250
|
-
outcome = { ok: true, report: bounded(report,
|
|
282
|
+
throw new AgentError("the worker was stopped");
|
|
283
|
+
outcome = { ok: true, report: bounded(report, maxReport) };
|
|
251
284
|
} catch (error) {
|
|
252
|
-
//
|
|
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 = "
|
|
295
|
+
if (controller.signal.aborted) message = "the worker was stopped";
|
|
263
296
|
if (timedOut)
|
|
264
|
-
message = `
|
|
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
|
-
|
|
329
|
+
maxReport,
|
|
297
330
|
),
|
|
298
331
|
);
|
|
299
332
|
} catch {
|
package/src/coding-plugin.ts
CHANGED
|
@@ -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
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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({
|
|
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(
|
|
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";
|
package/src/pi-coding-worker.ts
CHANGED
|
@@ -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("
|
|
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({
|
|
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)
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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.#
|
|
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 ||
|
|
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 (
|
|
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
|
|
496
|
+
`git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err, this.#diagnosticChars) || `exit ${code}`}`,
|
|
483
497
|
);
|
|
484
498
|
return out;
|
|
485
499
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -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<
|
|
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<
|
|
80
|
-
|
|
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:
|
|
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: "
|
|
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)?.(
|
|
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
|
-
() =>
|
|
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
|
});
|
package/src/worker-failure.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { AgentError } from "pi-roundtable/kit";
|
|
1
|
+
import { AgentError, scrubDiagnostic } from "pi-roundtable/kit";
|
|
2
2
|
|
|
3
|
-
/**
|
|
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
|
-
|
|
11
|
+
const reason = detail ? scrubDiagnostic(detail, diagnosticChars) : "";
|
|
12
|
+
let message = "the worker was stopped";
|
|
10
13
|
if (category === "exit")
|
|
11
|
-
message =
|
|
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);
|