pi-roundtable 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -0
  3. package/README.zh-TW.md +4 -0
  4. package/docs/plugins.md +31 -5
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-prompt.ts +1 -1
  7. package/src/core/builtin/discord-admin.ts +6 -2
  8. package/src/core/builtin/discord.ts +10 -0
  9. package/src/core/config/adapters.ts +47 -0
  10. package/src/core/config/config.ts +9 -34
  11. package/src/core/define-roundtable.ts +2 -0
  12. package/src/core/define.ts +8 -0
  13. package/src/core/discord/channel-files.ts +107 -0
  14. package/src/core/discord/channel-operations.ts +100 -33
  15. package/src/core/discord/discord-surface.ts +21 -4
  16. package/src/core/discord/dispatch-thread-host.ts +11 -0
  17. package/src/core/discord/dispatch-threads.ts +14 -0
  18. package/src/core/discord/inbound-message.ts +34 -0
  19. package/src/core/discord/owner-card-rows.ts +98 -0
  20. package/src/core/discord/owner-cards-fixture.ts +3 -0
  21. package/src/core/discord/owner-cards.ts +137 -105
  22. package/src/core/host.ts +9 -1
  23. package/src/core/i18n/cards.ts +6 -8
  24. package/src/core/i18n/conversation.ts +15 -0
  25. package/src/core/interactions/prompts.ts +36 -7
  26. package/src/core/modules/discord-admin/discord-admin.ts +16 -2
  27. package/src/core/plugin.ts +5 -0
  28. package/src/core/runtime/conversation-sessions.ts +2 -0
  29. package/src/core/runtime/extensions/ask-user.ts +21 -4
  30. package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
  31. package/src/core/runtime/prompt-slot.ts +6 -4
  32. package/src/core/runtime/runtime-types.ts +5 -0
  33. package/src/core/runtime/session-factory.ts +17 -0
  34. package/src/core/sessions.ts +6 -0
  35. package/src/core/shared/attach-file-tool.ts +72 -0
  36. package/src/core/shared/workspace-files.ts +76 -0
  37. package/src/core/testing/thread-host.ts +14 -0
  38. package/src/index.ts +2 -0
  39. package/src/kit/index.ts +2 -0
  40. package/src/kit/worker.ts +2 -0
  41. package/src/testing.ts +7 -1
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { messages } from "../../i18n/index.ts";
4
- import { type OwnerIdentity, ownerWords } from "../../identity.ts";
4
+ import { type OwnerIdentity, ownerWords, PRONOUNS } from "../../identity.ts";
5
5
  import type { OwnerAnswer, OwnerQuestion } from "../../interactions/prompts.ts";
6
6
  import { toolText } from "../../shared/tool-result.ts";
7
7
  import type { PromptSlot } from "../prompt-slot.ts";
@@ -10,12 +10,14 @@ export const ASK_USER_TOOL = "ask_user";
10
10
 
11
11
  /** The tool's result: what the owner chose and wrote. */
12
12
  export function answerText(
13
- answer: OwnerAnswer | undefined,
13
+ answer: OwnerAnswer | "pending" | undefined,
14
14
  owner: OwnerIdentity,
15
15
  ): string {
16
16
  const o = ownerWords(owner);
17
+ if (answer === "pending")
18
+ return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
17
19
  if (!answer)
18
- return `No answer: ${o.name} did not answer in time. Go on without it if you safely can, otherwise ask in your reply.`;
20
+ return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
19
21
  const lines = [
20
22
  ...(answer.choices.length > 0
21
23
  ? [`${o.name} chose: ${answer.choices.join("; ")}`]
@@ -25,6 +27,15 @@ export function answerText(
25
27
  return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
26
28
  }
27
29
 
30
+ /** The turn a late answer starts: the question it answers and what was chosen and written. */
31
+ export function lateAnswerText(
32
+ question: string,
33
+ answer: OwnerAnswer,
34
+ by: string,
35
+ ): string {
36
+ return `(${by} answered your earlier ask_user question on its card, after your turn had ended. Continue from where you stopped, with this answer.)\nQuestion: ${question}\n${answerText(answer, { name: by, pronouns: PRONOUNS.they })}`;
37
+ }
38
+
28
39
  /** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
29
40
  export function askUserExtension(
30
41
  slot: PromptSlot,
@@ -37,7 +48,7 @@ export function askUserExtension(
37
48
  pi.registerTool({
38
49
  name: ASK_USER_TOOL,
39
50
  label: `Ask ${o.name}`,
40
- description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
51
+ description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
41
52
  parameters: Type.Object(
42
53
  {
43
54
  question: Type.String({ minLength: 1, maxLength: 1500 }),
@@ -88,6 +99,12 @@ export function askUserExtension(
88
99
  messages().askTitle(slot.asker),
89
100
  question,
90
101
  signal,
102
+ {
103
+ late: (late) =>
104
+ late.kind === "question"
105
+ ? lateAnswerText(p.question, late.answer, late.by)
106
+ : undefined,
107
+ },
91
108
  ),
92
109
  addressee?.() ?? owner,
93
110
  ),
@@ -1,3 +1,5 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
4
  import type {
3
5
  HeldCall,
@@ -6,7 +8,7 @@ import type {
6
8
  import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
7
9
  import { messages } from "../../i18n/index.ts";
8
10
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
9
- import type { Prompts } from "../../interactions/prompts.ts";
11
+ import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
10
12
  import type { Speaker } from "../../speakers.ts";
11
13
  import type { ToolTiers } from "../../tool-tiers.ts";
12
14
  import type { PromptSlot } from "../prompt-slot.ts";
@@ -70,16 +72,22 @@ export class ConfirmationGate {
70
72
  #speaker: Pick<Speaker, "id" | "principalId"> | undefined;
71
73
  #pending: PendingConfirmation | undefined;
72
74
  #approved: HeldCall[] = [];
75
+ /** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
76
+ #lateApproved: HeldCall[] = [];
73
77
  readonly #holds: HoldCheck;
74
78
  readonly #owner: OwnerIdentity;
75
79
  /** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
76
80
  #addressee: OwnerIdentity;
77
81
  readonly #context: HoldContext;
78
82
  readonly #tiers: ToolTiers | undefined;
83
+ /** Where the session's relative file paths resolve, for the sizes a card shows. */
84
+ readonly #fileRoot: string | undefined;
79
85
 
80
86
  /**
81
87
  * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
82
88
  * and its writes outside the workspace and the context's scratch dir are held too.
89
+ * `fileRoot` is the session's working directory, where a card resolves relative file paths;
90
+ * it defaults to the workspace.
83
91
  */
84
92
  constructor(
85
93
  holds: HoldCheck,
@@ -87,8 +95,10 @@ export class ConfirmationGate {
87
95
  pending?: PendingConfirmation,
88
96
  context: HoldContext = {},
89
97
  tiers?: ToolTiers,
98
+ fileRoot?: string,
90
99
  ) {
91
100
  this.#tiers = tiers;
101
+ this.#fileRoot = fileRoot ?? context.workspace;
92
102
  this.#holds = holds;
93
103
  this.#owner = owner;
94
104
  this.#addressee = owner;
@@ -155,11 +165,18 @@ export class ConfirmationGate {
155
165
  if (!ask) return { reason: this.#record(call) };
156
166
  const answer = await ask.prompts.confirm(
157
167
  messages().confirmTitle(ask.asker),
158
- approvalCard(call),
168
+ approvalCard(call, this.#fileRoot),
159
169
  ask.signal,
160
170
  higherTier(this.#tiers?.minTier(call.tool), call.minTier),
171
+ { late: (late) => this.#late(call, late) },
161
172
  );
162
173
  if (answer === "approved") return { approvedOnCard: true };
174
+ if (answer === "pending") {
175
+ const o = ownerWords(this.#addressee);
176
+ return {
177
+ reason: `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`,
178
+ };
179
+ }
163
180
  if (answer === "declined") {
164
181
  const o = ownerWords(this.#addressee);
165
182
  return {
@@ -170,6 +187,18 @@ export class ConfirmationGate {
170
187
  return { reason: this.#record(call) };
171
188
  }
172
189
 
190
+ /**
191
+ * A late answer on a call's card: an approval lets the identical call run once, in whichever
192
+ * turn makes it next; returns the text of the turn that tells the model.
193
+ */
194
+ #late(call: HeldCall, late: LateAnswer): string | undefined {
195
+ if (late.kind !== "approval") return undefined;
196
+ if (!late.approved)
197
+ return `(${late.by} declined on its card the action you were waiting on: it would ${call.action}. It did not run; do not make the call again. Go on without it, or ask what ${late.by} wants instead.)`;
198
+ this.#lateApproved.push(call);
199
+ return `(${late.by} approved on its card the action you were waiting on: it would ${call.action}. Make this call now with exactly this input, and it runs once; then report its result.)\n- ${call.tool} ${call.input}`;
200
+ }
201
+
173
202
  /** The call when it needs the owner's confirmation and no approval releases it. */
174
203
  #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
175
204
  const context = this.#context;
@@ -182,12 +211,14 @@ export class ConfirmationGate {
182
211
  action,
183
212
  ...(minTier ? { minTier } : {}),
184
213
  };
185
- const approved = this.#approved.findIndex(
186
- (c) => c.tool === call.tool && c.input === call.input,
187
- );
188
- if (approved !== -1) {
189
- this.#approved.splice(approved, 1);
190
- return undefined;
214
+ for (const released of [this.#approved, this.#lateApproved]) {
215
+ const approved = released.findIndex(
216
+ (c) => c.tool === call.tool && c.input === call.input,
217
+ );
218
+ if (approved !== -1) {
219
+ released.splice(approved, 1);
220
+ return undefined;
221
+ }
191
222
  }
192
223
  return call;
193
224
  }
@@ -229,13 +260,44 @@ export function confirmedTurnText(
229
260
  return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
230
261
  }
231
262
 
232
- /** What an approval card says: the held action's words, then the exact call. */
233
- export function approvalCard(call: HeldCall): string {
263
+ /**
264
+ * What an approval card says: the held action's words, the exact call, and each file it sends by
265
+ * path with the file's size now; relative paths resolve against `workspace`.
266
+ */
267
+ export function approvalCard(call: HeldCall, workspace?: string): string {
234
268
  const input =
235
269
  call.input.length > CARD_INPUT_CHARS
236
270
  ? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
237
271
  : call.input;
238
- return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\``;
272
+ const files = pathFiles(call.input).map(
273
+ (path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
274
+ );
275
+ return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
276
+ }
277
+
278
+ /** The paths of a call's `files` entries that name one. */
279
+ function pathFiles(input: string): string[] {
280
+ let parsed: unknown;
281
+ try {
282
+ parsed = JSON.parse(input);
283
+ } catch {
284
+ return [];
285
+ }
286
+ const files = isPlainObject(parsed) ? parsed.files : undefined;
287
+ if (!Array.isArray(files)) return [];
288
+ return files.flatMap((file: unknown) =>
289
+ isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
290
+ );
291
+ }
292
+
293
+ /** A file's size in bytes, or undefined when it cannot be read. */
294
+ function fileSize(path: string, workspace: string | undefined) {
295
+ try {
296
+ const info = statSync(resolve(workspace ?? "/", path));
297
+ return info.isFile() ? info.size : undefined;
298
+ } catch {
299
+ return undefined;
300
+ }
239
301
  }
240
302
 
241
303
  /** Without a slot, or with an empty one, every call that needs confirmation is held. */
@@ -46,10 +46,12 @@ export class PromptSlot {
46
46
  const prompts = this.#prompts;
47
47
  if (!prompts) return undefined;
48
48
  return {
49
- confirm: (title, message, signal, minTier) =>
50
- this.#waiting(() => prompts.confirm(title, message, signal, minTier)),
51
- ask: (title, question: OwnerQuestion, signal) =>
52
- this.#waiting(() => prompts.ask(title, question, signal)),
49
+ confirm: (title, message, signal, minTier, wait) =>
50
+ this.#waiting(() =>
51
+ prompts.confirm(title, message, signal, minTier, wait),
52
+ ),
53
+ ask: (title, question: OwnerQuestion, signal, wait) =>
54
+ this.#waiting(() => prompts.ask(title, question, signal, wait)),
53
55
  };
54
56
  }
55
57
 
@@ -111,6 +111,8 @@ export const revisionsKey = (plan: SessionPlan) =>
111
111
  /** The core's own extensions of one session, which the plan's contributions surround. */
112
112
  export interface CoreExtensions {
113
113
  readAttachment: ExtensionFactory;
114
+ /** `attach_file`, for a session with a workspace. */
115
+ attachFile?: ExtensionFactory;
114
116
  confirmationGate: ExtensionFactory;
115
117
  askUser: ExtensionFactory;
116
118
  selfCompactGuard: ExtensionFactory;
@@ -139,6 +141,9 @@ export function sessionExtensions(
139
141
  return [
140
142
  ...contributed(plan.tools),
141
143
  { name: "read-attachment", factory: core.readAttachment },
144
+ ...(core.attachFile
145
+ ? [{ name: "attach-file", factory: core.attachFile }]
146
+ : []),
142
147
  { name: "confirmation-gate", factory: core.confirmationGate },
143
148
  { name: "ask-user", factory: core.askUser },
144
149
  { name: "self-compact-guard", factory: core.selfCompactGuard },
@@ -23,6 +23,10 @@ import {
23
23
  type TransientTask,
24
24
  } from "../sessions.ts";
25
25
  import { activeToolsExtension } from "../shared/active-tools.ts";
26
+ import {
27
+ ATTACH_FILE_TOOL,
28
+ attachFileExtension,
29
+ } from "../shared/attach-file-tool.ts";
26
30
  import { packageDir } from "../shared/package-dir.ts";
27
31
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
28
32
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
@@ -130,6 +134,8 @@ export class SessionFactory {
130
134
  ...currentToolNames(selection.tools, this.#options.logger),
131
135
  COMPACT_TOOL,
132
136
  ASK_USER_TOOL,
137
+ // Registered only by a session with a workspace.
138
+ ATTACH_FILE_TOOL,
133
139
  ...groups
134
140
  .filter((group) => selection.groups.includes(group.name))
135
141
  .flatMap((group) => group.tools),
@@ -332,6 +338,14 @@ export class SessionFactory {
332
338
  },
333
339
  conversation,
334
340
  addressee,
341
+ ...(agents
342
+ ? {
343
+ workspace: {
344
+ workspace: cwd,
345
+ ...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
346
+ },
347
+ }
348
+ : {}),
335
349
  attachmentDir,
336
350
  get memory() {
337
351
  return state.memory;
@@ -353,6 +367,9 @@ export class SessionFactory {
353
367
  additionalExtensionPaths: this.link().extensionPaths,
354
368
  extensionFactories: sessionExtensions(this.plan, context, {
355
369
  readAttachment: readAttachmentExtension(attachmentDir),
370
+ ...(context.workspace
371
+ ? { attachFile: attachFileExtension(context.workspace) }
372
+ : {}),
356
373
  confirmationGate: confirmationGateExtension(gate, slot),
357
374
  askUser: askUserExtension(slot, addressee, () =>
358
375
  turnAddressee(
@@ -83,6 +83,11 @@ export interface SessionContext {
83
83
  * it reads `"none"` for that turn alone.
84
84
  */
85
85
  memory: "speaker" | "none";
86
+ /**
87
+ * The shared workspace of a session with a shell, and its scratch dir: the roots its shell writes
88
+ * in without a hold, and where its tools may read a file by path. Absent for a session without one.
89
+ */
90
+ workspace?: { workspace: string; scratchDir?: string };
86
91
  /** Where the session's conversation keeps the files people attached; absent when the session keeps none. */
87
92
  attachmentDir?: string;
88
93
  /** The person the session's running turn is for; undefined between turns. */
@@ -124,6 +129,7 @@ export interface SessionTool {
124
129
  /** Extensions the core places itself; no contribution may take their names. */
125
130
  const CORE_EXTENSIONS = [
126
131
  "read-attachment",
132
+ "attach-file",
127
133
  "confirmation-gate",
128
134
  "ask-user",
129
135
  "self-compact-guard",
@@ -0,0 +1,72 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import {
4
+ attachReplyFile,
5
+ REPLY_FILE_LIMITS,
6
+ ReplyFileError,
7
+ } from "../reply-files.ts";
8
+ import { toolError, toolText } from "./tool-result.ts";
9
+ import {
10
+ findWorkspaceFile,
11
+ readWorkspaceFile,
12
+ WorkspaceFileError,
13
+ type WorkspaceRoots,
14
+ } from "./workspace-files.ts";
15
+
16
+ export const ATTACH_FILE_TOOL = "attach_file";
17
+
18
+ /** Registers `attach_file`, which posts a file from the session's disk with the turn's reply. */
19
+ export function attachFileExtension(roots: WorkspaceRoots): ExtensionFactory {
20
+ return (pi) => {
21
+ pi.registerTool({
22
+ name: ATTACH_FILE_TOOL,
23
+ label: "Attach file",
24
+ description:
25
+ "Show a file you have on disk, such as a picture you made, by attaching it to your reply in this conversation. Give its path inside the workspace or the scratch dir; never read an image or base64 a file to show it.",
26
+ parameters: Type.Object(
27
+ {
28
+ path: Type.String({
29
+ minLength: 1,
30
+ maxLength: 4096,
31
+ description:
32
+ "The file's path inside the workspace or the scratch dir.",
33
+ }),
34
+ filename: Type.Optional(
35
+ Type.String({
36
+ minLength: 1,
37
+ maxLength: 255,
38
+ description:
39
+ "The name it is posted under, without a path; defaults to the path's base name.",
40
+ }),
41
+ ),
42
+ },
43
+ { additionalProperties: false },
44
+ ),
45
+ execute: async (_toolCallId, params) => {
46
+ try {
47
+ const file = await findWorkspaceFile(params.path, roots);
48
+ if (file.size > REPLY_FILE_LIMITS.maxFileBytes)
49
+ throw new ReplyFileError(
50
+ `${params.path} is ${file.size} bytes; a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
51
+ );
52
+ const data = await readWorkspaceFile(
53
+ file,
54
+ REPLY_FILE_LIMITS.maxFileBytes,
55
+ );
56
+ const name = params.filename ?? file.name;
57
+ attachReplyFile({ name, data });
58
+ return toolText(
59
+ `${name} (${data.byteLength} bytes) will appear with your reply.`,
60
+ );
61
+ } catch (error) {
62
+ if (
63
+ error instanceof WorkspaceFileError ||
64
+ error instanceof ReplyFileError
65
+ )
66
+ return toolError(`Not attached: ${error.message}`);
67
+ throw error;
68
+ }
69
+ },
70
+ });
71
+ };
72
+ }
@@ -0,0 +1,76 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import { basename, isAbsolute, relative, resolve } from "node:path";
3
+
4
+ /** Where a session may read files by path: its shared workspace and its scratch dir. */
5
+ export interface WorkspaceRoots {
6
+ workspace: string;
7
+ scratchDir?: string;
8
+ }
9
+
10
+ /** A file refused by path; its message names the path and is safe to show the model. */
11
+ export class WorkspaceFileError extends Error {
12
+ override name = "WorkspaceFileError";
13
+ }
14
+
15
+ /** A local file found inside the roots, not read yet. */
16
+ export interface WorkspaceFile {
17
+ /** The path as the caller gave it. */
18
+ path: string;
19
+ /** Where it resolves, symlinks followed. */
20
+ realPath: string;
21
+ /** Its base name, the default name it is sent under. */
22
+ name: string;
23
+ size: number;
24
+ }
25
+
26
+ /** Whether `child` is `parent` or inside it. */
27
+ function within(parent: string, child: string): boolean {
28
+ const rel = relative(parent, child);
29
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
30
+ }
31
+
32
+ async function realRoot(root: string): Promise<string | undefined> {
33
+ return realpath(root).catch(() => undefined);
34
+ }
35
+
36
+ /**
37
+ * Finds a regular file by path, relative paths against the workspace, and refuses one that does
38
+ * not resolve inside the workspace or the scratch dir, so a symlink counts by its target.
39
+ */
40
+ export async function findWorkspaceFile(
41
+ path: string,
42
+ roots: WorkspaceRoots,
43
+ ): Promise<WorkspaceFile> {
44
+ const given = resolve(roots.workspace, path);
45
+ let real: string;
46
+ try {
47
+ real = await realpath(given);
48
+ } catch {
49
+ throw new WorkspaceFileError(`${path} does not exist.`);
50
+ }
51
+ const allowed = await Promise.all(
52
+ [roots.workspace, roots.scratchDir]
53
+ .filter((root): root is string => root !== undefined)
54
+ .map(realRoot),
55
+ );
56
+ if (!allowed.some((root) => root !== undefined && within(root, real)))
57
+ throw new WorkspaceFileError(
58
+ `${path} is outside the workspace${roots.scratchDir ? " and the scratch dir" : ""}; only files inside ${roots.scratchDir ? "them" : "it"} can be sent.`,
59
+ );
60
+ const info = await stat(real);
61
+ if (!info.isFile()) throw new WorkspaceFileError(`${path} is not a file.`);
62
+ return { path, realPath: real, name: basename(given), size: info.size };
63
+ }
64
+
65
+ /** Reads a file `findWorkspaceFile` found; a file that grew past `maxBytes` since is refused. */
66
+ export async function readWorkspaceFile(
67
+ file: WorkspaceFile,
68
+ maxBytes: number,
69
+ ): Promise<Uint8Array> {
70
+ const data = new Uint8Array(await readFile(file.realPath));
71
+ if (data.byteLength > maxBytes)
72
+ throw new WorkspaceFileError(
73
+ `${file.path} is ${data.byteLength} bytes, over the ${maxBytes} allowed.`,
74
+ );
75
+ return data;
76
+ }
@@ -5,6 +5,7 @@ import {
5
5
  DispatchThreads,
6
6
  type ThreadHost,
7
7
  } from "../discord/dispatch-threads.ts";
8
+ import type { InterimMessage } from "../domain/interim.ts";
8
9
  import { silentLogger } from "../log.ts";
9
10
 
10
11
  /** A ThreadHost that records what happens; parents in `hostless` cannot host threads. */
@@ -14,6 +15,8 @@ export class FakeThreadHost implements ThreadHost {
14
15
  readonly opened: { parentId: string; name: string; id: string }[] = [];
15
16
  readonly posts: { threadId: string; text: string }[] = [];
16
17
  readonly closed: string[] = [];
18
+ /** Edits of messages `send` posted, in order; each also changes its entry in `posts`. */
19
+ readonly edits: { threadId: string; text: string }[] = [];
17
20
  readonly hostless = new Set<string>();
18
21
  failOpen = false;
19
22
  #next = 900;
@@ -35,6 +38,17 @@ export class FakeThreadHost implements ThreadHost {
35
38
  this.posts.push({ threadId, text });
36
39
  }
37
40
 
41
+ async send(threadId: string, text: string): Promise<InterimMessage> {
42
+ const post = { threadId, text };
43
+ this.posts.push(post);
44
+ return {
45
+ edit: async (change) => {
46
+ post.text = change;
47
+ this.edits.push({ threadId, text: change });
48
+ },
49
+ };
50
+ }
51
+
38
52
  async close(threadId: string): Promise<void> {
39
53
  this.closed.push(threadId);
40
54
  }
package/src/index.ts CHANGED
@@ -162,11 +162,13 @@ export { SYSTEM_PRINCIPAL } from "./core/identity/principal-store.ts";
162
162
  export type {
163
163
  Approval,
164
164
  AskOption,
165
+ LateAnswer,
165
166
  OwnerAnswer,
166
167
  OwnerPrompts,
167
168
  OwnerQuestion,
168
169
  PromptScope,
169
170
  Prompts,
171
+ PromptWait,
170
172
  } from "./core/interactions/prompts.ts";
171
173
  export { promptScope } from "./core/interactions/prompts.ts";
172
174
  export type { JudgeModel } from "./core/judging/model-judge.ts";
package/src/kit/index.ts CHANGED
@@ -158,6 +158,7 @@ export {
158
158
  toolText,
159
159
  } from "./tools.ts";
160
160
  export type {
161
+ InterimPosterOptions,
161
162
  McpEndpoint,
162
163
  PromptSlot,
163
164
  VirtualServer,
@@ -166,6 +167,7 @@ export {
166
167
  approvalCard,
167
168
  archiveSessions,
168
169
  canonicalJson,
170
+ InterimPoster,
169
171
  mcpAdapterExtension,
170
172
  mcpExtension,
171
173
  promptSlot,
package/src/kit/worker.ts CHANGED
@@ -5,6 +5,8 @@ export {
5
5
  approvalCard,
6
6
  canonicalJson,
7
7
  } from "../core/runtime/extensions/confirmation-gate.ts";
8
+ export type { InterimPosterOptions } from "../core/runtime/interim-text.ts";
9
+ export { InterimPoster } from "../core/runtime/interim-text.ts";
8
10
  export type { VirtualServer } from "../core/runtime/mcp.ts";
9
11
  export { mcpExtension } from "../core/runtime/mcp.ts";
10
12
  export type { PromptSlot } from "../core/runtime/prompt-slot.ts";
package/src/testing.ts CHANGED
@@ -267,7 +267,12 @@ export interface TestPluginResult {
267
267
  runTool(
268
268
  name: string,
269
269
  args: Record<string, unknown>,
270
- options?: { speaker?: Speaker; channel?: ChannelKey },
270
+ /** `workspace` gives the session a workspace and scratch dir, as an agent's session has. */
271
+ options?: {
272
+ speaker?: Speaker;
273
+ channel?: ChannelKey;
274
+ workspace?: SessionContext["workspace"];
275
+ },
271
276
  ): Promise<string>;
272
277
  stop(): Promise<void>;
273
278
  }
@@ -632,6 +637,7 @@ export async function testPlugin(
632
637
  conversation: { visibility: "shared" },
633
638
  addressee: THE_SPEAKER,
634
639
  memory: "speaker",
640
+ ...(runOptions?.workspace ? { workspace: runOptions.workspace } : {}),
635
641
  attachmentDir: ownerAttachmentDir(attachmentDir, channel),
636
642
  speaker: () => runOptions?.speaker,
637
643
  runTask: async () => {