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 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
- They do not exempt shell pushes or risky worker actions.
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 `ownerRepos`.
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.2",
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.2",
47
+ "pi-roundtable": "0.7.4",
48
48
  "typescript": "7.0.2"
49
49
  }
50
50
  }
@@ -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 || task.length > MAX_CODING_TASK_CHARS)
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 must contain 1–${MAX_CODING_TASK_CHARS} characters.`,
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.checkIdle(request.repo);
110
- if (
111
- [...this.#running.values()].filter(
112
- ({ job }) => job.channel === request.channel,
113
- ).length >= 3
114
- )
115
- throw new AgentError("This channel already has three coding workers.");
130
+ const busy = [...this.#running.values()].find(
131
+ ({ job }) => job.repo === request.repo,
132
+ );
133
+ if (busy)
134
+ throw new AgentError(
135
+ `Coding task #${busy.job.id} is still working in ${request.repo}; one worker runs per repository.`,
136
+ );
137
+ const inChannel = [...this.#running.values()].filter(
138
+ ({ job }) => job.channel === request.channel,
139
+ ).length;
140
+ if (inChannel >= 3)
141
+ throw new AgentError(
142
+ `This channel already has ${inChannel} coding workers running; wait for one to report back.`,
143
+ );
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("The worker was stopped.");
162
- slot.bind(prompts(job.channel), `Coding worker #${job.id}`);
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
- `${slot.asker} requests approval`,
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
- if (answer !== "approved") {
181
- if (held.length < MAX_HELD_ENTRIES)
182
- held.push(
183
- bounded(
184
- `${answer}: ${call.action}: ${call.tool} ${call.input}`,
185
- MAX_HELD_CHARS,
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
- else omittedHeld++;
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("The worker was stopped.");
258
+ throw new AgentError("the worker was stopped");
199
259
  outcome = { ok: true, report: bounded(report, MAX_REPORT_CHARS) };
200
260
  } catch (error) {
201
- // Only structured, fixed diagnostics can cross the worker boundary.
261
+ // The host's own refusals and the worker's scrubbed, bounded error text reach the report.
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 = "The worker was stopped.";
271
+ if (controller.signal.aborted) message = "the worker was stopped";
212
272
  if (timedOut)
213
- message = `Work timeout after ${timeoutMs} ms (owner wait excluded).`;
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
- await deliver({ job, outcome, held, ...(state ? { state } : {}), commits });
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
 
@@ -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 { type CloneCommand, RepoShelf, reportPost } from "./repo-shelf.ts";
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 repoSchema = Type.String({
122
- description: "A managed repository, owner/repo.",
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
- busy: () => desk.busy(),
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
- parameters: Type.Object({ fetch: Type.Optional(Type.Boolean()) }),
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
- parameters: Type.Object({ repo: repoSchema }),
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
- parameters: Type.Object({ repo: repoSchema }),
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 text = reportPost(report, ownerRepos.has(repo));
191
- await context.surfaces.sendReply(turn.channel, {
192
- chunks: [text],
193
- });
194
- return text;
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
- "Push the exact full SHA from the latest change report to its default branch, without force. Held for owner approval unless ownerRepos explicitly lists the clone.",
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({ pattern: "^[0-9a-f]{40}$" }),
308
+ repo: repoSchema("repo_push"),
309
+ sha: Type.String({
310
+ pattern: "^[0-9a-f]{7,40}$",
311
+ ...about("repo_push", "sha"),
312
+ }),
206
313
  }),
207
314
  hold: ({ repo, sha }) =>
208
- ownerRepos.has(repo)
315
+ ownerOwned(repo)
209
316
  ? undefined
210
- : shelf.pushDescription(repo, sha),
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(Type.Array(Type.String())),
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
- skills?.checkRegistered(carried);
358
+ if (names || !options.skipUnavailableCarriedSkills)
359
+ skills?.checkRegistered(carried);
246
360
  const set = skills?.resolve(carried);
247
- if (set?.missing.length)
361
+ if (
362
+ set?.missing.length &&
363
+ (names || !options.skipUnavailableCarriedSkills)
364
+ )
248
365
  throw new AgentError(
249
- "Some carried skills cannot load; inspect skill_list.",
366
+ `These skills cannot load: ${set.missing.map((item) => `${item.name} (${item.reason})`).join(", ")}`,
250
367
  );
251
- const job = await desk.start({
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 `Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to this channel.`;
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 { CodingOptions, CodingService } from "./coding-plugin.ts";
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";
@@ -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("The worker was stopped.");
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
- job,
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 = { workspace: job.dir };
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}); check the host's Git login.`,
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 || report.sha !== sha)
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 (!report || report.sha !== sha || head !== sha)
341
+ if (
342
+ !/^[0-9a-f]{7,40}$/.test(sha) ||
343
+ !report?.sha.startsWith(sha) ||
344
+ head !== report.sha
345
+ )
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 (exit ${code}); check the clone and the host's Git login.`,
486
+ `git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err) || `exit ${code}`}`,
459
487
  );
460
488
  return out;
461
489
  }
@@ -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({ job, agentDir, packages }: StartMessage): Promise<string> {
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: "The worker was stopped.",
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
- () => process.exit(1),
139
+ (error: unknown) =>
140
+ process.send?.(
141
+ {
142
+ type: "failure",
143
+ message: error instanceof Error ? error.message : String(error),
144
+ },
145
+ () => process.exit(1),
146
+ ),
134
147
  );
135
148
  }
136
149
  });
@@ -1,14 +1,18 @@
1
- import { AgentError } from "pi-roundtable/kit";
1
+ import { AgentError, scrubDiagnostic } from "pi-roundtable/kit";
2
2
 
3
- /** Structured diagnostics never carry provider responses, stderr or credentials. */
3
+ /** Worker diagnostics reach the owner scrubbed of credentials and bounded; stderr and raw responses never do. */
4
4
  export class CodingWorkerFailure extends AgentError {
5
5
  constructor(
6
6
  readonly category: "stopped" | "exit" | "missing-report",
7
7
  readonly exitCode?: number,
8
+ detail?: string,
8
9
  ) {
9
- let message = "The coding worker was stopped.";
10
+ const reason = detail ? scrubDiagnostic(detail) : "";
11
+ let message = "the worker was stopped";
10
12
  if (category === "exit")
11
- message = `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
13
+ message =
14
+ reason ||
15
+ `The coding worker exited (code ${exitCode}); check its model, login and package configuration.`;
12
16
  if (category === "missing-report")
13
17
  message = "The coding worker returned no report.";
14
18
  super(message);