pi-roundtable-coding 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.3] - 2026-10-03
6
+
7
+ - 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.
8
+
5
9
  ## [0.7.2] - 2026-10-02
6
10
 
7
11
  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,17 @@ 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 and task-start/omitted-skill wording. |
170
+ | `threads` | none | Public `DispatchThreads`-compatible progress and approval thread port. |
171
+ | `threadText` | English package text | Thread introduction, held-action notice, approval title and final report. |
172
+ | `workerWorkspace` | individual clone | Trusted shell-policy write boundary; not an OS sandbox. |
173
+ | `workerPrompt` | generic worker instructions | Trusted standing prompt replacement; it cannot bypass approval or cleanup. |
174
+ | `skipUnavailableCarriedSkills` | `false` | Skip and disclose unavailable implicit skills; explicit skill requests still refuse them. |
156
175
  | `workerPackages` | `[]` | Absolute installed extension paths. |
157
176
  | `agentDir` | Pi host directory | Pi credentials and model configuration. |
158
177
  | `timeoutMs` | `3600000` | Work-time limit in ms, excluding owner wait; 0 < value <= 2147483647. |
@@ -162,6 +181,10 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
162
181
  | `onResult` | surface reply | Report delivery integration. |
163
182
 
164
183
  `RepoShelf`, `CodingDesk`, `PiCodingWorker`, `CodingWorkerFailure`, `REPO_TOOLS`, `codingReport`, `reportPost` and their option/result types are exported for testing and host integrations.
184
+ `resolveRun(turn)` receives the public `ToolTurn`, including the calling agent scope and channel.
185
+ It returns `{ model, thinking, channel, origin? }`; skill selection still follows the caller, not the resolved report destination.
186
+ `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.
187
+ `adoptClones` and `RepoShelf.adopt` keep existing clone contents and path layout without database migrations.
165
188
  No database or Discord-specific types are required.
166
189
 
167
190
  ## 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.3",
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.3",
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,7 +117,7 @@ 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();
@@ -146,6 +166,8 @@ export class CodingDesk {
146
166
  shelf,
147
167
  worker,
148
168
  prompts,
169
+ threads,
170
+ threadText,
149
171
  deliver,
150
172
  timeoutMs = 3_600_000,
151
173
  } = this.#options;
@@ -159,7 +181,29 @@ export class CodingDesk {
159
181
  job.startHead = (await shelf.state(job.repo)).head;
160
182
  if (controller.signal.aborted)
161
183
  throw new AgentError("The worker was stopped.");
162
- slot.bind(prompts(job.channel), `Coding worker #${job.id}`);
184
+ if (threads) {
185
+ try {
186
+ const thread = await threads.open(
187
+ job.origin,
188
+ `${job.repo} #${job.id}`,
189
+ threadText?.initial?.(job) ?? `Task:\n${job.task}`,
190
+ );
191
+ if (thread) job.thread = thread;
192
+ } catch {
193
+ this.#options.logger.warn(
194
+ { job: job.id },
195
+ "Coding thread unavailable.",
196
+ );
197
+ }
198
+ }
199
+ if (controller.signal.aborted)
200
+ throw new AgentError("The worker was stopped.");
201
+ slot.bind(
202
+ threads
203
+ ? job.thread && prompts?.(job.thread.channel)
204
+ : prompts?.(job.channel),
205
+ `Coding worker #${job.id}`,
206
+ );
163
207
  cancel = workTimeout(timeoutMs, slot, () => {
164
208
  timedOut = true;
165
209
  controller.abort();
@@ -168,7 +212,8 @@ export class CodingDesk {
168
212
  let answer: HeldCallAnswer = "held";
169
213
  try {
170
214
  const decision = await slot.prompts?.confirm(
171
- `${slot.asker} requests approval`,
215
+ threadText?.approvalTitle?.(job) ??
216
+ `${slot.asker} requests approval`,
172
217
  approvalCard(call),
173
218
  controller.signal,
174
219
  );
@@ -178,14 +223,20 @@ export class CodingDesk {
178
223
  /* A failed card never authorizes the call. */
179
224
  }
180
225
  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
- ),
226
+ if (held.length < MAX_HELD_ENTRIES) {
227
+ const entry = bounded(
228
+ `${answer}: ${call.action}: ${call.tool} ${call.input}`,
229
+ MAX_HELD_CHARS,
187
230
  );
188
- else omittedHeld++;
231
+ held.push(entry);
232
+ try {
233
+ await job.thread?.post(
234
+ threadText?.held?.(entry, job) ?? `Held: ${entry}`,
235
+ );
236
+ } catch {
237
+ /* A failed progress post does not discard the held action. */
238
+ }
239
+ } else omittedHeld++;
189
240
  }
190
241
  return answer;
191
242
  };
@@ -230,7 +281,25 @@ export class CodingDesk {
230
281
  }
231
282
  if (omittedHeld)
232
283
  held.push(`[${omittedHeld} more unapproved actions omitted]`);
233
- await deliver({ job, outcome, held, ...(state ? { state } : {}), commits });
284
+ const result: CodingResult = {
285
+ job,
286
+ outcome,
287
+ held,
288
+ ...(state ? { state } : {}),
289
+ commits,
290
+ };
291
+ try {
292
+ await job.thread?.close(
293
+ bounded(
294
+ threadText?.report?.(result) ??
295
+ codingReport(result, job.startedAt.toISOString()),
296
+ MAX_REPORT_CHARS,
297
+ ),
298
+ );
299
+ } catch {
300
+ this.#options.logger.warn({ job: job.id }, "Coding thread close failed.");
301
+ }
302
+ await deliver(result);
234
303
  }
235
304
  }
236
305
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ChannelKey,
2
3
  definePlugin,
3
4
  defineTool,
4
5
  type HoldCheck,
@@ -7,29 +8,52 @@ 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
+ reportPost,
36
+ } from "./repo-shelf.ts";
27
37
 
28
38
  export interface CodingService {
29
39
  readonly shelf: RepoShelf;
30
40
  readonly desk: CodingDesk;
31
41
  }
32
42
  export const CODING = serviceKey<CodingService>("coding.workbench");
43
+ export interface CodingRun {
44
+ model: string;
45
+ thinking: ThinkingLevel;
46
+ channel: ChannelKey;
47
+ origin?: ChannelKey;
48
+ }
49
+ export interface CodingPresentation {
50
+ /** Separate the owner's posted record from the model's shipping instructions. */
51
+ changeReport?(
52
+ report: ChangeReport,
53
+ direct: boolean,
54
+ ): { post: string; result: string };
55
+ taskStarted?(job: CodingJob, skippedSkills: string[]): string;
56
+ }
33
57
  export interface CodingOptions {
34
58
  shelfDir: string;
35
59
  /** Provider/model-id, resolved with the worker's Pi host login. */
@@ -37,6 +61,24 @@ export interface CodingOptions {
37
61
  thinking?: ThinkingLevel;
38
62
  /** Exact owner/repo names allowed to use repo_push without a hold. Default: none. */
39
63
  ownerRepos?: string[];
64
+ /** Trusted host policy for additional owner-owned repositories, including future clones.
65
+ * Only literal true grants an exemption. This never bypasses linked host hold rules. */
66
+ isOwnerRepo?: (repo: string) => boolean;
67
+ /** Trusted wording for the approval card of a held `repo_push`. Default: names the repository, sha, target and branch. */
68
+ pushHoldText?: (repo: string, sha: string) => string;
69
+ /** Moves legacy standalone clones before the host reads shelf-linked skills. */
70
+ adoptClones?: { from: string; repo: string }[];
71
+ /** Resolve the calling agent/owner's run identity, independently of report posting. */
72
+ resolveRun?: (turn: ToolTurn) => CodingRun | Promise<CodingRun>;
73
+ /** Post change reports in the caller's identity/channel rather than the default surface. */
74
+ postChangeReport?: (turn: ToolTurn, text: string) => Promise<void>;
75
+ presentation?: CodingPresentation;
76
+ threads?: Pick<DispatchThreads, "open">;
77
+ threadText?: CodingThreadText;
78
+ workerWorkspace?: string;
79
+ workerPrompt?: (dir: string) => string;
80
+ /** Opt in to skipping unavailable implicit skills; explicit requests always fail closed. */
81
+ skipUnavailableCarriedSkills?: boolean;
40
82
  /** Absolute installed extension package paths. Default: none. */
41
83
  workerPackages?: string[];
42
84
  agentDir?: string;
@@ -66,17 +108,25 @@ export function coding(options: CodingOptions) {
66
108
  throw new PluginError("model must be provider/model-id.");
67
109
  const ownerRepos = new Set(options.ownerRepos ?? []);
68
110
  for (const repo of ownerRepos) checkRepoName(repo);
111
+ const ownerOwned = (repo: string): boolean => {
112
+ checkRepoName(repo);
113
+ return ownerRepos.has(repo) || options.isOwnerRepo?.(repo) === true;
114
+ };
69
115
  return definePlugin({
70
116
  name: "coding",
71
117
  provides: [CODING],
72
118
  setup(context) {
73
119
  const shelf = new RepoShelf(options.shelfDir, options.clone);
120
+ for (const adoption of options.adoptClones ?? [])
121
+ shelf.adopt(adoption.from, adoption.repo);
74
122
  const skills = context.services.find(SKILLS);
75
123
  const worker =
76
124
  options.worker ??
77
125
  new PiCodingWorker({
78
126
  packages: options.workerPackages,
79
127
  agentDir: options.agentDir,
128
+ workspace: options.workerWorkspace,
129
+ prompt: options.workerPrompt,
80
130
  holds: (tool, input, scope) =>
81
131
  options.holds?.(tool, input, scope) ??
82
132
  context.sessions().holds(tool, input, scope),
@@ -86,6 +136,8 @@ export function coding(options: CodingOptions) {
86
136
  worker,
87
137
  timeoutMs: options.timeoutMs,
88
138
  logger: context.logger,
139
+ threads: options.threads,
140
+ threadText: options.threadText,
89
141
  prompts: (channel) => context.surfaces.prompts(channel),
90
142
  deliver:
91
143
  options.onResult ??
@@ -187,11 +239,21 @@ export function coding(options: CodingOptions) {
187
239
  refusal(() =>
188
240
  ship(repo, async () => {
189
241
  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;
242
+ const direct = ownerOwned(repo);
243
+ const text = options.presentation?.changeReport?.(
244
+ report,
245
+ direct,
246
+ ) ?? {
247
+ post: reportPost(report, direct),
248
+ result: reportPost(report, direct),
249
+ };
250
+ if (options.postChangeReport)
251
+ await options.postChangeReport(turn, text.post);
252
+ else
253
+ await context.surfaces.sendReply(turn.channel, {
254
+ chunks: [text.post],
255
+ });
256
+ return text.result;
195
257
  }),
196
258
  ),
197
259
  }),
@@ -199,15 +261,16 @@ export function coding(options: CodingOptions) {
199
261
  name: "repo_push",
200
262
  minTier: "owner",
201
263
  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.",
264
+ "Push the exact full SHA from the latest change report to its default branch, without force. Held for owner approval unless the trusted host policy explicitly marks the clone owner-owned.",
203
265
  parameters: Type.Object({
204
266
  repo: repoSchema,
205
267
  sha: Type.String({ pattern: "^[0-9a-f]{40}$" }),
206
268
  }),
207
269
  hold: ({ repo, sha }) =>
208
- ownerRepos.has(repo)
270
+ ownerOwned(repo)
209
271
  ? undefined
210
- : shelf.pushDescription(repo, sha),
272
+ : (options.pushHoldText?.(repo, sha) ??
273
+ shelf.pushDescription(repo, sha)),
211
274
  run: ({ repo, sha }) =>
212
275
  refusal(() =>
213
276
  ship(
@@ -242,21 +305,39 @@ export function coding(options: CodingOptions) {
242
305
  names ??
243
306
  (turn.agent ? skills?.carriedNames(turn.agent.name) : []) ??
244
307
  [];
245
- skills?.checkRegistered(carried);
308
+ if (names || !options.skipUnavailableCarriedSkills)
309
+ skills?.checkRegistered(carried);
246
310
  const set = skills?.resolve(carried);
247
- if (set?.missing.length)
311
+ if (
312
+ set?.missing.length &&
313
+ (names || !options.skipUnavailableCarriedSkills)
314
+ )
248
315
  throw new AgentError(
249
- "Some carried skills cannot load; inspect skill_list.",
316
+ `These skills cannot load: ${set.missing.map((item) => `${item.name} (${item.reason})`).join(", ")}`,
250
317
  );
251
- const job = await desk.start({
252
- repo,
253
- task,
318
+ const run = (await options.resolveRun?.(turn)) ?? {
254
319
  channel: turn.channel,
255
320
  model: options.model,
256
321
  thinking: options.thinking ?? "medium",
322
+ };
323
+ if (shipping.has(repo))
324
+ throw new AgentError(
325
+ "A report or push is in progress for this repository.",
326
+ );
327
+ const job = await desk.start({
328
+ ...run,
329
+ repo,
330
+ task,
257
331
  skillFiles: set?.skills.map((skill) => skill.file) ?? [],
332
+ skillNames: set?.skills.map((skill) => skill.name) ?? [],
258
333
  });
259
- return `Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to this channel.`;
334
+ return (
335
+ options.presentation?.taskStarted?.(
336
+ job,
337
+ set?.missing.map((item) => item.name) ?? [],
338
+ ) ??
339
+ `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(", ")}.` : ""}`
340
+ );
260
341
  }),
261
342
  }),
262
343
  ],
package/src/index.ts CHANGED
@@ -2,11 +2,17 @@ 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
+ } from "./coding-plugin.ts";
10
16
  export { CODING, coding } from "./coding-plugin.ts";
11
17
  export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
12
18
  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;
@@ -43,6 +47,7 @@ export class PiCodingWorker implements CodingWorker {
43
47
  if (process.platform === "win32")
44
48
  throw new AgentError("Coding workers require a POSIX host.");
45
49
  if (signal.aborted) throw new AgentError("The worker was stopped.");
50
+ const prompt = this.#options.prompt?.(job.dir);
46
51
  let report: string | undefined;
47
52
  const child = Bun.spawn(
48
53
  [
@@ -60,9 +65,11 @@ export class PiCodingWorker implements CodingWorker {
60
65
  if (value.type === "ready") {
61
66
  proc.send({
62
67
  type: "start",
63
- job,
68
+ // Thread handles and their host callbacks never cross IPC.
69
+ job: { ...job, thread: undefined },
64
70
  packages: this.#options.packages ?? [],
65
71
  agentDir: this.#options.agentDir ?? getAgentDir(),
72
+ prompt,
66
73
  });
67
74
  } else if (
68
75
  value.type === "report" &&
@@ -80,7 +87,9 @@ export class PiCodingWorker implements CodingWorker {
80
87
  void (async () => {
81
88
  let answer: HeldCallAnswer = "held";
82
89
  try {
83
- const context = { workspace: job.dir };
90
+ const context = {
91
+ workspace: this.#options.workspace ?? job.dir,
92
+ };
84
93
  const action =
85
94
  shellHoldRule.describe(tool, input, context) ??
86
95
  this.#options.holds?.(tool, input, context);
package/src/repo-shelf.ts CHANGED
@@ -9,6 +9,7 @@ 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
15
  import { AgentError, checkRepoName } from "pi-roundtable/kit";
@@ -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 [];
@@ -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({