pi-roundtable 0.22.4 → 0.23.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 (132) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +36 -10
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +7 -3
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/http/websocket.ts +2 -1
  75. package/src/core/i18n/agent-panel.ts +14 -21
  76. package/src/core/interactions/prompts.ts +12 -0
  77. package/src/core/modes/autonomy.ts +23 -0
  78. package/src/core/modes/conversation-modes.ts +213 -0
  79. package/src/core/modes/modes-plugin.ts +24 -0
  80. package/src/core/modules/delegation/delegator.ts +4 -4
  81. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  82. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  83. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  84. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  85. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  86. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  87. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  88. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  89. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  90. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  91. package/src/core/modules/schedules/schedules.ts +9 -3
  92. package/src/core/plugin.ts +8 -1
  93. package/src/core/presence/direct-channels.ts +30 -0
  94. package/src/core/presentation/reply-body.ts +25 -0
  95. package/src/core/registry/contributions.ts +31 -7
  96. package/src/core/registry/providers.ts +1 -1
  97. package/src/core/routing/conversation-turns.ts +2 -15
  98. package/src/core/routing/surface-port.ts +5 -1
  99. package/src/core/runtime/extensions/approval-card.ts +1 -5
  100. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  101. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  102. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  103. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  104. package/src/core/runtime/extensions/held-calls.ts +16 -1
  105. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  106. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  107. package/src/core/runtime/extensions/yolo.ts +105 -0
  108. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  109. package/src/core/runtime/runtime-plugin.ts +42 -1
  110. package/src/core/runtime/runtime-types.ts +11 -0
  111. package/src/core/runtime/turn-budget.ts +32 -5
  112. package/src/core/runtime/turn-limits.ts +14 -3
  113. package/src/core/services.ts +61 -9
  114. package/src/core/shared/attachment-reader.ts +6 -0
  115. package/src/core/shared/unix-server.ts +10 -1
  116. package/src/core/testing/modules.ts +12 -8
  117. package/src/core/testing/surface-threads.ts +58 -0
  118. package/src/core/testing/thread-host.ts +17 -5
  119. package/src/core/threads/dispatch-threads.ts +299 -0
  120. package/src/core/threads/host-threads.ts +19 -0
  121. package/src/core/tool-set.snapshot.json +5 -0
  122. package/src/index.ts +27 -1
  123. package/src/kit/characters.ts +5 -0
  124. package/src/kit/index.ts +2 -0
  125. package/src/kit/shell.ts +1 -0
  126. package/src/kit/support.ts +1 -1
  127. package/src/kit/threads.ts +1 -1
  128. package/src/testing.ts +4 -0
  129. package/templates/adapters/web/package.json.tmpl +1 -1
  130. package/templates/agents.ts +1 -1
  131. package/templates/package.json.tmpl +1 -1
  132. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -0,0 +1,104 @@
1
+ import { basename } from "node:path";
2
+ import { COMMAND_PREFIX_WORDS } from "./shell-lexer.ts";
3
+
4
+ /** A variable assignment word, `NAME=value` or `NAME+=value`, as it stands before a command. */
5
+ export const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
6
+
7
+ /**
8
+ * Words that run the next word as the command, with the options of each that take the word after
9
+ * them as their argument (`env -u NAME`, `timeout -s KILL`): that word is not the command.
10
+ */
11
+ export const WRAPPERS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
12
+ [
13
+ "env",
14
+ new Set(["-u", "--unset", "-C", "--chdir", "-P", "-S", "--split-string"]),
15
+ ],
16
+ ["nohup", new Set()],
17
+ ["time", new Set(["-f", "--format", "-o", "--output"])],
18
+ ["nice", new Set(["-n", "--adjustment"])],
19
+ ["ionice", new Set(["-c", "--class", "-n", "--classdata", "-p", "-P", "-u"])],
20
+ ["timeout", new Set(["-s", "--signal", "-k", "--kill-after"])],
21
+ ["stdbuf", new Set(["-i", "--input", "-o", "--output", "-e", "--error"])],
22
+ ["exec", new Set(["-a"])],
23
+ ["command", new Set()],
24
+ ["builtin", new Set()],
25
+ [
26
+ "xargs",
27
+ new Set(["-n", "-I", "-J", "-P", "-L", "-s", "-d", "-E", "-a", "-R", "-S"]),
28
+ ],
29
+ ["setsid", new Set()],
30
+ ["unbuffer", new Set()],
31
+ ["caffeinate", new Set(["-t", "-w"])],
32
+ ]);
33
+
34
+ /** Whether a word is the duration `timeout` takes, or the number a wrapper option leaves behind. */
35
+ const DURATION = /^\d+(?:\.\d+)?[smhd]?$/;
36
+
37
+ /** The string `xargs` replaces with each input line (`-I str`, `-Istr`, `-J str`, `-i`, `--replace`), given one of its options and the word after it. */
38
+ function xargsReplace(
39
+ option: string,
40
+ next: string | undefined,
41
+ ): string | undefined {
42
+ if (option === "-I" || option === "-J") return next;
43
+ if (option.startsWith("-I") || option.startsWith("-J"))
44
+ return option.slice(2);
45
+ if (option === "-i" || option === "--replace") return "{}";
46
+ if (option.startsWith("-i") || option.startsWith("--replace="))
47
+ return option.slice(
48
+ option.indexOf("=") === -1 ? 2 : option.indexOf("=") + 1,
49
+ );
50
+ return undefined;
51
+ }
52
+
53
+ /** The string that stands for the input in an `xargs` of `prefix` (the words `program` dropped), if it has one. */
54
+ export function replaceString(prefix: readonly string[]): string | undefined {
55
+ return prefix.find((word) => word.startsWith("xargs -I "))?.slice(9);
56
+ }
57
+
58
+ /**
59
+ * Drops leading variable assignments, wrappers, and their options, leaving the real program;
60
+ * `prefix` collects what was dropped. An `env -S` splits its argument into a command line, so it is
61
+ * recorded as `env -S` for the caller to hold, and the string an `xargs` replaces as `xargs -I str`.
62
+ */
63
+ export function program(words: string[], prefix: string[] = []): string[] {
64
+ let rest = words;
65
+ for (;;) {
66
+ const [first] = rest;
67
+ if (first === undefined) return rest;
68
+ if (COMMAND_PREFIX_WORDS.has(first)) {
69
+ rest = rest.slice(1);
70
+ continue;
71
+ }
72
+ if (ASSIGNMENT_WORD.test(first)) {
73
+ prefix.push(first);
74
+ rest = rest.slice(1);
75
+ continue;
76
+ }
77
+ const wrapper = basename(first);
78
+ const takesArgument = WRAPPERS.get(wrapper);
79
+ if (takesArgument) {
80
+ prefix.push(wrapper);
81
+ rest = rest.slice(1);
82
+ // Wrapper options (with the argument some take) and the timeout's duration.
83
+ while (
84
+ rest[0] !== undefined &&
85
+ (rest[0].startsWith("-") ||
86
+ DURATION.test(rest[0]) ||
87
+ ASSIGNMENT_WORD.test(rest[0]))
88
+ ) {
89
+ const option = rest[0];
90
+ rest = rest.slice(1);
91
+ if (wrapper === "xargs") {
92
+ const replace = xargsReplace(option, rest[0]);
93
+ if (replace !== undefined) prefix.push(`xargs -I ${replace}`);
94
+ }
95
+ if (!takesArgument.has(option)) continue;
96
+ if (wrapper === "env" && /^(-S|--split-string)/.test(option))
97
+ prefix.push("env -S");
98
+ rest = rest.slice(1);
99
+ }
100
+ continue;
101
+ }
102
+ return rest;
103
+ }
104
+ }
@@ -417,6 +417,7 @@ export function precheckScriptHoldRule(options: {
417
417
  };
418
418
  return {
419
419
  name: "precheck-scripts",
420
+ relay: true,
420
421
  describe(tool, input, context) {
421
422
  const held = heldCalls(tool, input, context);
422
423
  if (held.length === 0) return undefined;
@@ -430,6 +431,22 @@ export function precheckScriptHoldRule(options: {
430
431
  held.map((call) => call.held),
431
432
  );
432
433
  },
434
+ // The classes of every held call the script makes, so yolo mode approves saving it only when it
435
+ // would approve each of them: a scheduled run makes them with no one to ask. A call no rule
436
+ // classes gives the name of the rule that holds it, or of this rule when none is known.
437
+ holdClasses(tool, input, context) {
438
+ const holds = options.holds();
439
+ return scriptCalls(tool, input, context).flatMap((call) => {
440
+ const own = holds.classesOf?.(call.name, call.input, context) ?? [];
441
+ // A call computed when the script runs may be held for more than its rules say now, so it
442
+ // is also unclassed: under its own rules' bare names, or the rule that may hold it.
443
+ if (own.length > 0 && call.literal) return own;
444
+ const may = holds.mayHold?.(call.name);
445
+ const bare = own.map((id) => id.split(".")[0] as string);
446
+ if (own.length > 0) return [...own, ...new Set(bare.concat(may ?? []))];
447
+ return [may ?? "precheck-scripts"];
448
+ });
449
+ },
433
450
  // Whoever approves must be able to approve each held call the script makes.
434
451
  approvalTier(tool, input, context) {
435
452
  const tiers = options.tiers?.();
@@ -44,8 +44,14 @@ export interface OwnerSchedules {
44
44
  target?: BackgroundTarget;
45
45
  }
46
46
 
47
- /** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
48
- export type AgentChannelLookup = (agent: string) => ChannelKey;
47
+ /**
48
+ * Finds another agent's channel, for reading its schedules, on the asker's surface when that one
49
+ * hosts agent rooms (`near` is the asker's own conversation); throws ScheduleError when there is none.
50
+ */
51
+ export type AgentChannelLookup = (
52
+ agent: string,
53
+ near: ChannelKey,
54
+ ) => ChannelKey;
49
55
 
50
56
  /** schedule_list for agents: an optional agent name reads that agent's schedules instead. */
51
57
  function withAgentOption(spec: ScheduleToolSpec): ScheduleToolSpec {
@@ -99,7 +105,7 @@ export function schedulesExtension(
99
105
  );
100
106
  const peer =
101
107
  agents && typeof input.agent === "string" && input.agent
102
- ? agents(input.agent)
108
+ ? agents(input.agent, channel)
103
109
  : undefined;
104
110
  const target = peer ?? (await channelFor(channel, author.principalId));
105
111
  const answer = await callScheduleTool(
@@ -15,7 +15,7 @@ import type { ToolContribution } from "./define.ts";
15
15
  import type { TurnProgress } from "./domain/progress.ts";
16
16
  import { PluginError } from "./errors.ts";
17
17
  import type { HoldCheck, HoldRule } from "./holds.ts";
18
- import type { HttpRoute } from "./http/listeners.ts";
18
+ import type { HttpRoute, ListenerAddress } from "./http/listeners.ts";
19
19
  import type { Locale } from "./i18n/index.ts";
20
20
  import type { Logger } from "./log.ts";
21
21
  import type {
@@ -321,6 +321,13 @@ export interface PluginContext {
321
321
  * never throws for that. The value is a secret: keep it out of logs and error messages.
322
322
  */
323
323
  apiKey(provider: string): Promise<string | undefined>;
324
+ /**
325
+ * Where the host's listener `id` serves, as configured: a unix socket (with its mode) or a TCP
326
+ * port (with its hostname, absent when the host did not set one). `undefined` for an id the
327
+ * host has no listener for. A plugin that serves without authentication reads it to refuse an
328
+ * address that reaches beyond the machine.
329
+ */
330
+ listener(id: string): Readonly<ListenerAddress> | undefined;
324
331
  }
325
332
 
326
333
  /**
@@ -1,6 +1,7 @@
1
1
  import type { SurfacePort } from "../contract/surface.ts";
2
2
  import type { ChannelKey } from "../domain/conversation.ts";
3
3
  import type { Notifier } from "../domain/ports.ts";
4
+ import type { Logger } from "../log.ts";
4
5
  import { splitReply } from "../presentation/reply-splitter.ts";
5
6
 
6
7
  /**
@@ -34,6 +35,13 @@ export interface DirectChannelProvider {
34
35
  * names, through the surface that serves it.
35
36
  */
36
37
  deliver?(principalId: string, text: string): Promise<void>;
38
+ /**
39
+ * Called with the notice after another provider delivered it, so this one can echo it, such as
40
+ * a web app pushing a prompt to a phone that Discord's direct message already reached. It runs
41
+ * only after a delivery, on every provider but the one that delivered; its failure is logged
42
+ * and never changes what `notify` returns.
43
+ */
44
+ mirror?(principalId: string, text: string): Promise<void>;
37
45
  }
38
46
 
39
47
  /** Where a person was reached: the provider, and their conversation on it. */
@@ -67,6 +75,7 @@ export interface DirectChannels extends Notifier {
67
75
  export function directChannelsPort(
68
76
  providers: () => readonly DirectChannelProvider[],
69
77
  surfaces: Pick<SurfacePort, "sendReply">,
78
+ logger?: Pick<Logger, "warn">,
70
79
  ): DirectChannels {
71
80
  const reach = async (
72
81
  principalId: string,
@@ -77,6 +86,26 @@ export function directChannelsPort(
77
86
  }
78
87
  return undefined;
79
88
  };
89
+ const mirrored = async (
90
+ delivered: DirectChannelProvider,
91
+ principalId: string,
92
+ text: string,
93
+ ): Promise<void> => {
94
+ await Promise.all(
95
+ providers()
96
+ .filter((provider) => provider !== delivered && provider.mirror)
97
+ .map(async (provider) => {
98
+ try {
99
+ await provider.mirror?.(principalId, text);
100
+ } catch (error) {
101
+ logger?.warn(
102
+ { err: error, provider: provider.name },
103
+ "a direct channel did not mirror a notice",
104
+ );
105
+ }
106
+ }),
107
+ );
108
+ };
80
109
  return {
81
110
  providers: () => providers(),
82
111
  reach,
@@ -98,6 +127,7 @@ export function directChannelsPort(
98
127
  await surfaces.sendReply(reached.channel, {
99
128
  chunks: splitReply(text),
100
129
  });
130
+ await mirrored(reached.provider, principalId, text);
101
131
  return true;
102
132
  },
103
133
  };
@@ -0,0 +1,25 @@
1
+ import type { ReplyFile, TurnResult } from "../domain/conversation.ts";
2
+ import { failureNotice } from "./failure-notice.ts";
3
+ import { splitReply } from "./reply-splitter.ts";
4
+ import { thinkingLine } from "./thinking-line.ts";
5
+
6
+ /** What a turn posts: its answer with a thinking line, or the failure or stopped notice. */
7
+ export function replyBody(result: TurnResult): {
8
+ thinking?: string;
9
+ chunks: string[];
10
+ files?: ReplyFile[];
11
+ /** Set on the notice for a turn that ended in an error, not on a stopped one. */
12
+ failed?: true;
13
+ } {
14
+ if (!result.ok)
15
+ return {
16
+ chunks: [failureNotice(result)],
17
+ ...(result.stopped ? {} : { failed: true as const }),
18
+ };
19
+ const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
20
+ return {
21
+ ...(thinking ? { thinking } : {}),
22
+ chunks: splitReply(result.text),
23
+ ...(result.files?.length ? { files: result.files } : {}),
24
+ };
25
+ }
@@ -1,3 +1,4 @@
1
+ import { CharacterError, resolveCharacter } from "../../characters/index.ts";
1
2
  import type { AgentSeed } from "../agents/agent-store.ts";
2
3
  import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
3
4
  import type { ChatSurface } from "../contract/surface.ts";
@@ -126,6 +127,24 @@ function checkKeys(plugin: string, contribution: Contribution): void {
126
127
  }
127
128
  }
128
129
 
130
+ /** Refuses a seed whose `character` the characters module would not resolve. */
131
+ function checkSeedCharacters(
132
+ plugin: string,
133
+ seeds: readonly AgentSeed[],
134
+ ): void {
135
+ for (const seed of seeds) {
136
+ if (seed.character === undefined) continue;
137
+ try {
138
+ resolveCharacter(seed.character);
139
+ } catch (error) {
140
+ if (!(error instanceof CharacterError)) throw error;
141
+ throw new PluginError(
142
+ `plugin ${plugin}: seed "${seed.name}" has a bad character, field "${error.field}": ${error.message}.`,
143
+ );
144
+ }
145
+ }
146
+ }
147
+
129
148
  /** The selections' tools and groups, each once, in the order the plugins gave them. */
130
149
  function mergeSelections(
131
150
  selections: readonly (() => ToolSelection)[],
@@ -295,13 +314,17 @@ export async function collectContributions(
295
314
  let collected = false;
296
315
  const context: Omit<PluginContext, "services"> = {
297
316
  ...base,
298
- directChannels: directChannelsPort(() => {
299
- if (!collected)
300
- throw new NotLinkedError(
301
- "direct channels are linked once every plugin is set up. Use them from a service's start, a handler, or a session tool, not during setup.",
302
- );
303
- return registry.directChannels;
304
- }, base.surfaces),
317
+ directChannels: directChannelsPort(
318
+ () => {
319
+ if (!collected)
320
+ throw new NotLinkedError(
321
+ "direct channels are linked once every plugin is set up. Use them from a service's start, a handler, or a session tool, not during setup.",
322
+ );
323
+ return registry.directChannels;
324
+ },
325
+ base.surfaces,
326
+ base.logger,
327
+ ),
305
328
  };
306
329
  const serviceNames = new Names("service");
307
330
  const sessionTools = new Names("session tool");
@@ -439,6 +462,7 @@ export async function collectContributions(
439
462
  registry.backgroundTargets.push(...backgroundTargets);
440
463
  registry.directChannels.push(...directChannels);
441
464
  registry.dashboard.push(...dashboard);
465
+ checkSeedCharacters(plugin.name, seeds);
442
466
  registry.seeds.push(...seeds);
443
467
  registry.prompt.push(...prompt);
444
468
  registry.requiredTools.push(...requiredTools);
@@ -30,7 +30,7 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
30
30
  judge: judgeModel ? new ModelJudge(judgeModel) : unconfiguredJudge(),
31
31
  images: async () => {
32
32
  throw new ProviderError(
33
- "no image provider is configured; agents use generated avatars",
33
+ "no image provider is configured: a plugin fills the images slot to let agents make pictures",
34
34
  );
35
35
  },
36
36
  runtime: () => {
@@ -13,9 +13,7 @@ import { HostStoppingError, PluginError } from "../errors.ts";
13
13
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
14
14
  import type { Logger } from "../log.ts";
15
15
  import type { EventSink } from "../plugin.ts";
16
- import { failureNotice } from "../presentation/failure-notice.ts";
17
- import { splitReply } from "../presentation/reply-splitter.ts";
18
- import { thinkingLine } from "../presentation/thinking-line.ts";
16
+ import { replyBody } from "../presentation/reply-body.ts";
19
17
  import { withReplyFiles } from "../reply-files.ts";
20
18
  import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
21
19
  import type { Speaker } from "../speakers.ts";
@@ -226,7 +224,7 @@ export function conversationTurns(
226
224
  if (!result.ok && !result.stopped)
227
225
  logger.error({ channel, kind, err: result.error }, "turn failed");
228
226
  if (input.reply) await input.reply(result);
229
- else await surfaces.sendReply(channel, replyOf(result));
227
+ else await surfaces.sendReply(channel, replyBody(result));
230
228
  } catch (error) {
231
229
  logger.error({ channel, kind, err: error }, "reply not posted");
232
230
  } finally {
@@ -236,14 +234,3 @@ export function conversationTurns(
236
234
  },
237
235
  };
238
236
  }
239
-
240
- /** The default post: the answer with its thinking line, or the failure or stopped notice. */
241
- function replyOf(result: TurnResult) {
242
- if (!result.ok) return { chunks: [failureNotice(result)] };
243
- const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
244
- return {
245
- ...(thinking ? { thinking } : {}),
246
- chunks: splitReply(result.text),
247
- ...(result.files?.length ? { files: result.files } : {}),
248
- };
249
- }
@@ -56,7 +56,11 @@ export function surfacePort(
56
56
  of(channel)?.unreact?.(channel, messageId, emoji),
57
57
  prompts: (channel, scope) =>
58
58
  of(channel)?.prompts?.(channel, scopeOf(scope, options)),
59
- interim: (channel) => of(channel)?.interim?.(channel),
59
+ roomSurfaces: () =>
60
+ linked()
61
+ .filter((surface) => surface.agentRooms === true)
62
+ .map((surface) => surface.surface),
63
+ interim: (channel, as) => of(channel)?.interim?.(channel, as),
60
64
  progress: async (channel, event) => {
61
65
  await of(channel)?.progress?.(channel, event);
62
66
  },
@@ -5,6 +5,7 @@ import { messages } from "../../i18n/index.ts";
5
5
  import type { ApprovalDetails } from "../../interactions/prompts.ts";
6
6
  import { fieldList, schemaFields } from "./approval-fields.ts";
7
7
  import { countPoints, firstPoints, prefix } from "./card-text.ts";
8
+ import { isPlainObject } from "./held-calls.ts";
8
9
 
9
10
  /** The fewest characters of a long string value in a held call's input its approval card keeps, when the input does not fit whole. */
10
11
  const CARD_VALUE_CHARS = 200;
@@ -193,8 +194,3 @@ function fileSize(path: string, workspace: string | undefined) {
193
194
  return undefined;
194
195
  }
195
196
  }
196
-
197
- // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
198
- function isPlainObject(value: unknown): value is Record<string, unknown> {
199
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
200
- }
@@ -1,5 +1,6 @@
1
1
  import { messages } from "../../i18n/index.ts";
2
2
  import { countPoints, firstPoints } from "./card-text.ts";
3
+ import { isPlainObject } from "./held-calls.ts";
3
4
 
4
5
  /** The longest label a card shows for a field, in code points. */
5
6
  const LABEL_CHARS = 40;
@@ -27,11 +28,6 @@ export interface FieldLimits {
27
28
  totalChars: number;
28
29
  }
29
30
 
30
- // pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
31
- function isRecord(value: unknown): value is Record<string, unknown> {
32
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
33
- }
34
-
35
31
  /**
36
32
  * The arguments of a call in the order its tool's parameter schema lists them, each labelled by
37
33
  * its property's `title` or else its name; absent arguments are left out, and an argument the
@@ -43,7 +39,7 @@ export function schemaFields(
43
39
  // pi-lens-ignore: no-unknown-parameters — a tool's parameter schema is untyped JSON
44
40
  schema: unknown,
45
41
  ): CardField[] | undefined {
46
- if (!input || !isRecord(schema) || !isRecord(schema.properties))
42
+ if (!input || !isPlainObject(schema) || !isPlainObject(schema.properties))
47
43
  return undefined;
48
44
  const properties = schema.properties;
49
45
  const fields: CardField[] = [];
@@ -53,7 +49,7 @@ export function schemaFields(
53
49
  const value = input[name];
54
50
  if (value === undefined) continue;
55
51
  const title =
56
- isRecord(property) && typeof property.title === "string"
52
+ isPlainObject(property) && typeof property.title === "string"
57
53
  ? property.title.trim()
58
54
  : "";
59
55
  fields.push({ label: title || name, value });
@@ -38,8 +38,9 @@ export function confirmationGateExtension(
38
38
  const turn: HoldTurn | undefined = turnOf
39
39
  ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
40
40
  : undefined;
41
- const { reason, approvedOnCard, approvedBy, approvedPrompt, refused } =
42
- await gate.decide(
41
+ let decision: Awaited<ReturnType<ConfirmationGate["decide"]>>;
42
+ try {
43
+ decision = await gate.decide(
43
44
  event.toolName,
44
45
  event.input as Record<string, unknown>,
45
46
  ask,
@@ -48,6 +49,16 @@ export function confirmationGateExtension(
48
49
  pi.getAllTools?.().find((tool) => tool.name === event.toolName)
49
50
  ?.parameters,
50
51
  );
52
+ } catch (error) {
53
+ // A hold rule that fails must not let the call run unjudged.
54
+ if (ctx.signal?.aborted) throw error;
55
+ return {
56
+ block: true,
57
+ reason: `The call was blocked because its safety check failed (${error instanceof Error ? error.message : String(error)}). It was not run.`,
58
+ };
59
+ }
60
+ const { reason, approvedOnCard, approvedBy, approvedPrompt, refused } =
61
+ decision;
51
62
  if (approvedOnCard) approved.add(event.toolCallId);
52
63
  if (!reason && (approvedBy || approvedPrompt))
53
64
  gate.grant(event.toolCallId, {
@@ -2,7 +2,6 @@ import type {
2
2
  HeldCall,
3
3
  PendingConfirmation,
4
4
  } from "../../domain/conversation.ts";
5
- import type { HoldRefusal } from "../../domain/progress.ts";
6
5
  import {
7
6
  HOLD_DESCRIBE_TIMEOUT_MS,
8
7
  type HoldCheck,
@@ -12,19 +11,17 @@ import {
12
11
  } from "../../holds.ts";
13
12
  import { messages } from "../../i18n/index.ts";
14
13
  import type { OwnerIdentity } from "../../identity.ts";
15
- import type {
16
- ApprovedBy,
17
- LateAnswer,
18
- Prompts,
19
- } from "../../interactions/prompts.ts";
14
+ import type { ApprovedBy, Prompts } from "../../interactions/prompts.ts";
20
15
  import type { ToolTiers } from "../../tool-tiers.ts";
21
16
  import { approvalCard, approvalDetails } from "./approval-card.ts";
22
17
  import { askOnCard } from "./card-ask.ts";
23
18
  import {
19
+ type AutoApprover,
24
20
  approverOf,
25
21
  askSpeaker,
26
22
  awaitingText,
27
23
  declinedText,
24
+ type GateDecision,
28
25
  type Needing,
29
26
  namesOf,
30
27
  type Release,
@@ -39,6 +36,7 @@ import {
39
36
  mayApproveNamed,
40
37
  namedApprover,
41
38
  sameCall,
39
+ spendApproval,
42
40
  unexpired,
43
41
  } from "./held-calls.ts";
44
42
  import { HoldDescriber } from "./hold-describer.ts";
@@ -55,8 +53,9 @@ export {
55
53
  unexpired,
56
54
  } from "./held-calls.ts";
57
55
 
58
- import { type Watch, Withdrawals } from "./withdrawals.ts";
56
+ import { Withdrawals } from "./withdrawals.ts";
59
57
 
58
+ export type { AutoApprover, GateDecision } from "./gate-refusals.ts";
60
59
  export type { HoldTurn } from "./hold-lookups.ts";
61
60
 
62
61
  /**
@@ -73,6 +72,8 @@ export class ConfirmationGate {
73
72
  #speaker: SpeakerRef | undefined;
74
73
  /** Whether the running turn's conversation is private to its speaker, who then alone answers its cards. */
75
74
  #private = false;
75
+ /** The running turn's auto-approver, asked before any card opens. */
76
+ #auto: AutoApprover | undefined;
76
77
  #pending: PendingConfirmation | undefined;
77
78
  #approved: Release[] = [];
78
79
  /** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
@@ -160,7 +161,8 @@ export class ConfirmationGate {
160
161
  * approve them. When `confirmed`, the speaker's message approved the held calls, and they are
161
162
  * the approver the calls' `run` sees; a call that named approvers is released only when the
162
163
  * speaker is one of them or an owner, and otherwise stays held. `isPrivate` says the turn's
163
- * conversation is private to its speaker: they alone then answer its cards.
164
+ * conversation is private to its speaker: they alone then answer its cards. `auto` may approve
165
+ * a call the turn makes without a card (yolo mode); it does not change a message's approval.
164
166
  */
165
167
  beginTurn(
166
168
  selectionId: string,
@@ -168,10 +170,12 @@ export class ConfirmationGate {
168
170
  addressee?: OwnerIdentity,
169
171
  speaker?: SpeakerRef,
170
172
  isPrivate = false,
173
+ auto?: AutoApprover,
171
174
  ): void {
172
175
  this.#selectionId = selectionId;
173
176
  this.#speaker = speaker;
174
177
  this.#private = isPrivate;
178
+ this.#auto = auto;
175
179
  this.#addressee = addressee ?? this.#owner;
176
180
  const by = approverOf(speaker);
177
181
  const held = this.pending();
@@ -287,16 +291,7 @@ export class ConfirmationGate {
287
291
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
288
292
  turn?: HoldTurn,
289
293
  parameters?: unknown,
290
- ): Promise<{
291
- reason?: string;
292
- approvedOnCard?: true;
293
- /** Who approved the call, when it was held and a surface or a confirming message said who. */
294
- approvedBy?: ApprovedBy;
295
- /** The id of the card whose approval released the call, in this turn or answered late; absent for a call a message confirmed. */
296
- approvedPrompt?: string;
297
- /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
298
- refused?: HoldRefusal;
299
- }> {
294
+ ): Promise<GateDecision> {
300
295
  const found = this.#needingIn(tool, input, turn);
301
296
  const needing = isPromise(found) ? await found : found;
302
297
  if (needing === STOPPED) return { reason: "The turn was stopped." };
@@ -320,6 +315,14 @@ export class ConfirmationGate {
320
315
  if (watch) watch.card = false;
321
316
  return { reason: await this.#held(call), refused };
322
317
  };
318
+ // Outside a turn there is no speaker whose conversation could be in yolo mode.
319
+ const auto = turn
320
+ ? this.#auto?.(call, { speakerOnly: needing.speakerOnly === true })
321
+ : undefined;
322
+ if (auto) {
323
+ watch?.end();
324
+ return { approvedBy: auto };
325
+ }
323
326
  if (!ask) return held("held");
324
327
  // In a private conversation only its person answers: a call that names others is not theirs.
325
328
  if (this.#private && !mayApproveNamed(this.#speaker, call))
@@ -331,7 +334,14 @@ export class ConfirmationGate {
331
334
  message: approvalCard(call, this.#fileRoot, {}, parameters),
332
335
  signal: ask.signal,
333
336
  minTier: higherTier(this.#tiers?.minTier(call.tool), call.minTier),
334
- wait: { late: (late) => this.#late(call, late, watch) },
337
+ wait: {
338
+ // A late answer on the card: the text of the turn that tells the model, and the release it earned.
339
+ late: (late) => {
340
+ const outcome = lateOutcome(call, late, watch);
341
+ if (outcome?.release) this.#lateApproved.push(outcome.release);
342
+ return outcome?.text;
343
+ },
344
+ },
335
345
  details: approvalDetails(call, this.#fileRoot, needing.copy),
336
346
  approvers: call.approverIds,
337
347
  speakerOnly: needing.speakerOnly === true,
@@ -377,21 +387,12 @@ export class ConfirmationGate {
377
387
  return held(answer === "unavailable" ? "held" : "expired");
378
388
  }
379
389
 
380
- /** A late answer on a call's card; see `lateOutcome`. Returns the text of the turn that tells the model. */
381
- #late(
382
- call: HeldCall,
383
- late: LateAnswer,
384
- watch: Watch | undefined,
385
- ): string | undefined {
386
- const outcome = lateOutcome(call, late, watch);
387
- if (outcome?.release) this.#lateApproved.push(outcome.release);
388
- return outcome?.text;
389
- }
390
-
391
390
  /** What a call needs when a rule holds it; undefined when none does. */
392
391
  #needing(tool: string, input: Record<string, unknown>): Needing | undefined {
393
392
  const action = this.#holds(tool, input, this.#context);
394
- return action ? this.#unlessApproved(tool, input, { action }) : undefined;
393
+ if (!action) return undefined;
394
+ const classes = this.#holds.classesOf?.(tool, input, this.#context);
395
+ return this.#unlessApproved(tool, input, { action, classes });
395
396
  }
396
397
 
397
398
  /**
@@ -431,17 +432,17 @@ export class ConfirmationGate {
431
432
  action: judged.action,
432
433
  ...(minTier ? { minTier } : {}),
433
434
  ...(judged.approvers ? { approverIds: [...judged.approvers] } : {}),
435
+ ...(judged.classes && judged.classes.length > 0
436
+ ? { holdClasses: [...judged.classes] }
437
+ : {}),
434
438
  };
435
439
  // A plugin that already withdrew the call lets no approval of it release it.
436
- for (const released of judged.supersede?.aborted
437
- ? []
438
- : [this.#approved, this.#lateApproved]) {
439
- const approved = released.findIndex((r) => sameCall(r.call, call));
440
- const [spent] = approved === -1 ? [] : released.splice(approved, 1);
441
- if (spent) {
442
- this.#withdrawals.endCall(spent.call);
443
- return { released: spent };
444
- }
440
+ const spent = judged.supersede?.aborted
441
+ ? undefined
442
+ : spendApproval([this.#approved, this.#lateApproved], call);
443
+ if (spent) {
444
+ this.#withdrawals.endCall(spent.call);
445
+ return { released: spent };
445
446
  }
446
447
  return {
447
448
  held: call,