pi-roundtable-coding 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.5] - 2026-10-10
6
+
7
+ 0.9.4 was tagged but never published (its release checks failed on the recorded prompt texts); everything listed under it is released in 0.9.5.
8
+
9
+ ### Added
10
+
11
+ - A coding worker posts its progress into its thread as it works, as the host's turns do: longer or structured text as ordinary messages, and short narration with the tools it called in one small-text progress message edited in place. Its approval cards come after the text written before them, the report is posted once at the end, and a failed post or edit is logged without failing the job. `interimText: "off"` (on `coding` and `CodingDeskOptions`) keeps today's thread of task, cards and report; `interimPrimaryChars` sets the length of primary text (default 400). It needs pi-roundtable 0.9.5's `DispatchThread.interim`.
12
+ - `CodingWorker.run` takes an optional fourth argument, `CodingProgress` (`messageEnd(message)`, `toolStart(name)`), which `PiCodingWorker` feeds from the worker's session over its IPC channel (message text and tool names only, never thinking).
13
+
14
+ ### Changed
15
+
16
+ - A worker's approval card no longer expires after 30 minutes: the worker waits for the answer, with no countdown on the card, until it is stopped; the waiting does not count against `timeoutMs`.
17
+
18
+ ### Fixed
19
+
20
+ - A worker's shell gets the host's scratch dir (`env.scratchDir`): TMPDIR points to it, and writes, redirects and removals inside it run without the owner's approval, as in the agents' shell. Before, every scratch-dir command a worker ran asked the owner.
21
+ - A worker whose host has a scratch dir is told it in its system prompt, after the host's own prompt or the default, so a task need not say where temporary files and test homes go.
22
+
5
23
  ## [0.9.3] - 2026-10-10
6
24
 
7
25
  - Release in lockstep with pi-roundtable 0.9.3; no package-specific behavior changes.
package/README.md CHANGED
@@ -128,9 +128,13 @@ The parent consults `shellHoldRule`, additional `holds`, and the host's linked h
128
128
  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.
129
129
  Without `threads`, the resolved report channel's prompts are used.
130
130
  `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.
131
+ While it works, the worker posts its progress into its thread, as the host's own turns do: text of `interimPrimaryChars` (400) characters or more, or written with a Markdown heading, list, table or code fence, as ordinary messages, and short narration with the tools it called in one small-text progress message edited in place.
132
+ The thread's cards come after the text written before them, the final report is posted once, and a failed progress post or edit is logged and never fails the job.
133
+ `interimText: "off"` posts only the task, the held-action notices, the cards and the report; a thread whose host cannot edit its posts gets no progress either.
131
134
  `promptSlot` tracks these cards and `workTimeout` excludes their waiting time from the worker's budget (one hour by default).
132
135
  `timeoutMs` must be positive and finite, and at most 2,147,483,647 ms to fit the host timer.
133
- 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.
136
+ A worker's card has no grace period and no deadline: the worker waits for the answer until it is stopped, and the waiting does not count against its time.
137
+ An approved call runs; a declined, missing or failed card blocks it and instructs the worker not to retry or work around the refusal.
134
138
  Unapproved actions appear in the report rather than running later automatically.
135
139
  By default worker answers are capped at 20,000 characters and the Held list keeps ten entries of at most 1,000 characters each, with explicit truncation and omission notices; `limits` changes each bound.
136
140
  A worker failure carries its exit category and numeric code; git and gh stderr and the worker's own error text reach the report only after `scrubDiagnostic` masks credentials, and are cut at `diagnosticChars`.
@@ -181,6 +185,8 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
181
185
  | `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. |
182
186
  | `threads` | none | Public `DispatchThreads`-compatible progress and approval thread port. |
183
187
  | `threadText` | English package text | Thread introduction, held-action notice, approval title and final report. |
188
+ | `interimText` | `"on"` | `"off"` keeps the worker's progress out of its thread; pass the host config's `interimText` to follow it. |
189
+ | `interimPrimaryChars` | `400` | Length from which a worker's intermediate text is posted as its own message. |
184
190
  | `workerWorkspace` | individual clone | Trusted shell-policy write boundary; not an OS sandbox. |
185
191
  | `workerPrompt` | generic worker instructions | Trusted standing prompt replacement; it cannot bypass approval or cleanup. |
186
192
  | `workerBlockText` | "The owner declined / has not approved this call. Do not retry it or work around it; list it under Held in your report." | Trusted wording of what the worker reads when a call is declined or held, from `(answer, action)`; it never changes who is held, and the desk lists only unanswered calls as held. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-coding",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Repository shelves and owner-approved Pi coding workers for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "@earendil-works/pi-ai": ">=1.0.0 <2",
45
45
  "@biomejs/biome": "2.5.15",
46
46
  "@types/bun": "1.4.2",
47
- "pi-roundtable": "0.9.3",
47
+ "pi-roundtable": "0.9.5",
48
48
  "typescript": "7.0.2"
49
49
  }
50
50
  }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ChannelKey,
3
3
  HeldCall,
4
+ InterimTextMode,
4
5
  Logger,
5
6
  OwnerPrompts,
6
7
  ThinkingLevel,
@@ -10,6 +11,7 @@ import {
10
11
  approvalCard,
11
12
  type DispatchThread,
12
13
  type DispatchThreads,
14
+ InterimPoster,
13
15
  promptSlot,
14
16
  workTimeout,
15
17
  } from "pi-roundtable/kit";
@@ -34,11 +36,24 @@ export interface CodingJob {
34
36
  startedAt: Date;
35
37
  startHead: string;
36
38
  }
39
+ /** What the worker writes and runs as it goes, for its thread's progress posts. */
40
+ export interface CodingProgress {
41
+ /** A finished message of the worker's session: its role, stop reason, and text. */
42
+ messageEnd(message: {
43
+ role: string;
44
+ content?: unknown;
45
+ stopReason?: string;
46
+ }): void;
47
+ /** A tool the worker started. */
48
+ toolStart(name: string): void;
49
+ }
37
50
  export interface CodingWorker {
51
+ /** `progress`, when given, hears the session's messages and tools as the worker goes. */
38
52
  run(
39
53
  job: CodingJob & { dir: string },
40
54
  signal: AbortSignal,
41
55
  review: (call: HeldCall) => Promise<HeldCallAnswer>,
56
+ progress?: CodingProgress,
42
57
  ): Promise<string>;
43
58
  }
44
59
  export interface CodingResult {
@@ -65,6 +80,13 @@ export interface CodingDeskOptions {
65
80
  logger: Logger;
66
81
  timeoutMs?: number;
67
82
  limits?: CodingLimits;
83
+ /**
84
+ * Whether the worker posts the text it writes before its report into its thread as it goes,
85
+ * as the host's turns do; "on" by default. A thread whose host cannot edit its posts gets none.
86
+ */
87
+ interimText?: InterimTextMode;
88
+ /** Length from which an intermediate text is posted as its own message; default 400. */
89
+ interimPrimaryChars?: number;
68
90
  }
69
91
  /** What a coding report keeps of a long run; each is a count of characters or entries, or `Infinity` for no bound. */
70
92
  export interface CodingLimits {
@@ -212,6 +234,7 @@ export class CodingDesk {
212
234
  this.#options.limits?.heldEntries ?? MAX_HELD_ENTRIES;
213
235
  const maxHeldChars = this.#options.limits?.heldChars ?? MAX_HELD_CHARS;
214
236
  let cancel = () => {};
237
+ let interim: InterimPoster | undefined;
215
238
  let timedOut = false;
216
239
  let outcome: CodingResult["outcome"];
217
240
  try {
@@ -235,11 +258,22 @@ export class CodingDesk {
235
258
  }
236
259
  if (controller.signal.aborted)
237
260
  throw new AgentError("the worker was stopped");
261
+ // The worker's progress goes to its thread as it works, under the report's name.
262
+ if (job.thread?.interim && this.#options.interimText !== "off")
263
+ interim = new InterimPoster(job.thread.interim, {
264
+ logger: this.#options.logger,
265
+ channel: job.thread.channel,
266
+ ...(this.#options.interimPrimaryChars
267
+ ? { primaryChars: this.#options.interimPrimaryChars }
268
+ : {}),
269
+ });
270
+ const poster = interim;
238
271
  slot.bind(
239
272
  threads
240
273
  ? job.thread && prompts?.(job.thread.channel)
241
274
  : prompts?.(job.channel),
242
275
  `Coding worker #${job.id}`,
276
+ poster ? () => poster.flush() : undefined,
243
277
  );
244
278
  cancel = workTimeout(timeoutMs, slot, () => {
245
279
  timedOut = true;
@@ -268,6 +302,7 @@ export class CodingDesk {
268
302
  );
269
303
  held.push(entry);
270
304
  try {
305
+ await interim?.flush();
271
306
  await job.thread?.post(
272
307
  threadText?.held?.(entry, job) ?? `Held: ${entry}`,
273
308
  );
@@ -282,6 +317,7 @@ export class CodingDesk {
282
317
  { ...job, dir },
283
318
  controller.signal,
284
319
  review,
320
+ interim,
285
321
  );
286
322
  if (controller.signal.aborted)
287
323
  throw new AgentError("the worker was stopped");
@@ -304,6 +340,8 @@ export class CodingDesk {
304
340
  } finally {
305
341
  cancel();
306
342
  slot.unbind();
343
+ // What the worker wrote lands before its report.
344
+ await interim?.flush();
307
345
  }
308
346
  let state: RepoState | undefined;
309
347
  let commits: string[] = [];
@@ -4,6 +4,7 @@ import {
4
4
  defineTool,
5
5
  type HoldCheck,
6
6
  IDENTITY,
7
+ type InterimTextMode,
7
8
  PluginError,
8
9
  SKILLS,
9
10
  SYSTEM_PRINCIPAL,
@@ -99,6 +100,14 @@ export interface CodingOptions {
99
100
  toolText?: Partial<Record<RepoToolName, CodingToolText>>;
100
101
  threads?: Pick<DispatchThreads, "open">;
101
102
  threadText?: CodingThreadText;
103
+ /**
104
+ * Whether a worker posts its progress into its thread as it goes: longer text as messages, short
105
+ * narration and its tools in one edited small-text message. Default "on"; "off" posts only the
106
+ * task and the report. Pass the host config's `interimText` to follow it.
107
+ */
108
+ interimText?: InterimTextMode;
109
+ /** Length from which a worker's intermediate text is its own message; default 400. */
110
+ interimPrimaryChars?: number;
102
111
  workerWorkspace?: string;
103
112
  workerPrompt?: (dir: string) => string;
104
113
  /** How much of a long run a report keeps; see `CodingLimits`. */
@@ -168,6 +177,9 @@ export function coding(options: CodingOptions) {
168
177
  packages: options.workerPackages,
169
178
  agentDir: options.agentDir,
170
179
  workspace: options.workerWorkspace,
180
+ ...(context.env.scratchDir
181
+ ? { scratchDir: context.env.scratchDir }
182
+ : {}),
171
183
  prompt: options.workerPrompt,
172
184
  blockText: options.workerBlockText,
173
185
  diagnosticChars: options.diagnosticChars,
@@ -183,6 +195,10 @@ export function coding(options: CodingOptions) {
183
195
  logger: context.logger,
184
196
  threads: options.threads,
185
197
  threadText: options.threadText,
198
+ ...(options.interimText ? { interimText: options.interimText } : {}),
199
+ ...(options.interimPrimaryChars
200
+ ? { interimPrimaryChars: options.interimPrimaryChars }
201
+ : {}),
186
202
  prompts: (channel) => context.surfaces.prompts(channel),
187
203
  deliver:
188
204
  options.onResult ??
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export type {
2
2
  CodingDeskOptions,
3
3
  CodingJob,
4
4
  CodingLimits,
5
+ CodingProgress,
5
6
  CodingResult,
6
7
  CodingThreadText,
7
8
  CodingWorker,
@@ -2,7 +2,12 @@ import { fileURLToPath } from "node:url";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
  import type { HeldCall, HoldCheck } from "pi-roundtable";
4
4
  import { AgentError, canonicalJson, shellHoldRule } from "pi-roundtable/kit";
5
- import type { CodingJob, CodingWorker, HeldCallAnswer } from "./coding-desk.ts";
5
+ import type {
6
+ CodingJob,
7
+ CodingProgress,
8
+ CodingWorker,
9
+ HeldCallAnswer,
10
+ } from "./coding-desk.ts";
6
11
  import { CodingWorkerFailure } from "./worker-failure.ts";
7
12
 
8
13
  export interface PiCodingWorkerOptions {
@@ -14,6 +19,11 @@ export interface PiCodingWorkerOptions {
14
19
  holds?: HoldCheck;
15
20
  /** Trusted host policy boundary for writes; defaults to the individual clone. */
16
21
  workspace?: string;
22
+ /**
23
+ * The host's scratch dir: the worker's shell runs with TMPDIR pointing to it, and writes and
24
+ * removals inside it run without a hold, as in the agents' shell.
25
+ */
26
+ scratchDir?: string;
17
27
  /** Trusted host standing prompt, replacing the generic worker instructions. */
18
28
  prompt?: (dir: string) => string;
19
29
  /**
@@ -31,6 +41,10 @@ interface WorkerMessage {
31
41
  input?: Record<string, unknown>;
32
42
  report?: string;
33
43
  message?: string;
44
+ role?: string;
45
+ stopReason?: string;
46
+ text?: string;
47
+ name?: string;
34
48
  }
35
49
  function isMessage(value: unknown): value is WorkerMessage {
36
50
  return (
@@ -51,6 +65,7 @@ export class PiCodingWorker implements CodingWorker {
51
65
  job: CodingJob & { dir: string },
52
66
  signal: AbortSignal,
53
67
  review: (call: HeldCall) => Promise<HeldCallAnswer>,
68
+ progress?: CodingProgress,
54
69
  ): Promise<string> {
55
70
  if (process.platform === "win32")
56
71
  throw new AgentError("Coding workers require a POSIX host.");
@@ -65,6 +80,9 @@ export class PiCodingWorker implements CodingWorker {
65
80
  ],
66
81
  {
67
82
  cwd: job.dir,
83
+ ...(this.#options.scratchDir
84
+ ? { env: { ...process.env, TMPDIR: this.#options.scratchDir } }
85
+ : {}),
68
86
  stdin: "ignore",
69
87
  stdout: "ignore",
70
88
  stderr: "ignore",
@@ -79,12 +97,30 @@ export class PiCodingWorker implements CodingWorker {
79
97
  packages: this.#options.packages ?? [],
80
98
  agentDir: this.#options.agentDir ?? getAgentDir(),
81
99
  prompt,
100
+ progress: progress !== undefined,
101
+ ...(this.#options.scratchDir
102
+ ? { scratchDir: this.#options.scratchDir }
103
+ : {}),
82
104
  });
83
105
  } else if (
84
106
  value.type === "report" &&
85
107
  typeof value.report === "string"
86
108
  ) {
87
109
  report = value.report;
110
+ } else if (
111
+ value.type === "message" &&
112
+ typeof value.role === "string" &&
113
+ typeof value.text === "string"
114
+ ) {
115
+ progress?.messageEnd({
116
+ role: value.role,
117
+ content: value.text,
118
+ ...(typeof value.stopReason === "string"
119
+ ? { stopReason: value.stopReason }
120
+ : {}),
121
+ });
122
+ } else if (value.type === "tool" && typeof value.name === "string") {
123
+ progress?.toolStart(value.name);
88
124
  } else if (
89
125
  value.type === "failure" &&
90
126
  typeof value.message === "string"
@@ -109,6 +145,9 @@ export class PiCodingWorker implements CodingWorker {
109
145
  try {
110
146
  const context = {
111
147
  workspace: this.#options.workspace ?? job.dir,
148
+ ...(this.#options.scratchDir
149
+ ? { scratchDir: this.#options.scratchDir }
150
+ : {}),
112
151
  };
113
152
  const action =
114
153
  shellHoldRule.describe(tool, input, context) ??
@@ -11,6 +11,7 @@ import {
11
11
  activeToolsExtension,
12
12
  runWorkerTask,
13
13
  SHELL_TOOLS,
14
+ textOf,
14
15
  } from "pi-roundtable/kit";
15
16
  import type { CodingJob, HeldCallAnswer } from "./coding-desk.ts";
16
17
 
@@ -20,6 +21,14 @@ interface StartMessage {
20
21
  packages: string[];
21
22
  agentDir: string;
22
23
  prompt?: string;
24
+ /** The host posts the worker's progress, so the session's messages and tools go to it. */
25
+ progress?: boolean;
26
+ /** The host's scratch dir, the worker's TMPDIR, where writes and removals need no approval. */
27
+ scratchDir?: string;
28
+ }
29
+ /** Said to every worker whose host has a scratch dir, after the host's own prompt or the default. */
30
+ export function scratchDirPrompt(scratchDir: string): string {
31
+ return `Temporary files, test homes and throwaway clones go under the scratch dir ${scratchDir} ($TMPDIR): writes and removals there, and inside the repository, run without approval; elsewhere they wait for the owner.`;
23
32
  }
24
33
  export function codingWorkerPrompt(dir: string): string {
25
34
  return [
@@ -42,6 +51,8 @@ async function run({
42
51
  agentDir,
43
52
  packages,
44
53
  prompt,
54
+ progress,
55
+ scratchDir,
45
56
  }: StartMessage): Promise<string> {
46
57
  const modelRuntime = await ModelRuntime.create({
47
58
  authPath: join(agentDir, "auth.json"),
@@ -103,7 +114,10 @@ async function run({
103
114
  },
104
115
  },
105
116
  ],
106
- appendSystemPrompt: [prompt ?? codingWorkerPrompt(job.dir)],
117
+ appendSystemPrompt: [
118
+ prompt ?? codingWorkerPrompt(job.dir),
119
+ ...(scratchDir ? [scratchDirPrompt(scratchDir)] : []),
120
+ ],
107
121
  });
108
122
  await loader.reload();
109
123
  const { session } = await createAgentSession({
@@ -115,6 +129,19 @@ async function run({
115
129
  sessionManager: SessionManager.inMemory(job.dir),
116
130
  settingsManager: SettingsManager.inMemory({}),
117
131
  });
132
+ // Only what the host's progress posts read crosses: a message's text, never its thinking.
133
+ if (progress)
134
+ session.subscribe((event) => {
135
+ if (event.type === "tool_execution_start")
136
+ process.send?.({ type: "tool", name: event.toolName });
137
+ if (event.type === "message_end" && event.message.role === "assistant")
138
+ process.send?.({
139
+ type: "message",
140
+ role: event.message.role,
141
+ stopReason: event.message.stopReason,
142
+ text: textOf(event.message.content),
143
+ });
144
+ });
118
145
  return runWorkerTask(session, {
119
146
  modelRuntime,
120
147
  model: job.model,