pi-roundtable-coding 0.7.2 → 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 +8 -0
- package/README.md +27 -3
- package/package.json +2 -2
- package/src/coding-desk.ts +106 -28
- package/src/coding-plugin.ts +172 -41
- package/src/index.ts +9 -1
- package/src/pi-coding-worker.ts +20 -4
- package/src/repo-shelf.ts +35 -7
- package/src/worker-entry.ts +17 -4
- package/src/worker-failure.ts +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
9
|
+
## [0.7.3] - 2026-10-03
|
|
10
|
+
|
|
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.
|
|
12
|
+
|
|
5
13
|
## [0.7.2] - 2026-10-02
|
|
6
14
|
|
|
7
15
|
First release published by the lockstep workflow; no changes to the package.
|
package/README.md
CHANGED
|
@@ -78,7 +78,9 @@ All five tools have `minTier: "owner"` and are contributed through `defineTool`
|
|
|
78
78
|
The operator remains responsible for any tool-tier overrides.
|
|
79
79
|
A custom owner claim with a restricted `ToolSelection` must include the exported `REPO_TOOLS` names; include `skill_list` separately when the host skills addon is enabled.
|
|
80
80
|
The host's skills addon resolves explicit skills, or the calling agent's carried skills when omitted; an owner call carries none unless it names them.
|
|
81
|
-
Missing or unknown skills refuse the task; requesting skills while the addon is off also refuses it.
|
|
81
|
+
Missing or unknown skills refuse the task by default; requesting skills while the addon is off also refuses it.
|
|
82
|
+
`skipUnavailableCarriedSkills: true` opts into skipping unavailable implicit skills and disclosing them in the task-start text or through `presentation.taskStarted`.
|
|
83
|
+
Explicit skill requests still refuse missing or unknown skills.
|
|
82
84
|
Sessions with kind `owner` get the public `skillListExtension`; agent sessions keep the core's existing `skill_list` instead of registering it twice.
|
|
83
85
|
|
|
84
86
|
A worker reads the repository's instructions, edits and checks the work, and commits using repository conventions.
|
|
@@ -99,7 +101,10 @@ Shipping is separate from coding:
|
|
|
99
101
|
|
|
100
102
|
`ownerRepos` is an exact list of `owner/repo` names, not a prefix or wildcard; its default is empty.
|
|
101
103
|
Listed repositories skip the plugin's `repo_push` hold, though other host hold rules can still hold the call.
|
|
102
|
-
|
|
104
|
+
A trusted `isOwnerRepo(repo)` host policy can grant additional exemptions, including repositories created after startup; only literal `true` grants one.
|
|
105
|
+
The package validates the repository name before consulting it.
|
|
106
|
+
Never derive this policy from model input, clone files, or remote repository instructions.
|
|
107
|
+
Neither exemption bypasses shell pushes, risky worker actions, or other linked host rules.
|
|
103
108
|
The report and approval card name the full SHA, current default branch and credential-free push destination.
|
|
104
109
|
The clone must have exactly one push destination; HTTP remote URLs with user information, query strings or fragments are refused before fetching.
|
|
105
110
|
A changed HEAD, dirty clone, changed remote default branch, changed push destination, missing report or non-fast-forward remote causes refusal.
|
|
@@ -109,6 +114,9 @@ Report receipts live in memory, so request a fresh report after restarting the h
|
|
|
109
114
|
|
|
110
115
|
Every worker tool call crosses a private Bun IPC channel to the parent process before execution.
|
|
111
116
|
The parent consults `shellHoldRule`, additional `holds`, and the host's linked hold rules; a recognized risky action asks the owner's surface prompts using `approvalCard`.
|
|
117
|
+
When `threads` is configured, progress and cards stay in a thread opened under the resolved run's `origin`; if no thread can open, calls remain held rather than falling back to another channel's approval cards.
|
|
118
|
+
Without `threads`, the resolved report channel's prompts are used.
|
|
119
|
+
`threadText` controls the initial post, held-action post, approval title and final report; the thread is archived before `onResult` is called, and a failed thread post/close does not discard result delivery.
|
|
112
120
|
`promptSlot` tracks these cards and `workTimeout` excludes their waiting time from the worker's budget (one hour by default).
|
|
113
121
|
`timeoutMs` must be positive and finite, and at most 2,147,483,647 ms to fit the host timer.
|
|
114
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.
|
|
@@ -129,7 +137,7 @@ This is a host coding desk, **not a sandbox**.
|
|
|
129
137
|
The worker can run local checks, read the host's accessible files, use the network and commit changes.
|
|
130
138
|
Its standing instructions delegate shipping to the calling agent; repository tools and extra package tools are not activated inside the worker.
|
|
131
139
|
`repo_push` is the supported shipping route and always uses a previously reported full SHA and a non-force push.
|
|
132
|
-
The public shell rule holds recognized shell pushes, destructive commands, privileged commands and recognized writes outside the clone, even for
|
|
140
|
+
The public shell rule holds recognized shell pushes, destructive commands, privileged commands and recognized writes outside the configured worker workspace (the clone by default), even for owner-owned repositories.
|
|
133
141
|
It is a heuristic guard: scripts, interpreters, symlinks, Git hooks, package extensions and detached grandchildren are not an OS isolation boundary.
|
|
134
142
|
A trusted package or arbitrary shell program can bypass heuristic detection; only run code and repositories you trust, under a dedicated low-privilege host user.
|
|
135
143
|
Use an isolated container or VM when host access is unacceptable.
|
|
@@ -153,6 +161,18 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
|
|
|
153
161
|
| `model` | required | Worker provider/model ID. |
|
|
154
162
|
| `thinking` | `medium` | Pi thinking level. |
|
|
155
163
|
| `ownerRepos` | `[]` | Exact push-hold exemptions. |
|
|
164
|
+
| `isOwnerRepo` | none | Trusted host policy for additional push-hold exemptions; must return literal true. |
|
|
165
|
+
| `pushHoldText` | repository, sha, target and branch | Trusted wording for the approval card of a held `repo_push`; it never changes who is held. |
|
|
166
|
+
| `adoptClones` | `[]` | Startup `{ from, repo }` moves of standalone clones; existing shelf destinations are never replaced. |
|
|
167
|
+
| `resolveRun` | configured model/thinking, caller channel | Per-caller model, thinking, report channel and optional thread origin. |
|
|
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, 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. |
|
|
171
|
+
| `threads` | none | Public `DispatchThreads`-compatible progress and approval thread port. |
|
|
172
|
+
| `threadText` | English package text | Thread introduction, held-action notice, approval title and final report. |
|
|
173
|
+
| `workerWorkspace` | individual clone | Trusted shell-policy write boundary; not an OS sandbox. |
|
|
174
|
+
| `workerPrompt` | generic worker instructions | Trusted standing prompt replacement; it cannot bypass approval or cleanup. |
|
|
175
|
+
| `skipUnavailableCarriedSkills` | `false` | Skip and disclose unavailable implicit skills; explicit skill requests still refuse them. |
|
|
156
176
|
| `workerPackages` | `[]` | Absolute installed extension paths. |
|
|
157
177
|
| `agentDir` | Pi host directory | Pi credentials and model configuration. |
|
|
158
178
|
| `timeoutMs` | `3600000` | Work-time limit in ms, excluding owner wait; 0 < value <= 2147483647. |
|
|
@@ -162,6 +182,10 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
|
|
|
162
182
|
| `onResult` | surface reply | Report delivery integration. |
|
|
163
183
|
|
|
164
184
|
`RepoShelf`, `CodingDesk`, `PiCodingWorker`, `CodingWorkerFailure`, `REPO_TOOLS`, `codingReport`, `reportPost` and their option/result types are exported for testing and host integrations.
|
|
185
|
+
`resolveRun(turn)` receives the public `ToolTurn`, including the calling agent scope and channel.
|
|
186
|
+
It returns `{ model, thinking, channel, origin? }`; skill selection still follows the caller, not the resolved report destination.
|
|
187
|
+
`onResult` receives those fields plus loaded `skillNames` and the optional progress `thread` in `result.job`, so a host can enqueue its own localized conversation turn.
|
|
188
|
+
`adoptClones` and `RepoShelf.adopt` keep existing clone contents and path layout without database migrations.
|
|
165
189
|
No database or Discord-specific types are required.
|
|
166
190
|
|
|
167
191
|
## Development and publishing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-coding",
|
|
3
|
-
"version": "0.7.
|
|
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.
|
|
47
|
+
"pi-roundtable": "0.7.4",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/coding-desk.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
import {
|
|
9
9
|
AgentError,
|
|
10
10
|
approvalCard,
|
|
11
|
+
type DispatchThread,
|
|
12
|
+
type DispatchThreads,
|
|
11
13
|
promptSlot,
|
|
12
14
|
workTimeout,
|
|
13
15
|
} from "pi-roundtable/kit";
|
|
@@ -20,6 +22,12 @@ export interface CodingJob {
|
|
|
20
22
|
repo: string;
|
|
21
23
|
task: string;
|
|
22
24
|
channel: ChannelKey;
|
|
25
|
+
/** The calling turn's channel, where a progress thread opens. */
|
|
26
|
+
origin?: ChannelKey;
|
|
27
|
+
/** Host-resolved names of the skills carried to this run. */
|
|
28
|
+
skillNames?: string[];
|
|
29
|
+
/** Progress thread, when the surface supports one. */
|
|
30
|
+
thread?: DispatchThread;
|
|
23
31
|
model: string;
|
|
24
32
|
thinking: ThinkingLevel;
|
|
25
33
|
skillFiles: string[];
|
|
@@ -40,10 +48,19 @@ export interface CodingResult {
|
|
|
40
48
|
state?: RepoState;
|
|
41
49
|
commits: string[];
|
|
42
50
|
}
|
|
51
|
+
export interface CodingThreadText {
|
|
52
|
+
initial?(job: CodingJob): string;
|
|
53
|
+
held?(entry: string, job: CodingJob): string;
|
|
54
|
+
approvalTitle?(job: CodingJob): string;
|
|
55
|
+
report?(result: CodingResult): string;
|
|
56
|
+
}
|
|
43
57
|
export interface CodingDeskOptions {
|
|
44
58
|
shelf: Pick<RepoShelf, "dirOf" | "state" | "commitsSince">;
|
|
45
59
|
worker: CodingWorker;
|
|
46
|
-
prompts(channel: ChannelKey): OwnerPrompts | undefined;
|
|
60
|
+
prompts?(channel: ChannelKey): OwnerPrompts | undefined;
|
|
61
|
+
/** When configured, approval cards stay in the job's thread; no thread means held. */
|
|
62
|
+
threads?: Pick<DispatchThreads, "open">;
|
|
63
|
+
threadText?: CodingThreadText;
|
|
47
64
|
deliver(result: CodingResult): Promise<void>;
|
|
48
65
|
logger: Logger;
|
|
49
66
|
timeoutMs?: number;
|
|
@@ -79,6 +96,9 @@ export class CodingDesk {
|
|
|
79
96
|
);
|
|
80
97
|
this.#options = options;
|
|
81
98
|
}
|
|
99
|
+
runningChannels(): ChannelKey[] {
|
|
100
|
+
return [...this.#running.values()].map(({ job }) => job.channel);
|
|
101
|
+
}
|
|
82
102
|
busy(): string[] {
|
|
83
103
|
return [...this.#running.values()].map(
|
|
84
104
|
({ job }) => `Coding #${job.id} in ${job.repo}`,
|
|
@@ -97,22 +117,30 @@ export class CodingDesk {
|
|
|
97
117
|
await this.idle();
|
|
98
118
|
}
|
|
99
119
|
async start(
|
|
100
|
-
request: Omit<CodingJob, "id" | "startedAt" | "startHead">,
|
|
120
|
+
request: Omit<CodingJob, "id" | "startedAt" | "startHead" | "thread">,
|
|
101
121
|
): Promise<CodingJob> {
|
|
102
122
|
if (this.#stopped) throw new AgentError("The coding desk is stopped.");
|
|
103
123
|
const task = request.task.trim();
|
|
104
|
-
if (!task
|
|
124
|
+
if (!task) throw new AgentError("The task is required.");
|
|
125
|
+
if (task.length > MAX_CODING_TASK_CHARS)
|
|
105
126
|
throw new AgentError(
|
|
106
|
-
`The task
|
|
127
|
+
`The task is ${task.length} characters; keep it within ${MAX_CODING_TASK_CHARS}.`,
|
|
107
128
|
);
|
|
108
129
|
const dir = this.#options.shelf.dirOf(request.repo);
|
|
109
|
-
this.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
);
|
|
116
144
|
const job: CodingJob = {
|
|
117
145
|
...request,
|
|
118
146
|
task,
|
|
@@ -146,6 +174,8 @@ export class CodingDesk {
|
|
|
146
174
|
shelf,
|
|
147
175
|
worker,
|
|
148
176
|
prompts,
|
|
177
|
+
threads,
|
|
178
|
+
threadText,
|
|
149
179
|
deliver,
|
|
150
180
|
timeoutMs = 3_600_000,
|
|
151
181
|
} = this.#options;
|
|
@@ -158,8 +188,30 @@ export class CodingDesk {
|
|
|
158
188
|
try {
|
|
159
189
|
job.startHead = (await shelf.state(job.repo)).head;
|
|
160
190
|
if (controller.signal.aborted)
|
|
161
|
-
throw new AgentError("
|
|
162
|
-
|
|
191
|
+
throw new AgentError("the worker was stopped");
|
|
192
|
+
if (threads) {
|
|
193
|
+
try {
|
|
194
|
+
const thread = await threads.open(
|
|
195
|
+
job.origin,
|
|
196
|
+
`${job.repo} #${job.id}`,
|
|
197
|
+
threadText?.initial?.(job) ?? `Task:\n${job.task}`,
|
|
198
|
+
);
|
|
199
|
+
if (thread) job.thread = thread;
|
|
200
|
+
} catch {
|
|
201
|
+
this.#options.logger.warn(
|
|
202
|
+
{ job: job.id },
|
|
203
|
+
"Coding thread unavailable.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (controller.signal.aborted)
|
|
208
|
+
throw new AgentError("the worker was stopped");
|
|
209
|
+
slot.bind(
|
|
210
|
+
threads
|
|
211
|
+
? job.thread && prompts?.(job.thread.channel)
|
|
212
|
+
: prompts?.(job.channel),
|
|
213
|
+
`Coding worker #${job.id}`,
|
|
214
|
+
);
|
|
163
215
|
cancel = workTimeout(timeoutMs, slot, () => {
|
|
164
216
|
timedOut = true;
|
|
165
217
|
controller.abort();
|
|
@@ -168,7 +220,8 @@ export class CodingDesk {
|
|
|
168
220
|
let answer: HeldCallAnswer = "held";
|
|
169
221
|
try {
|
|
170
222
|
const decision = await slot.prompts?.confirm(
|
|
171
|
-
|
|
223
|
+
threadText?.approvalTitle?.(job) ??
|
|
224
|
+
`${slot.asker} requests approval`,
|
|
172
225
|
approvalCard(call),
|
|
173
226
|
controller.signal,
|
|
174
227
|
);
|
|
@@ -177,15 +230,22 @@ export class CodingDesk {
|
|
|
177
230
|
} catch {
|
|
178
231
|
/* A failed card never authorizes the call. */
|
|
179
232
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
),
|
|
233
|
+
// A call the owner declined is settled, not held for the report.
|
|
234
|
+
if (answer === "held") {
|
|
235
|
+
if (held.length < MAX_HELD_ENTRIES) {
|
|
236
|
+
const entry = bounded(
|
|
237
|
+
`${call.action}: ${call.tool} ${call.input}`,
|
|
238
|
+
MAX_HELD_CHARS,
|
|
187
239
|
);
|
|
188
|
-
|
|
240
|
+
held.push(entry);
|
|
241
|
+
try {
|
|
242
|
+
await job.thread?.post(
|
|
243
|
+
threadText?.held?.(entry, job) ?? `Held: ${entry}`,
|
|
244
|
+
);
|
|
245
|
+
} catch {
|
|
246
|
+
/* A failed progress post does not discard the held action. */
|
|
247
|
+
}
|
|
248
|
+
} else omittedHeld++;
|
|
189
249
|
}
|
|
190
250
|
return answer;
|
|
191
251
|
};
|
|
@@ -195,22 +255,22 @@ export class CodingDesk {
|
|
|
195
255
|
review,
|
|
196
256
|
);
|
|
197
257
|
if (controller.signal.aborted)
|
|
198
|
-
throw new AgentError("
|
|
258
|
+
throw new AgentError("the worker was stopped");
|
|
199
259
|
outcome = { ok: true, report: bounded(report, MAX_REPORT_CHARS) };
|
|
200
260
|
} catch (error) {
|
|
201
|
-
//
|
|
261
|
+
// The host's own refusals and the worker's scrubbed, bounded error text reach the report.
|
|
202
262
|
let message =
|
|
203
263
|
"The worker failed; inspect the clone and worker configuration on the host.";
|
|
264
|
+
if (error instanceof AgentError) message = error.message;
|
|
204
265
|
if (error instanceof CodingWorkerFailure) {
|
|
205
|
-
message = error.message;
|
|
206
266
|
this.#options.logger.warn(
|
|
207
267
|
{ job: job.id, category: error.category, exitCode: error.exitCode },
|
|
208
268
|
"Coding worker failed.",
|
|
209
269
|
);
|
|
210
270
|
}
|
|
211
|
-
if (controller.signal.aborted) message = "
|
|
271
|
+
if (controller.signal.aborted) message = "the worker was stopped";
|
|
212
272
|
if (timedOut)
|
|
213
|
-
message = `
|
|
273
|
+
message = `the worker ran out of time (${Math.round(timeoutMs / 60_000)} minutes)`;
|
|
214
274
|
outcome = { ok: false, error: message };
|
|
215
275
|
} finally {
|
|
216
276
|
cancel();
|
|
@@ -230,7 +290,25 @@ export class CodingDesk {
|
|
|
230
290
|
}
|
|
231
291
|
if (omittedHeld)
|
|
232
292
|
held.push(`[${omittedHeld} more unapproved actions omitted]`);
|
|
233
|
-
|
|
293
|
+
const result: CodingResult = {
|
|
294
|
+
job,
|
|
295
|
+
outcome,
|
|
296
|
+
held,
|
|
297
|
+
...(state ? { state } : {}),
|
|
298
|
+
commits,
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
await job.thread?.close(
|
|
302
|
+
bounded(
|
|
303
|
+
threadText?.report?.(result) ??
|
|
304
|
+
codingReport(result, job.startedAt.toISOString()),
|
|
305
|
+
MAX_REPORT_CHARS,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
} catch {
|
|
309
|
+
this.#options.logger.warn({ job: job.id }, "Coding thread close failed.");
|
|
310
|
+
}
|
|
311
|
+
await deliver(result);
|
|
234
312
|
}
|
|
235
313
|
}
|
|
236
314
|
|
package/src/coding-plugin.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ChannelKey,
|
|
2
3
|
definePlugin,
|
|
3
4
|
defineTool,
|
|
4
5
|
type HoldCheck,
|
|
@@ -7,29 +8,70 @@ import {
|
|
|
7
8
|
serviceKey,
|
|
8
9
|
type ThinkingLevel,
|
|
9
10
|
ToolRefusal,
|
|
11
|
+
type ToolTurn,
|
|
10
12
|
} from "pi-roundtable";
|
|
11
13
|
import {
|
|
12
14
|
AgentError,
|
|
13
15
|
checkRepoName,
|
|
16
|
+
type DispatchThreads,
|
|
14
17
|
skillListExtension,
|
|
15
18
|
zonedStamp,
|
|
16
19
|
} from "pi-roundtable/kit";
|
|
17
20
|
import { Type } from "typebox";
|
|
18
21
|
import {
|
|
19
22
|
CodingDesk,
|
|
23
|
+
type CodingJob,
|
|
20
24
|
type CodingResult,
|
|
25
|
+
type CodingThreadText,
|
|
21
26
|
type CodingWorker,
|
|
22
27
|
codingReport,
|
|
23
28
|
MAX_CODING_TASK_CHARS,
|
|
24
29
|
} from "./coding-desk.ts";
|
|
25
30
|
import { PiCodingWorker } from "./pi-coding-worker.ts";
|
|
26
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
type ChangeReport,
|
|
33
|
+
type CloneCommand,
|
|
34
|
+
RepoShelf,
|
|
35
|
+
type RepoSummary,
|
|
36
|
+
reportPost,
|
|
37
|
+
} from "./repo-shelf.ts";
|
|
27
38
|
|
|
28
39
|
export interface CodingService {
|
|
29
40
|
readonly shelf: RepoShelf;
|
|
30
41
|
readonly desk: CodingDesk;
|
|
31
42
|
}
|
|
32
43
|
export const CODING = serviceKey<CodingService>("coding.workbench");
|
|
44
|
+
export interface CodingRun {
|
|
45
|
+
model: string;
|
|
46
|
+
thinking: ThinkingLevel;
|
|
47
|
+
channel: ChannelKey;
|
|
48
|
+
origin?: ChannelKey;
|
|
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
|
+
}
|
|
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;
|
|
68
|
+
/** Separate the owner's posted record from the model's shipping instructions. */
|
|
69
|
+
changeReport?(
|
|
70
|
+
report: ChangeReport,
|
|
71
|
+
direct: boolean,
|
|
72
|
+
): { post: string; result: string };
|
|
73
|
+
taskStarted?(job: CodingJob, skippedSkills: string[]): string;
|
|
74
|
+
}
|
|
33
75
|
export interface CodingOptions {
|
|
34
76
|
shelfDir: string;
|
|
35
77
|
/** Provider/model-id, resolved with the worker's Pi host login. */
|
|
@@ -37,6 +79,26 @@ export interface CodingOptions {
|
|
|
37
79
|
thinking?: ThinkingLevel;
|
|
38
80
|
/** Exact owner/repo names allowed to use repo_push without a hold. Default: none. */
|
|
39
81
|
ownerRepos?: string[];
|
|
82
|
+
/** Trusted host policy for additional owner-owned repositories, including future clones.
|
|
83
|
+
* Only literal true grants an exemption. This never bypasses linked host hold rules. */
|
|
84
|
+
isOwnerRepo?: (repo: string) => boolean;
|
|
85
|
+
/** Trusted wording for the approval card of a held `repo_push`. Default: names the repository, sha, target and branch. */
|
|
86
|
+
pushHoldText?: (repo: string, sha: string) => string;
|
|
87
|
+
/** Moves legacy standalone clones before the host reads shelf-linked skills. */
|
|
88
|
+
adoptClones?: { from: string; repo: string }[];
|
|
89
|
+
/** Resolve the calling agent/owner's run identity, independently of report posting. */
|
|
90
|
+
resolveRun?: (turn: ToolTurn) => CodingRun | Promise<CodingRun>;
|
|
91
|
+
/** Post change reports in the caller's identity/channel rather than the default surface. */
|
|
92
|
+
postChangeReport?: (turn: ToolTurn, text: string) => Promise<void>;
|
|
93
|
+
presentation?: CodingPresentation;
|
|
94
|
+
/** Trusted wording of the repository tools' descriptions and arguments. */
|
|
95
|
+
toolText?: Partial<Record<RepoToolName, CodingToolText>>;
|
|
96
|
+
threads?: Pick<DispatchThreads, "open">;
|
|
97
|
+
threadText?: CodingThreadText;
|
|
98
|
+
workerWorkspace?: string;
|
|
99
|
+
workerPrompt?: (dir: string) => string;
|
|
100
|
+
/** Opt in to skipping unavailable implicit skills; explicit requests always fail closed. */
|
|
101
|
+
skipUnavailableCarriedSkills?: boolean;
|
|
40
102
|
/** Absolute installed extension package paths. Default: none. */
|
|
41
103
|
workerPackages?: string[];
|
|
42
104
|
agentDir?: string;
|
|
@@ -66,17 +128,25 @@ export function coding(options: CodingOptions) {
|
|
|
66
128
|
throw new PluginError("model must be provider/model-id.");
|
|
67
129
|
const ownerRepos = new Set(options.ownerRepos ?? []);
|
|
68
130
|
for (const repo of ownerRepos) checkRepoName(repo);
|
|
131
|
+
const ownerOwned = (repo: string): boolean => {
|
|
132
|
+
checkRepoName(repo);
|
|
133
|
+
return ownerRepos.has(repo) || options.isOwnerRepo?.(repo) === true;
|
|
134
|
+
};
|
|
69
135
|
return definePlugin({
|
|
70
136
|
name: "coding",
|
|
71
137
|
provides: [CODING],
|
|
72
138
|
setup(context) {
|
|
73
139
|
const shelf = new RepoShelf(options.shelfDir, options.clone);
|
|
140
|
+
for (const adoption of options.adoptClones ?? [])
|
|
141
|
+
shelf.adopt(adoption.from, adoption.repo);
|
|
74
142
|
const skills = context.services.find(SKILLS);
|
|
75
143
|
const worker =
|
|
76
144
|
options.worker ??
|
|
77
145
|
new PiCodingWorker({
|
|
78
146
|
packages: options.workerPackages,
|
|
79
147
|
agentDir: options.agentDir,
|
|
148
|
+
workspace: options.workerWorkspace,
|
|
149
|
+
prompt: options.workerPrompt,
|
|
80
150
|
holds: (tool, input, scope) =>
|
|
81
151
|
options.holds?.(tool, input, scope) ??
|
|
82
152
|
context.sessions().holds(tool, input, scope),
|
|
@@ -86,6 +156,8 @@ export function coding(options: CodingOptions) {
|
|
|
86
156
|
worker,
|
|
87
157
|
timeoutMs: options.timeoutMs,
|
|
88
158
|
logger: context.logger,
|
|
159
|
+
threads: options.threads,
|
|
160
|
+
threadText: options.threadText,
|
|
89
161
|
prompts: (channel) => context.surfaces.prompts(channel),
|
|
90
162
|
deliver:
|
|
91
163
|
options.onResult ??
|
|
@@ -118,14 +190,21 @@ export function coding(options: CodingOptions) {
|
|
|
118
190
|
}
|
|
119
191
|
}
|
|
120
192
|
context.services.provide(CODING, { shelf, desk });
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
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."));
|
|
124
202
|
return {
|
|
125
203
|
services: [
|
|
126
204
|
{
|
|
127
205
|
name: "coding-desk",
|
|
128
|
-
|
|
206
|
+
// The host names the channels whose work a shutdown waits for or aborts.
|
|
207
|
+
busy: () => desk.runningChannels(),
|
|
129
208
|
stop: () => desk.stop(),
|
|
130
209
|
},
|
|
131
210
|
],
|
|
@@ -148,30 +227,39 @@ export function coding(options: CodingOptions) {
|
|
|
148
227
|
defineTool({
|
|
149
228
|
name: "repo_list",
|
|
150
229
|
minTier: "owner",
|
|
151
|
-
description:
|
|
230
|
+
description: describe(
|
|
231
|
+
"repo_list",
|
|
152
232
|
"List managed clones, branch, upstream counts, dirty files, last commit, summary, CI hints and linked skills. Fetch first only when requested.",
|
|
153
|
-
|
|
233
|
+
),
|
|
234
|
+
parameters: Type.Object({
|
|
235
|
+
fetch: Type.Optional(Type.Boolean(about("repo_list", "fetch"))),
|
|
236
|
+
}),
|
|
154
237
|
run: ({ fetch }) =>
|
|
155
238
|
refusal(async () => {
|
|
156
|
-
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
|
+
});
|
|
157
250
|
return repos.length
|
|
158
|
-
? JSON.stringify(
|
|
159
|
-
repos.map((repo) => ({
|
|
160
|
-
...repo,
|
|
161
|
-
skills: skills?.linkedFrom(repo.repo) ?? [],
|
|
162
|
-
})),
|
|
163
|
-
null,
|
|
164
|
-
2,
|
|
165
|
-
)
|
|
251
|
+
? JSON.stringify(repos, null, 2)
|
|
166
252
|
: `No managed repositories in ${shelf.dir}; use repo_add.`;
|
|
167
253
|
}),
|
|
168
254
|
}),
|
|
169
255
|
defineTool({
|
|
170
256
|
name: "repo_add",
|
|
171
257
|
minTier: "owner",
|
|
172
|
-
description:
|
|
258
|
+
description: describe(
|
|
259
|
+
"repo_add",
|
|
173
260
|
"Clone owner/repo into the shelf using the host's Git login (GitHub CLI by default).",
|
|
174
|
-
|
|
261
|
+
),
|
|
262
|
+
parameters: Type.Object({ repo: repoSchema("repo_add") }),
|
|
175
263
|
run: ({ repo }) =>
|
|
176
264
|
refusal(
|
|
177
265
|
async () => `Cloned ${repo} into ${await shelf.add(repo)}.`,
|
|
@@ -180,34 +268,54 @@ export function coding(options: CodingOptions) {
|
|
|
180
268
|
defineTool({
|
|
181
269
|
name: "repo_change_report",
|
|
182
270
|
minTier: "owner",
|
|
183
|
-
description:
|
|
271
|
+
description: describe(
|
|
272
|
+
"repo_change_report",
|
|
184
273
|
"Fetch and report commits and changed files for the default branch. Requires a clean, ahead-only clone. Review checks before requesting this report.",
|
|
185
|
-
|
|
274
|
+
),
|
|
275
|
+
parameters: Type.Object({
|
|
276
|
+
repo: repoSchema("repo_change_report"),
|
|
277
|
+
}),
|
|
186
278
|
run: ({ repo }, turn) =>
|
|
187
279
|
refusal(() =>
|
|
188
280
|
ship(repo, async () => {
|
|
189
281
|
const report = await shelf.report(repo);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
282
|
+
const direct = ownerOwned(repo);
|
|
283
|
+
const text = options.presentation?.changeReport?.(
|
|
284
|
+
report,
|
|
285
|
+
direct,
|
|
286
|
+
) ?? {
|
|
287
|
+
post: reportPost(report, direct),
|
|
288
|
+
result: reportPost(report, direct),
|
|
289
|
+
};
|
|
290
|
+
if (options.postChangeReport)
|
|
291
|
+
await options.postChangeReport(turn, text.post);
|
|
292
|
+
else
|
|
293
|
+
await context.surfaces.sendReply(turn.channel, {
|
|
294
|
+
chunks: [text.post],
|
|
295
|
+
});
|
|
296
|
+
return text.result;
|
|
195
297
|
}),
|
|
196
298
|
),
|
|
197
299
|
}),
|
|
198
300
|
defineTool({
|
|
199
301
|
name: "repo_push",
|
|
200
302
|
minTier: "owner",
|
|
201
|
-
description:
|
|
202
|
-
"
|
|
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
|
+
),
|
|
203
307
|
parameters: Type.Object({
|
|
204
|
-
repo: repoSchema,
|
|
205
|
-
sha: Type.String({
|
|
308
|
+
repo: repoSchema("repo_push"),
|
|
309
|
+
sha: Type.String({
|
|
310
|
+
pattern: "^[0-9a-f]{7,40}$",
|
|
311
|
+
...about("repo_push", "sha"),
|
|
312
|
+
}),
|
|
206
313
|
}),
|
|
207
314
|
hold: ({ repo, sha }) =>
|
|
208
|
-
|
|
315
|
+
ownerOwned(repo)
|
|
209
316
|
? undefined
|
|
210
|
-
:
|
|
317
|
+
: (options.pushHoldText?.(repo, sha) ??
|
|
318
|
+
shelf.pushDescription(repo, sha)),
|
|
211
319
|
run: ({ repo, sha }) =>
|
|
212
320
|
refusal(() =>
|
|
213
321
|
ship(
|
|
@@ -220,15 +328,20 @@ export function coding(options: CodingOptions) {
|
|
|
220
328
|
defineTool({
|
|
221
329
|
name: "repo_task",
|
|
222
330
|
minTier: "owner",
|
|
223
|
-
description:
|
|
331
|
+
description: describe(
|
|
332
|
+
"repo_task",
|
|
224
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
|
+
),
|
|
225
335
|
parameters: Type.Object({
|
|
226
|
-
repo: repoSchema,
|
|
336
|
+
repo: repoSchema("repo_task"),
|
|
227
337
|
task: Type.String({
|
|
228
338
|
minLength: 1,
|
|
229
339
|
maxLength: MAX_CODING_TASK_CHARS,
|
|
340
|
+
...about("repo_task", "task"),
|
|
230
341
|
}),
|
|
231
|
-
skills: Type.Optional(
|
|
342
|
+
skills: Type.Optional(
|
|
343
|
+
Type.Array(Type.String(), about("repo_task", "skills")),
|
|
344
|
+
),
|
|
232
345
|
}),
|
|
233
346
|
run: ({ repo, task, skills: names }, turn) =>
|
|
234
347
|
refusal(async () => {
|
|
@@ -242,21 +355,39 @@ export function coding(options: CodingOptions) {
|
|
|
242
355
|
names ??
|
|
243
356
|
(turn.agent ? skills?.carriedNames(turn.agent.name) : []) ??
|
|
244
357
|
[];
|
|
245
|
-
|
|
358
|
+
if (names || !options.skipUnavailableCarriedSkills)
|
|
359
|
+
skills?.checkRegistered(carried);
|
|
246
360
|
const set = skills?.resolve(carried);
|
|
247
|
-
if (
|
|
361
|
+
if (
|
|
362
|
+
set?.missing.length &&
|
|
363
|
+
(names || !options.skipUnavailableCarriedSkills)
|
|
364
|
+
)
|
|
248
365
|
throw new AgentError(
|
|
249
|
-
|
|
366
|
+
`These skills cannot load: ${set.missing.map((item) => `${item.name} (${item.reason})`).join(", ")}`,
|
|
250
367
|
);
|
|
251
|
-
const
|
|
252
|
-
repo,
|
|
253
|
-
task,
|
|
368
|
+
const run = (await options.resolveRun?.(turn)) ?? {
|
|
254
369
|
channel: turn.channel,
|
|
255
370
|
model: options.model,
|
|
256
371
|
thinking: options.thinking ?? "medium",
|
|
372
|
+
};
|
|
373
|
+
if (shipping.has(repo))
|
|
374
|
+
throw new AgentError(
|
|
375
|
+
"A report or push is in progress for this repository.",
|
|
376
|
+
);
|
|
377
|
+
const job = await desk.start({
|
|
378
|
+
...run,
|
|
379
|
+
repo,
|
|
380
|
+
task,
|
|
257
381
|
skillFiles: set?.skills.map((skill) => skill.file) ?? [],
|
|
382
|
+
skillNames: set?.skills.map((skill) => skill.name) ?? [],
|
|
258
383
|
});
|
|
259
|
-
return
|
|
384
|
+
return (
|
|
385
|
+
options.presentation?.taskStarted?.(
|
|
386
|
+
job,
|
|
387
|
+
set?.missing.map((item) => item.name) ?? [],
|
|
388
|
+
) ??
|
|
389
|
+
`Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to ${job.channel}.${set?.missing.length ? ` Unavailable implicit skills skipped: ${set.missing.map((item) => item.name).join(", ")}.` : ""}`
|
|
390
|
+
);
|
|
260
391
|
}),
|
|
261
392
|
}),
|
|
262
393
|
],
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,19 @@ export type {
|
|
|
2
2
|
CodingDeskOptions,
|
|
3
3
|
CodingJob,
|
|
4
4
|
CodingResult,
|
|
5
|
+
CodingThreadText,
|
|
5
6
|
CodingWorker,
|
|
6
7
|
HeldCallAnswer,
|
|
7
8
|
} from "./coding-desk.ts";
|
|
8
9
|
export { CodingDesk, codingReport } from "./coding-desk.ts";
|
|
9
|
-
export type {
|
|
10
|
+
export type {
|
|
11
|
+
CodingOptions,
|
|
12
|
+
CodingPresentation,
|
|
13
|
+
CodingRun,
|
|
14
|
+
CodingService,
|
|
15
|
+
CodingToolText,
|
|
16
|
+
RepoToolName,
|
|
17
|
+
} from "./coding-plugin.ts";
|
|
10
18
|
export { CODING, coding } from "./coding-plugin.ts";
|
|
11
19
|
export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
|
|
12
20
|
export { PiCodingWorker } from "./pi-coding-worker.ts";
|
package/src/pi-coding-worker.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface PiCodingWorkerOptions {
|
|
|
12
12
|
agentDir?: string;
|
|
13
13
|
/** Additional host rules, consulted in the parent process. */
|
|
14
14
|
holds?: HoldCheck;
|
|
15
|
+
/** Trusted host policy boundary for writes; defaults to the individual clone. */
|
|
16
|
+
workspace?: string;
|
|
17
|
+
/** Trusted host standing prompt, replacing the generic worker instructions. */
|
|
18
|
+
prompt?: (dir: string) => string;
|
|
15
19
|
}
|
|
16
20
|
interface WorkerMessage {
|
|
17
21
|
type: string;
|
|
@@ -19,6 +23,7 @@ interface WorkerMessage {
|
|
|
19
23
|
tool?: string;
|
|
20
24
|
input?: Record<string, unknown>;
|
|
21
25
|
report?: string;
|
|
26
|
+
message?: string;
|
|
22
27
|
}
|
|
23
28
|
function isMessage(value: unknown): value is WorkerMessage {
|
|
24
29
|
return (
|
|
@@ -42,8 +47,10 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
42
47
|
): Promise<string> {
|
|
43
48
|
if (process.platform === "win32")
|
|
44
49
|
throw new AgentError("Coding workers require a POSIX host.");
|
|
45
|
-
if (signal.aborted) throw new AgentError("
|
|
50
|
+
if (signal.aborted) throw new AgentError("the worker was stopped");
|
|
51
|
+
const prompt = this.#options.prompt?.(job.dir);
|
|
46
52
|
let report: string | undefined;
|
|
53
|
+
let failure: string | undefined;
|
|
47
54
|
const child = Bun.spawn(
|
|
48
55
|
[
|
|
49
56
|
process.execPath,
|
|
@@ -60,15 +67,22 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
60
67
|
if (value.type === "ready") {
|
|
61
68
|
proc.send({
|
|
62
69
|
type: "start",
|
|
63
|
-
|
|
70
|
+
// Thread handles and their host callbacks never cross IPC.
|
|
71
|
+
job: { ...job, thread: undefined },
|
|
64
72
|
packages: this.#options.packages ?? [],
|
|
65
73
|
agentDir: this.#options.agentDir ?? getAgentDir(),
|
|
74
|
+
prompt,
|
|
66
75
|
});
|
|
67
76
|
} else if (
|
|
68
77
|
value.type === "report" &&
|
|
69
78
|
typeof value.report === "string"
|
|
70
79
|
) {
|
|
71
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);
|
|
72
86
|
} else if (
|
|
73
87
|
value.type === "call" &&
|
|
74
88
|
Number.isSafeInteger(value.id) &&
|
|
@@ -80,7 +94,9 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
80
94
|
void (async () => {
|
|
81
95
|
let answer: HeldCallAnswer = "held";
|
|
82
96
|
try {
|
|
83
|
-
const context = {
|
|
97
|
+
const context = {
|
|
98
|
+
workspace: this.#options.workspace ?? job.dir,
|
|
99
|
+
};
|
|
84
100
|
const action =
|
|
85
101
|
shellHoldRule.describe(tool, input, context) ??
|
|
86
102
|
this.#options.holds?.(tool, input, context);
|
|
@@ -115,7 +131,7 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
115
131
|
if (signal.aborted) kill();
|
|
116
132
|
const code = await child.exited;
|
|
117
133
|
if (signal.aborted) throw new CodingWorkerFailure("stopped");
|
|
118
|
-
if (code !== 0) throw new CodingWorkerFailure("exit", code);
|
|
134
|
+
if (code !== 0) throw new CodingWorkerFailure("exit", code, failure);
|
|
119
135
|
if (!report?.trim()) throw new CodingWorkerFailure("missing-report");
|
|
120
136
|
return report;
|
|
121
137
|
} finally {
|
package/src/repo-shelf.ts
CHANGED
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
readdirSync,
|
|
10
10
|
readSync,
|
|
11
11
|
realpathSync,
|
|
12
|
+
renameSync,
|
|
12
13
|
} from "node:fs";
|
|
13
14
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
14
|
-
import { AgentError, checkRepoName } from "pi-roundtable/kit";
|
|
15
|
+
import { AgentError, checkRepoName, scrubDiagnostic } from "pi-roundtable/kit";
|
|
15
16
|
|
|
16
17
|
/** What a clone would push to its default branch (repos-and-skills spec behavior 10). */
|
|
17
18
|
export interface ChangeReport {
|
|
@@ -91,7 +92,7 @@ export const ghClone: CloneCommand = async (repo, dir) => {
|
|
|
91
92
|
checkRepoName(repo);
|
|
92
93
|
if (repo.startsWith("-"))
|
|
93
94
|
throw new AgentError("Repository owner cannot start with a dash.");
|
|
94
|
-
const { code } = await run([
|
|
95
|
+
const { err, code } = await run([
|
|
95
96
|
"gh",
|
|
96
97
|
"repo",
|
|
97
98
|
"clone",
|
|
@@ -102,7 +103,7 @@ export const ghClone: CloneCommand = async (repo, dir) => {
|
|
|
102
103
|
]);
|
|
103
104
|
if (code !== 0)
|
|
104
105
|
throw new AgentError(
|
|
105
|
-
`Cloning ${repo} failed (exit ${code}
|
|
106
|
+
`Cloning ${repo} failed: ${scrubDiagnostic(err) || `exit ${code}`}`,
|
|
106
107
|
);
|
|
107
108
|
};
|
|
108
109
|
|
|
@@ -179,6 +180,29 @@ export class RepoShelf {
|
|
|
179
180
|
this.#clone = clone;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
/** Adopt an existing standalone clone at startup; an existing destination is never replaced. */
|
|
184
|
+
adopt(from: string, repo: string): boolean {
|
|
185
|
+
checkRepoName(repo);
|
|
186
|
+
const to = join(this.dir, repo);
|
|
187
|
+
if (
|
|
188
|
+
!existsSync(join(from, ".git")) ||
|
|
189
|
+
lstatSync(to, { throwIfNoEntry: false })
|
|
190
|
+
)
|
|
191
|
+
return false;
|
|
192
|
+
if (
|
|
193
|
+
!lstatSync(from).isDirectory() ||
|
|
194
|
+
!lstatSync(join(from, ".git")).isDirectory()
|
|
195
|
+
)
|
|
196
|
+
throw new AgentError("Only standalone local clones can be adopted.");
|
|
197
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
198
|
+
const rel = relative(realpathSync(this.dir), realpathSync(dirname(to)));
|
|
199
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
200
|
+
throw new AgentError("Repository owner directory escapes the shelf.");
|
|
201
|
+
renameSync(from, to);
|
|
202
|
+
this.dirOf(repo);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
182
206
|
/** Every clone as `<owner>/<repo>`, sorted. */
|
|
183
207
|
repos(): string[] {
|
|
184
208
|
if (!existsSync(this.dir)) return [];
|
|
@@ -304,7 +328,7 @@ export class RepoShelf {
|
|
|
304
328
|
/** A synchronous hold description from the latest report, safe to show on an owner card. */
|
|
305
329
|
pushDescription(repo: string, sha: string): string {
|
|
306
330
|
const report = this.#reports.get(repo);
|
|
307
|
-
if (!report ||
|
|
331
|
+
if (!report || !/^[0-9a-f]{7,40}$/.test(sha) || !report.sha.startsWith(sha))
|
|
308
332
|
return `Push ${repo} commit ${sha}; request a fresh change report first`;
|
|
309
333
|
return `Push ${repo} commit ${sha} to ${report.target}, branch ${report.branch}`;
|
|
310
334
|
}
|
|
@@ -314,7 +338,11 @@ export class RepoShelf {
|
|
|
314
338
|
const dir = this.dirOf(repo);
|
|
315
339
|
const head = await this.#git(dir, "rev-parse", "HEAD");
|
|
316
340
|
const report = this.#reports.get(repo);
|
|
317
|
-
if (
|
|
341
|
+
if (
|
|
342
|
+
!/^[0-9a-f]{7,40}$/.test(sha) ||
|
|
343
|
+
!report?.sha.startsWith(sha) ||
|
|
344
|
+
head !== report.sha
|
|
345
|
+
)
|
|
318
346
|
throw new AgentError(
|
|
319
347
|
`${sha} is not HEAD of ${repo} (HEAD is ${head.slice(0, 12)}); report again with repo_change_report.`,
|
|
320
348
|
);
|
|
@@ -452,10 +480,10 @@ export class RepoShelf {
|
|
|
452
480
|
}
|
|
453
481
|
|
|
454
482
|
async #git(dir: string, ...args: string[]): Promise<string> {
|
|
455
|
-
const { out, code } = await run(["git", "-C", dir, ...args]);
|
|
483
|
+
const { out, err, code } = await run(["git", "-C", dir, ...args]);
|
|
456
484
|
if (code !== 0)
|
|
457
485
|
throw new AgentError(
|
|
458
|
-
`git ${args[0]} failed
|
|
486
|
+
`git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err) || `exit ${code}`}`,
|
|
459
487
|
);
|
|
460
488
|
return out;
|
|
461
489
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface StartMessage {
|
|
|
19
19
|
job: CodingJob & { dir: string };
|
|
20
20
|
packages: string[];
|
|
21
21
|
agentDir: string;
|
|
22
|
+
prompt?: string;
|
|
22
23
|
}
|
|
23
24
|
export function codingWorkerPrompt(dir: string): string {
|
|
24
25
|
return [
|
|
@@ -33,7 +34,12 @@ export function codingWorkerPrompt(dir: string): string {
|
|
|
33
34
|
const answers = new Map<number, (answer: HeldCallAnswer) => void>();
|
|
34
35
|
let nextId = 1;
|
|
35
36
|
let started = false;
|
|
36
|
-
async function run({
|
|
37
|
+
async function run({
|
|
38
|
+
job,
|
|
39
|
+
agentDir,
|
|
40
|
+
packages,
|
|
41
|
+
prompt,
|
|
42
|
+
}: StartMessage): Promise<string> {
|
|
37
43
|
const modelRuntime = await ModelRuntime.create({
|
|
38
44
|
authPath: join(agentDir, "auth.json"),
|
|
39
45
|
modelsPath: join(agentDir, "models.json"),
|
|
@@ -89,7 +95,7 @@ async function run({ job, agentDir, packages }: StartMessage): Promise<string> {
|
|
|
89
95
|
},
|
|
90
96
|
},
|
|
91
97
|
],
|
|
92
|
-
appendSystemPrompt: [codingWorkerPrompt(job.dir)],
|
|
98
|
+
appendSystemPrompt: [prompt ?? codingWorkerPrompt(job.dir)],
|
|
93
99
|
});
|
|
94
100
|
await loader.reload();
|
|
95
101
|
const { session } = await createAgentSession({
|
|
@@ -106,7 +112,7 @@ async function run({ job, agentDir, packages }: StartMessage): Promise<string> {
|
|
|
106
112
|
model: job.model,
|
|
107
113
|
task: job.task,
|
|
108
114
|
signal: new AbortController().signal,
|
|
109
|
-
aborted: "
|
|
115
|
+
aborted: "the worker was stopped",
|
|
110
116
|
});
|
|
111
117
|
}
|
|
112
118
|
if (process.send) {
|
|
@@ -130,7 +136,14 @@ if (process.send) {
|
|
|
130
136
|
(report) => {
|
|
131
137
|
process.send?.({ type: "report", report }, () => process.exit(0));
|
|
132
138
|
},
|
|
133
|
-
() =>
|
|
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
|
+
),
|
|
134
147
|
);
|
|
135
148
|
}
|
|
136
149
|
});
|
package/src/worker-failure.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
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,
|
|
8
9
|
) {
|
|
9
|
-
|
|
10
|
+
const reason = detail ? scrubDiagnostic(detail) : "";
|
|
11
|
+
let message = "the worker was stopped";
|
|
10
12
|
if (category === "exit")
|
|
11
|
-
message =
|
|
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);
|