pi-roundtable 0.24.2 → 0.26.0

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 (81) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/plugins.md +174 -7
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-book.ts +155 -0
  8. package/src/core/agents/agent-ports.ts +5 -1
  9. package/src/core/agents/agent-prompt.ts +6 -1
  10. package/src/core/agents/agent-schema.ts +3 -0
  11. package/src/core/agents/agent-statements.ts +179 -0
  12. package/src/core/agents/agent-store.ts +184 -191
  13. package/src/core/agents/agent-team-fixture.ts +21 -2
  14. package/src/core/agents/agent-team.ts +44 -36
  15. package/src/core/agents/agent-tools.ts +12 -6
  16. package/src/core/agents/team-editing.ts +154 -32
  17. package/src/core/agents/team-keys.ts +17 -0
  18. package/src/core/agents/team-lifecycle.ts +96 -23
  19. package/src/core/agents/team-options.ts +9 -4
  20. package/src/core/agents/team-selection.ts +32 -0
  21. package/src/core/builtin/agent-server.ts +30 -5
  22. package/src/core/builtin/channel-context.ts +21 -0
  23. package/src/core/builtin/discord.ts +4 -1
  24. package/src/core/builtin/prompts.ts +40 -0
  25. package/src/core/builtin/skills.ts +9 -5
  26. package/src/core/config/config-schema.ts +22 -6
  27. package/src/core/config/config.ts +30 -2
  28. package/src/core/config/conversations.ts +21 -0
  29. package/src/core/config/profiles.ts +53 -0
  30. package/src/core/contract/channel-context.ts +106 -0
  31. package/src/core/contract/surface.ts +16 -0
  32. package/src/core/conversations/conversation-registry.ts +6 -0
  33. package/src/core/conversations/conversation-retention.ts +107 -0
  34. package/src/core/conversations/conversation-store.ts +6 -0
  35. package/src/core/conversations/conversations-plugin.ts +19 -3
  36. package/src/core/define-roundtable.ts +12 -2
  37. package/src/core/discord/channel-context.ts +47 -78
  38. package/src/core/discord/discord-surface.ts +16 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host-registration.ts +4 -0
  41. package/src/core/host.ts +3 -1
  42. package/src/core/i18n/agent-panel.ts +4 -0
  43. package/src/core/i18n/cards.ts +1 -1
  44. package/src/core/identity/actor-facts.ts +7 -0
  45. package/src/core/identity/identity-plugin.ts +27 -5
  46. package/src/core/identity/identity-port.ts +62 -0
  47. package/src/core/identity/identity-schema.ts +2 -0
  48. package/src/core/identity/identity-service.ts +45 -52
  49. package/src/core/identity/identity-view.ts +1 -0
  50. package/src/core/identity/person-profiles.ts +275 -0
  51. package/src/core/identity/principal-tables.ts +10 -0
  52. package/src/core/identity/profile-choices.ts +45 -0
  53. package/src/core/identity/profile-memory.ts +65 -0
  54. package/src/core/identity/profile-picture.ts +117 -0
  55. package/src/core/identity/profiles-view.ts +20 -0
  56. package/src/core/identity/profiles.ts +82 -0
  57. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  58. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  59. package/src/core/modules/prompts/prompt-history.ts +103 -0
  60. package/src/core/modules/skills/skill-contract.ts +52 -0
  61. package/src/core/modules/skills/skill-document-files.ts +66 -0
  62. package/src/core/modules/skills/skill-registry.ts +136 -8
  63. package/src/core/modules/skills/skill-rules.ts +94 -0
  64. package/src/core/modules/skills/skill-store.ts +88 -17
  65. package/src/core/modules/skills/skill-tools.ts +18 -10
  66. package/src/core/plugin.ts +40 -0
  67. package/src/core/registry/contributions.ts +8 -1
  68. package/src/core/runtime/chat-only.ts +32 -0
  69. package/src/core/runtime/conversation-sessions.ts +2 -1
  70. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  71. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  72. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  73. package/src/core/runtime/runtime-plugin.ts +1 -0
  74. package/src/core/runtime/runtime-types.ts +3 -1
  75. package/src/core/runtime/session-factory.ts +70 -65
  76. package/src/core/runtime/session-helpers.ts +27 -0
  77. package/src/core/services.ts +97 -33
  78. package/src/index.ts +31 -0
  79. package/src/kit/index.ts +1 -0
  80. package/src/kit/oidc.ts +16 -0
  81. package/src/testing.ts +1 -0
@@ -81,11 +81,12 @@ export function skillListExtension(
81
81
  );
82
82
  }
83
83
 
84
- /** The skill tools of an agent session. */
84
+ /** The skill tools of an agent session; a skill the agent writes or edits is kept under `by`, the agent's name. */
85
85
  export function skillToolsExtension(
86
86
  registry: StoredSkillRegistry,
87
87
  /** Throws AgentError unless the name is an active agent. */
88
88
  checkAgent: (name: string) => void,
89
+ by: string,
89
90
  ): ExtensionFactory {
90
91
  return textToolsExtension(
91
92
  [
@@ -127,11 +128,14 @@ export function skillToolsExtension(
127
128
  body: BODY,
128
129
  }),
129
130
  run: (i) =>
130
- registry.create({
131
- name: requiredString(i, "name"),
132
- description: requiredString(i, "description"),
133
- body: requiredString(i, "body"),
134
- }),
131
+ registry.create(
132
+ {
133
+ name: requiredString(i, "name"),
134
+ description: requiredString(i, "description"),
135
+ body: requiredString(i, "body"),
136
+ },
137
+ by,
138
+ ),
135
139
  },
136
140
  {
137
141
  name: "skill_update",
@@ -146,10 +150,14 @@ export function skillToolsExtension(
146
150
  run: (i) => {
147
151
  const description = optionalString(i, "description");
148
152
  const body = optionalString(i, "body");
149
- return registry.update(requiredString(i, "name"), {
150
- ...(description !== undefined ? { description } : {}),
151
- ...(body !== undefined ? { body } : {}),
152
- });
153
+ return registry.update(
154
+ requiredString(i, "name"),
155
+ {
156
+ ...(description !== undefined ? { description } : {}),
157
+ ...(body !== undefined ? { body } : {}),
158
+ },
159
+ by,
160
+ );
153
161
  },
154
162
  },
155
163
  {
@@ -107,10 +107,30 @@ export interface EventHandlers {
107
107
  turnProgress?(event: TurnProgressEvent): Promise<void> | void;
108
108
  /** The team changed: an agent or group was created, edited, arranged, archived, or started over. */
109
109
  changed?(): Promise<void> | void;
110
+ /**
111
+ * Something a person did through a plugin's surface, reported by any plugin through
112
+ * `context.events.action`: for example a support inbox's "assign", "reply" or "close". It names
113
+ * the kind, who did it and whether it worked, never the content.
114
+ */
115
+ action?(event: ActionEvent): Promise<void> | void;
110
116
  /** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
111
117
  shutdown?(left: readonly string[]): Promise<void> | void;
112
118
  }
113
119
 
120
+ /** One thing a person did through a plugin's surface, as a handler hears of it. */
121
+ export interface ActionEvent {
122
+ /** What was done, in the reporting plugin's words, such as "assign" or "close". */
123
+ kind: string;
124
+ /** The `Speaker.id` of who did it. */
125
+ actor: string;
126
+ principalId: string;
127
+ /** The conversation or page it concerns, when there is one. */
128
+ channel?: ChannelKey | `page:${string}`;
129
+ /** Whether it worked. */
130
+ ok: boolean;
131
+ at: Date;
132
+ }
133
+
114
134
  /** What the core reports to the plugins' handlers. */
115
135
  export interface EventSink {
116
136
  turnStarted(turn: TurnEvent): void;
@@ -118,6 +138,8 @@ export interface EventSink {
118
138
  /** Optional, so a sink of your own written before it still fits. */
119
139
  turnProgress?(event: TurnProgressEvent): void;
120
140
  changed(): void;
141
+ /** Optional, as `turnProgress` is: a sink of your own written before it still fits. */
142
+ action?(event: ActionEvent): void;
121
143
  }
122
144
 
123
145
  /** What a prompt section is built for: one agent's turn. */
@@ -265,6 +287,12 @@ export interface LinkedSessions {
265
287
  agentSelection(): ToolSelection;
266
288
  }
267
289
 
290
+ /**
291
+ * What the agents' turns may use of the machine: "full" gives them the shell and file tools, and
292
+ * "chat-only" none of them, as `agentServer: { tools: "chat-only" }` configures.
293
+ */
294
+ export type AgentTools = "full" | "chat-only";
295
+
268
296
  /** The host's own locale and time zone, as its plugins read them. */
269
297
  export interface HostEnv {
270
298
  readonly locale: Locale;
@@ -284,6 +312,12 @@ export interface HostEnv {
284
312
  * agent wears the same ones.
285
313
  */
286
314
  readonly characterSkins: readonly Hex[];
315
+ /**
316
+ * Whether the agents' turns carry the shell and file tools. A plugin whose tools reach the host
317
+ * checks it before it offers them to agents; under "chat-only" the host refuses to start with a
318
+ * plugin that declares `hostAccess`.
319
+ */
320
+ readonly agentTools: AgentTools;
287
321
  }
288
322
 
289
323
  /** What the host gives every plugin. */
@@ -391,6 +425,12 @@ export interface RoundtablePlugin {
391
425
  * dropped plugin provides a service this one does not replace.
392
426
  */
393
427
  replaces?: readonly ServiceKey<unknown>[];
428
+ /**
429
+ * Marks a plugin whose tools reach the host's machine, such as one that runs shells or edits
430
+ * its files for the agents. A host that configures `agentServer: { tools: "chat-only" }`
431
+ * refuses to start with one registered, with a ConfigError that names the plugin.
432
+ */
433
+ hostAccess?: true;
394
434
  setup(context: PluginContext): Promise<Contribution> | Contribution;
395
435
  /**
396
436
  * Runs once every plugin is set up and linked, before any service starts; a failure stops the
@@ -25,6 +25,7 @@ import {
25
25
  type DirectChannelProvider,
26
26
  directChannelsPort,
27
27
  } from "../presence/direct-channels.ts";
28
+ import { refuseShellTools } from "../runtime/chat-only.ts";
28
29
  import {
29
30
  compileSessionPlan,
30
31
  type SessionTool,
@@ -451,8 +452,14 @@ export async function collectContributions(
451
452
  registry.holdRules.push(tool.hold);
452
453
  }
453
454
  }
454
- if (contribution.agentSelection)
455
+ if (contribution.agentSelection) {
456
+ if (context.env.agentTools === "chat-only")
457
+ refuseShellTools(
458
+ contribution.agentSelection().tools,
459
+ `plugin ${plugin.name}: agentSelection`,
460
+ );
455
461
  registry.agentSelections.push(contribution.agentSelection);
462
+ }
456
463
  if (events) registry.handlers.push({ plugin: plugin.name, events });
457
464
  registry.routes.push(...http);
458
465
  registry.holdRules.push(...holdRules);
@@ -0,0 +1,32 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import { SHELL_TOOLS } from "../modules/host-shell/shell-policy.ts";
3
+ import type { RoundtablePlugin } from "../plugin.ts";
4
+
5
+ const CHAT_ONLY = 'agentServer: { tools: "chat-only" }';
6
+
7
+ /** The shell and file tools among these names. */
8
+ export function shellToolsIn(names: Iterable<string>): string[] {
9
+ const shell: readonly string[] = SHELL_TOOLS;
10
+ return [...new Set(names)].filter((name) => shell.includes(name));
11
+ }
12
+
13
+ /**
14
+ * Refuses names that carry the machine's shell and file tools: the one place a chat-only host
15
+ * checks, where a selection turns into active tools. `where` says whose selection it is.
16
+ */
17
+ export function refuseShellTools(names: Iterable<string>, where: string): void {
18
+ const found = shellToolsIn(names);
19
+ if (found.length > 0)
20
+ throw new ConfigError(
21
+ `${where} names ${found.join(", ")}, which ${CHAT_ONLY} withholds from every turn. Take the tool out of the selection, or remove tools: "chat-only" from agentServer to give the agents the shell.`,
22
+ );
23
+ }
24
+
25
+ /** Refuses, at start, a plugin whose tools reach the host: an agent of a chat-only host must not. */
26
+ export function refuseHostAccess(plugins: readonly RoundtablePlugin[]): void {
27
+ const reaching = plugins.find((plugin) => plugin.hostAccess === true);
28
+ if (reaching)
29
+ throw new ConfigError(
30
+ `plugin ${reaching.name} declares hostAccess: its tools reach the host's machine, and the configuration says ${CHAT_ONLY}. Remove the plugin, or remove tools: "chat-only" from agentServer.`,
31
+ );
32
+ }
@@ -102,7 +102,8 @@ export class ConversationSessions {
102
102
  this.#factory.link().holds,
103
103
  this.#options.owner,
104
104
  await this.#options.confirmations.load(key),
105
- agents
105
+ // A chat-only host's agents have no shell, so their gate judges no workspace.
106
+ agents && this.#options.agentTools !== "chat-only"
106
107
  ? {
107
108
  workspace: agents.workDir,
108
109
  ...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
@@ -1,16 +1,31 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { Logger } from "../../log.ts";
3
+ import { readPromptOrStop } from "./prompt-read.ts";
2
4
 
3
5
  /**
4
6
  * Puts the agent's current prompt first in the system prompt's appended part before each run,
5
- * keeping what earlier handlers appended, such as the owner memory section.
7
+ * keeping what earlier handlers appended, such as the owner memory section. A `prompt()` that
8
+ * throws is logged as an error and stops the run: an agent never runs without its prompt.
6
9
  */
7
- export function agentPromptExtension(prompt: () => string): ExtensionFactory {
10
+ export function agentPromptExtension(
11
+ prompt: () => string,
12
+ logger: Logger,
13
+ /** The agent's name, named in the log. */
14
+ agent: string,
15
+ ): ExtensionFactory {
8
16
  return (pi) => {
9
- pi.on("before_agent_start", (event) => {
17
+ pi.on("before_agent_start", (event, ctx) => {
18
+ const text = readPromptOrStop(
19
+ prompt,
20
+ ctx,
21
+ logger,
22
+ `The prompt of the agent "${agent}"`,
23
+ );
24
+ if (text === undefined) return;
10
25
  const appended = event.systemPromptOptions.appendSystemPrompt;
11
26
  event.systemPromptOptions.appendSystemPrompt = appended
12
- ? `${prompt()}\n\n${appended}`
13
- : prompt();
27
+ ? `${text}\n\n${appended}`
28
+ : text;
14
29
  });
15
30
  };
16
31
  }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { Logger } from "../../log.ts";
3
+ import { readPromptOrStop } from "./prompt-read.ts";
4
+
5
+ /**
6
+ * Brings the persona in the system prompt up to date before each run. The session was built with
7
+ * `initial`, the persona's text at that moment; when `current()` reads differently now, such as a
8
+ * prompt document the owner edited in the app, this run carries the new text in the same place.
9
+ * A `current()` that throws is logged as an error and stops the run: it does not go on with the
10
+ * text the session was built with.
11
+ */
12
+ export function personaPromptExtension(
13
+ initial: string,
14
+ current: () => string,
15
+ logger: Logger,
16
+ /** The conversation kind, named in the log. */
17
+ kind: string,
18
+ ): ExtensionFactory {
19
+ return (pi) => {
20
+ pi.on("before_agent_start", (event, ctx) => {
21
+ const now = readPromptOrStop(
22
+ current,
23
+ ctx,
24
+ logger,
25
+ `The persona of the "${kind}" conversations`,
26
+ );
27
+ if (now === undefined || now === initial) return;
28
+ const appended = event.systemPromptOptions.appendSystemPrompt;
29
+ if (!appended) {
30
+ event.systemPromptOptions.appendSystemPrompt = now;
31
+ return;
32
+ }
33
+ event.systemPromptOptions.appendSystemPrompt =
34
+ initial === ""
35
+ ? `${now}\n\n${appended}`
36
+ : appended.split(initial).join(now);
37
+ });
38
+ };
39
+ }
@@ -0,0 +1,22 @@
1
+ import type { Logger } from "../../log.ts";
2
+
3
+ /**
4
+ * Reads a prompt for the run that is starting. Pi swallows what a `before_agent_start` handler
5
+ * throws, so a prompt that cannot be read would let the run go on quietly with the prompt the
6
+ * session was built with, or with none at all. The failure is logged as an error and the run is
7
+ * stopped instead; the result is then undefined.
8
+ */
9
+ export function readPromptOrStop(
10
+ read: () => string,
11
+ run: { abort(): void },
12
+ logger: Logger,
13
+ what: string,
14
+ ): string | undefined {
15
+ try {
16
+ return read();
17
+ } catch (error) {
18
+ logger.error({ err: error }, `${what} cannot be read; the run is stopped`);
19
+ run.abort();
20
+ return undefined;
21
+ }
22
+ }
@@ -199,6 +199,7 @@ export function runtimePlugin(
199
199
  },
200
200
  ...turnPolicy(options),
201
201
  sessions: context.sessions,
202
+ agentTools: context.env.agentTools,
202
203
  agentDir: options.agentDir,
203
204
  modelRuntime: options.modelRuntime,
204
205
  dataDir: options.dataDir,
@@ -16,7 +16,7 @@ import type { Logger } from "../log.ts";
16
16
  import type { EffortPolicy, ThinkingLevel, ThinkingPicker } from "../models.ts";
17
17
  import type { Autonomy } from "../modes/autonomy.ts";
18
18
  import type { ConversationModes } from "../modes/conversation-modes.ts";
19
- import type { LinkedSessions } from "../plugin.ts";
19
+ import type { AgentTools, LinkedSessions } from "../plugin.ts";
20
20
  import {
21
21
  planOrder,
22
22
  type SessionContext,
@@ -75,6 +75,8 @@ export interface PiAgentRuntimeOptions {
75
75
  sessions: () => LinkedSessions;
76
76
  /** The agent server's sessions: their shared workspace, model, and skills. */
77
77
  agents?: AgentSessions;
78
+ /** "chat-only": no turn carries the shell and file tools, and agent sessions have no workspace; default "full". */
79
+ agentTools?: AgentTools;
78
80
  /**
79
81
  * The prompts of a channel for an interactive turn's scope, made from its speaker and its
80
82
  * conversation's visibility; undefined for a channel that cannot show them. Without it every
@@ -3,14 +3,11 @@ import { join } from "node:path";
3
3
  import {
4
4
  type AgentSession,
5
5
  createAgentSession,
6
- createBashToolDefinition,
7
6
  DefaultResourceLoader,
8
7
  type ModelRuntime,
9
8
  type SessionManager,
10
- type ToolDefinition,
11
9
  } from "@earendil-works/pi-coding-agent";
12
10
  import { channelSegment } from "../attachments/attachment-dir.ts";
13
- import type { ImageDrawer } from "../contract/providers.ts";
14
11
  import type { ClientPort } from "../domain/client.ts";
15
12
  import type { ChannelKey } from "../domain/conversation.ts";
16
13
  import { ConfigError } from "../domain/errors.ts";
@@ -29,16 +26,13 @@ import {
29
26
  ATTACH_FILE_TOOL,
30
27
  attachFileExtension,
31
28
  } from "../shared/attach-file-tool.ts";
32
- import {
33
- GENERATE_IMAGE_TOOL,
34
- generateImageExtension,
35
- } from "../shared/generate-image-tool.ts";
29
+ import { GENERATE_IMAGE_TOOL } from "../shared/generate-image-tool.ts";
36
30
  import { packageDir } from "../shared/package-dir.ts";
37
31
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
38
- import type { WorkspaceRoots } from "../shared/workspace-files.ts";
39
32
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
40
33
  import { currentToolNames } from "../tool-tiers.ts";
41
34
  import { toolTurn } from "../tool-turn.ts";
35
+ import { refuseShellTools } from "./chat-only.ts";
42
36
  import {
43
37
  CompactionTiers,
44
38
  compactionEngine,
@@ -49,6 +43,7 @@ import {
49
43
  type ConfirmationGate,
50
44
  confirmationGateExtension,
51
45
  } from "./extensions/confirmation-gate.ts";
46
+ import { personaPromptExtension } from "./extensions/persona-prompt.ts";
52
47
  import {
53
48
  MemoryDraws,
54
49
  privateCompaction,
@@ -63,7 +58,6 @@ import type { PromptSlot } from "./prompt-slot.ts";
63
58
  import type {
64
59
  ChannelSession,
65
60
  CompactionEnd,
66
- CoreExtensions,
67
61
  PiAgentRuntimeOptions,
68
62
  } from "./runtime-types.ts";
69
63
  import {
@@ -77,6 +71,9 @@ import {
77
71
  sessionAddressee,
78
72
  turnAddressee,
79
73
  } from "./session-conversation.ts";
74
+ import { imageTools, scratchBash } from "./session-helpers.ts";
75
+
76
+ export { imageTools, scratchBash };
80
77
 
81
78
  /** What a session factory asks of the runtime that owns the turns. */
82
79
  export interface SessionFactoryDeps {
@@ -138,11 +135,22 @@ export class SessionFactory {
138
135
  );
139
136
  }
140
137
 
138
+ /** Whether the host's agents carry no shell or file tools. */
139
+ get chatOnly(): boolean {
140
+ return this.#options.agentTools === "chat-only";
141
+ }
142
+
141
143
  get plan(): SessionPlan {
142
144
  return this.link().plan;
143
145
  }
144
146
 
147
+ /**
148
+ * The tools a selection turns into. A chat-only host refuses a selection that names a shell or
149
+ * file tool, whatever built it (the agent team, `context.turns`, a plugin), so the turn fails
150
+ * closed before any session runs it.
151
+ */
145
152
  toolsFor(selection: ToolSelection): string[] {
153
+ if (this.chatOnly) refuseShellTools(selection.tools, "a tool selection");
146
154
  const groups = planOrder(this.plan).flatMap(
147
155
  (tool) => tool.snapshot().groups ?? [],
148
156
  );
@@ -359,7 +367,8 @@ export class SessionFactory {
359
367
  },
360
368
  conversation,
361
369
  addressee,
362
- ...(agents
370
+ // A chat-only host's agents work in no workspace.
371
+ ...(agents && !this.chatOnly
363
372
  ? {
364
373
  workspace: {
365
374
  workspace: cwd,
@@ -390,42 +399,57 @@ export class SessionFactory {
390
399
  noThemes: true,
391
400
  noContextFiles: true,
392
401
  additionalExtensionPaths: this.link().extensionPaths,
393
- extensionFactories: sessionExtensions(this.plan, context, {
394
- readAttachment: readAttachmentExtension(attachmentDir),
395
- ...(context.workspace
396
- ? { attachFile: attachFileExtension(context.workspace) }
397
- : {}),
398
- ...imageTools(
399
- this.#options.images,
400
- context.workspace ?? { workspace: cwd },
401
- ),
402
- confirmationGate: confirmationGateExtension(gate, slot, (signal) =>
403
- toolTurn(context, signal),
404
- ),
405
- askUser: askUserExtension(slot, addressee, () =>
406
- turnAddressee(
407
- this.#deps.speaker(turnKey),
408
- state,
409
- this.#options.owner,
402
+ extensionFactories: [
403
+ ...(persona === undefined
404
+ ? []
405
+ : [
406
+ {
407
+ name: "persona-prompt",
408
+ factory: personaPromptExtension(
409
+ persona,
410
+ () => this.personaOf(kind),
411
+ logger,
412
+ kind,
413
+ ),
414
+ },
415
+ ]),
416
+ ...sessionExtensions(this.plan, context, {
417
+ readAttachment: readAttachmentExtension(attachmentDir),
418
+ ...(context.workspace
419
+ ? { attachFile: attachFileExtension(context.workspace) }
420
+ : {}),
421
+ ...imageTools(
422
+ this.#options.images,
423
+ context.workspace ?? { workspace: cwd },
410
424
  ),
411
- ),
412
- selfCompactGuard: selfCompactGuardExtension(),
413
- // Each request carries no one's memory but the running turn's reader's.
414
- privateMemory: privateMemoryExtension(
415
- conversation.visibility === "shared",
416
- () =>
417
- deps.withholdsMemory(turnKey)
418
- ? undefined
419
- : memoryReader(conversation, deps.speaker(turnKey)),
420
- state.draws,
421
- ),
422
- surfaceFormatting: surfaceFormattingExtension(() =>
423
- // A worker writes a report for the turn, not a reply on its surface.
424
- worker ? undefined : deps.formatting(turnKey),
425
- ),
426
- activeTools: activeToolsExtension(() => state.tools),
427
- }),
428
- // An agent's prompt is set before each run by the agent-prompt extension.
425
+ confirmationGate: confirmationGateExtension(gate, slot, (signal) =>
426
+ toolTurn(context, signal),
427
+ ),
428
+ askUser: askUserExtension(slot, addressee, () =>
429
+ turnAddressee(
430
+ this.#deps.speaker(turnKey),
431
+ state,
432
+ this.#options.owner,
433
+ ),
434
+ ),
435
+ selfCompactGuard: selfCompactGuardExtension(),
436
+ // Each request carries no one's memory but the running turn's reader's.
437
+ privateMemory: privateMemoryExtension(
438
+ conversation.visibility === "shared",
439
+ () =>
440
+ deps.withholdsMemory(turnKey)
441
+ ? undefined
442
+ : memoryReader(conversation, deps.speaker(turnKey)),
443
+ state.draws,
444
+ ),
445
+ surfaceFormatting: surfaceFormattingExtension(() =>
446
+ // A worker writes a report for the turn, not a reply on its surface.
447
+ worker ? undefined : deps.formatting(turnKey),
448
+ ),
449
+ activeTools: activeToolsExtension(() => state.tools),
450
+ }),
451
+ ],
452
+ // An agent's prompt is set before each run by the agent-prompt extension, and another kind's persona is read again before each run.
429
453
  appendSystemPrompt: persona === undefined ? [] : [persona],
430
454
  });
431
455
  await resourceLoader.reload();
@@ -434,7 +458,7 @@ export class SessionFactory {
434
458
  cwd,
435
459
  agentDir,
436
460
  // Pi's bash under its own name, run with TMPDIR at the agents' scratch dir.
437
- ...(agents?.scratchDir
461
+ ...(agents?.scratchDir && !this.chatOnly
438
462
  ? { customTools: [scratchBash(cwd, agents.scratchDir)] }
439
463
  : {}),
440
464
  thinkingLevel: thinking,
@@ -463,22 +487,3 @@ export class SessionFactory {
463
487
  return Object.assign(state, { session });
464
488
  }
465
489
  }
466
-
467
- /** `generate_image` for a session of a host with an image provider; nothing without one. */
468
- export function imageTools(
469
- images: ImageDrawer | undefined,
470
- roots: WorkspaceRoots,
471
- ): Pick<CoreExtensions, "generateImage"> {
472
- return images ? { generateImage: generateImageExtension(images, roots) } : {};
473
- }
474
-
475
- /** Pi's bash tool with TMPDIR set to `scratchDir`, so tools that read TMPDIR and GNU `mktemp` write there (BSD `mktemp` needs `-p "$TMPDIR"`). */
476
- export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
477
- // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
478
- return createBashToolDefinition(cwd, {
479
- spawnHook: (context) => ({
480
- ...context,
481
- env: { ...context.env, TMPDIR: scratchDir },
482
- }),
483
- }) as unknown as ToolDefinition;
484
- }
@@ -0,0 +1,27 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ type ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ImageDrawer } from "../contract/providers.ts";
6
+ import { generateImageExtension } from "../shared/generate-image-tool.ts";
7
+ import type { WorkspaceRoots } from "../shared/workspace-files.ts";
8
+ import type { CoreExtensions } from "./runtime-types.ts";
9
+
10
+ /** `generate_image` for a session of a host with an image provider; nothing without one. */
11
+ export function imageTools(
12
+ images: ImageDrawer | undefined,
13
+ roots: WorkspaceRoots,
14
+ ): Pick<CoreExtensions, "generateImage"> {
15
+ return images ? { generateImage: generateImageExtension(images, roots) } : {};
16
+ }
17
+
18
+ /** Pi's bash tool with TMPDIR set to `scratchDir`, so tools that read TMPDIR and GNU `mktemp` write there (BSD `mktemp` needs `-p "$TMPDIR"`). */
19
+ export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
20
+ // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
21
+ return createBashToolDefinition(cwd, {
22
+ spawnHook: (context) => ({
23
+ ...context,
24
+ env: { ...context.env, TMPDIR: scratchDir },
25
+ }),
26
+ }) as unknown as ToolDefinition;
27
+ }