pi-roundtable 0.9.6 → 0.9.8

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.
@@ -3,17 +3,40 @@ import type { TurnRequest } from "../domain/ports.ts";
3
3
  import type { Speaker, Tier } from "../speakers.ts";
4
4
 
5
5
  /**
6
- * How a prompt ended: answered yes or no; `expired` when no one could answer it (no one's to
7
- * approve, or the card could not be posted); `cancelled` when its turn stopped; or `pending` when
8
- * the turn stopped waiting while the card stays open, its answer to come through `PromptWait`.
6
+ * How a prompt ended: answered yes or no; `expired` when the card was shown and no one answered
7
+ * it in time; `unavailable` when no card could be shown (no one may approve the call, the speaker
8
+ * is below its tier, or the card could not be posted); `cancelled` when its turn stopped; or
9
+ * `pending` when the turn stopped waiting while the card stays open, its answer to come through
10
+ * `PromptWait`. A surface that cannot tell the two may answer `expired` for both.
9
11
  */
10
12
  export type Approval =
11
13
  | "approved"
12
14
  | "declined"
13
15
  | "expired"
16
+ | "unavailable"
14
17
  | "cancelled"
15
18
  | "pending";
16
19
 
20
+ /** A file a held call sends by path; `bytes` is its size when it was read, absent when it could not be. */
21
+ export interface ApprovalFile {
22
+ path: string;
23
+ bytes?: number;
24
+ }
25
+
26
+ /**
27
+ * What an approval is for, as data: the same facts a card's text shows, so a surface that is not
28
+ * text (a web client) need not read them back out of the markdown. `input` is the call's whole
29
+ * input, never cut.
30
+ */
31
+ export interface ApprovalDetails {
32
+ /** What the call would do, in plain words. */
33
+ action: string;
34
+ tool: string;
35
+ input: Record<string, unknown>;
36
+ /** The files the call sends by path; absent when it sends none. */
37
+ files?: ApprovalFile[];
38
+ }
39
+
17
40
  /** An answer given on a prompt after its turn stopped waiting; `by` names who gave it. */
18
41
  export type LateAnswer = { by: string } & (
19
42
  | { kind: "approval"; approved: boolean }
@@ -80,7 +103,7 @@ export interface PromptScope {
80
103
  /**
81
104
  * Who else may answer: `owners` in a shared conversation, where an approval above the
82
105
  * speaker's tier goes to the owners; `none` in a private one, which no one else sees, so such
83
- * an approval expires at once.
106
+ * an approval comes back `unavailable` at once.
84
107
  */
85
108
  escalate: "owners" | "none";
86
109
  }
@@ -95,7 +118,8 @@ export interface Prompts {
95
118
  * Asks to approve an action; `signal` cancels the prompt when the turn stops. `minTier` is the
96
119
  * tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
97
120
  * prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
98
- * `pending` comes only with a `wait`.
121
+ * `pending` comes only with a `wait`. `details` is the approval as data beside `message`, its
122
+ * text; a surface that shows text only ignores it.
99
123
  */
100
124
  confirm(
101
125
  title: string,
@@ -103,6 +127,7 @@ export interface Prompts {
103
127
  signal?: AbortSignal,
104
128
  minTier?: Tier,
105
129
  wait?: PromptWait,
130
+ details?: ApprovalDetails,
106
131
  ): Promise<Approval>;
107
132
  /**
108
133
  * Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
@@ -0,0 +1,210 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { HeldCall } from "../../domain/conversation.ts";
4
+ import { messages } from "../../i18n/index.ts";
5
+ import type { ApprovalDetails } from "../../interactions/prompts.ts";
6
+
7
+ /** 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. */
8
+ const CARD_VALUE_CHARS = 200;
9
+
10
+ /** How many characters of the whole input its approval card shows, at most. */
11
+ const CARD_INPUT_CHARS = 1_500;
12
+
13
+ /** How many hidden key names a card lists, and how long each may be. */
14
+ const CARD_HIDDEN_KEYS = 20;
15
+ const CARD_KEY_CHARS = 40;
16
+
17
+ /** What an approval card shows of a call's input; see `approvalCard`. */
18
+ export interface CardLimits {
19
+ /**
20
+ * The fewest characters of a long string value (at any depth) kept when the input is over
21
+ * `totalChars`; a value is cut only as far as the input needs, never below this. Counted in code points.
22
+ */
23
+ valueChars?: number;
24
+ /** Longest input shown. An input within it is shown whole; a longer one has its values cut, then the rest dropped. */
25
+ totalChars?: number;
26
+ }
27
+
28
+ /**
29
+ * What an approval card says: the held action's words, the exact call, and each file it sends by
30
+ * path with the file's size now; relative paths resolve against `workspace`. An input within
31
+ * `limits.totalChars` is shown whole. A longer one has its string values cut, the longest first
32
+ * and never below `limits.valueChars`, each marked `… [N chars]` with its original length in code
33
+ * points, until it fits, so one long value does not push the keys after it out of the card. If it
34
+ * still does not fit, the rest is dropped and the keys that are no longer shown are listed.
35
+ */
36
+ export function approvalCard(
37
+ call: HeldCall,
38
+ workspace?: string,
39
+ limits: CardLimits = {},
40
+ ): string {
41
+ const input = shownInput(
42
+ call.input,
43
+ limits.valueChars ?? CARD_VALUE_CHARS,
44
+ limits.totalChars ?? CARD_INPUT_CHARS,
45
+ );
46
+ const files = pathFiles(call.input).map(
47
+ (path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
48
+ );
49
+ return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
50
+ }
51
+
52
+ /** The input as a card shows it within `total` characters; see `approvalCard`. */
53
+ function shownInput(input: string, valueChars: number, total: number): string {
54
+ if (input.length <= total) return input;
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(input);
58
+ } catch {
59
+ return `${prefix(input, total)}…`;
60
+ }
61
+ const longest = longestString(parsed);
62
+ const cutAt = (limit: number) => cutValues(parsed, limit);
63
+ let limit = valueChars;
64
+ if (cutAt(limit).length <= total) {
65
+ // The largest limit that still fits: the longest values give up characters first.
66
+ let low = limit;
67
+ let high = Math.max(low, longest - 1);
68
+ while (low < high) {
69
+ const mid = Math.ceil((low + high) / 2);
70
+ if (cutAt(mid).length <= total) low = mid;
71
+ else high = mid - 1;
72
+ }
73
+ limit = low;
74
+ return cutAt(limit);
75
+ }
76
+ const shown = cutAt(limit);
77
+ const kept = prefix(shown, total);
78
+ return `${kept}…${hiddenKeys(parsed, limit, kept.length)}`;
79
+ }
80
+
81
+ /** The longest string value in `value`, at any depth, in code points; 0 when there is none. */
82
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
83
+ function longestString(value: unknown): number {
84
+ let longest = 0;
85
+ JSON.stringify(value, (_key, v: unknown) => {
86
+ if (typeof v === "string" && v.length > longest)
87
+ longest = Math.max(longest, countPoints(v));
88
+ return v;
89
+ });
90
+ return longest;
91
+ }
92
+
93
+ /** `value` as JSON with each string longer than `limit` code points cut and marked `… [N chars]`. */
94
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
95
+ function cutValues(value: unknown, limit: number): string {
96
+ return JSON.stringify(value, (_key, v: unknown) => {
97
+ if (typeof v !== "string" || v.length <= limit) return v;
98
+ const points = countPoints(v);
99
+ if (points <= limit) return v;
100
+ return `${firstPoints(v, limit)}… [${points} chars]`;
101
+ });
102
+ }
103
+
104
+ /** The top-level keys, as a line for the card, whose names fall after the first `shown` characters of the cut input. */
105
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
106
+ function hiddenKeys(parsed: unknown, limit: number, shown: number): string {
107
+ if (!isPlainObject(parsed)) return "";
108
+ const hidden: string[] = [];
109
+ // Entries are laid out as `{"k":v,"k2":v2}`; an entry starts after the `{` and each comma.
110
+ let offset = 1;
111
+ for (const [key, value] of Object.entries(parsed)) {
112
+ if (offset >= shown) hidden.push(key);
113
+ offset += `${JSON.stringify(key)}:${cutValues(value, limit)},`.length;
114
+ }
115
+ if (hidden.length === 0) return "";
116
+ const names = hidden
117
+ .slice(0, CARD_HIDDEN_KEYS)
118
+ .map((key) => JSON.stringify(firstPoints(key, CARD_KEY_CHARS)));
119
+ if (hidden.length > CARD_HIDDEN_KEYS) names.push("…");
120
+ return `\n${messages().cardHiddenKeys(names)}`;
121
+ }
122
+
123
+ /** How many code points `text` has. */
124
+ function countPoints(text: string): number {
125
+ let points = 0;
126
+ for (let i = 0; i < text.length; i++) {
127
+ const code = text.charCodeAt(i);
128
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) i++;
129
+ points++;
130
+ }
131
+ return points;
132
+ }
133
+
134
+ /** The first `count` code points of `text`. */
135
+ function firstPoints(text: string, count: number): string {
136
+ let end = 0;
137
+ for (let points = 0; points < count && end < text.length; points++) {
138
+ const code = text.charCodeAt(end);
139
+ end += code >= 0xd800 && code <= 0xdbff && end + 1 < text.length ? 2 : 1;
140
+ }
141
+ return text.slice(0, end);
142
+ }
143
+
144
+ /** The first `max` characters of `text`, backing off a cut that would split a surrogate pair. */
145
+ function prefix(text: string, max: number): string {
146
+ if (text.length <= max) return text;
147
+ const code = text.charCodeAt(max - 1);
148
+ return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
149
+ }
150
+
151
+ /**
152
+ * A held call as data for a card that is not text: the action, the tool, the whole input, and
153
+ * each file it sends by path with its size now (absent when unreadable). Unlike the text card,
154
+ * nothing is cut and nothing is worded for a locale.
155
+ */
156
+ export function approvalDetails(
157
+ call: HeldCall,
158
+ workspace?: string,
159
+ ): ApprovalDetails {
160
+ const files = pathFiles(call.input).map((path) => {
161
+ const bytes = fileSize(path, workspace);
162
+ return bytes === undefined ? { path } : { path, bytes };
163
+ });
164
+ return {
165
+ action: call.action,
166
+ tool: call.tool,
167
+ input: parsedInput(call.input),
168
+ ...(files.length > 0 ? { files } : {}),
169
+ };
170
+ }
171
+
172
+ /** A call's input object; `{}` for input that is not one (a held call's always is). */
173
+ function parsedInput(input: string): Record<string, unknown> {
174
+ try {
175
+ const parsed: unknown = JSON.parse(input);
176
+ return isPlainObject(parsed) ? parsed : {};
177
+ } catch {
178
+ return {};
179
+ }
180
+ }
181
+
182
+ /** The paths of a call's `files` entries that name one. */
183
+ function pathFiles(input: string): string[] {
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(input);
187
+ } catch {
188
+ return [];
189
+ }
190
+ const files = isPlainObject(parsed) ? parsed.files : undefined;
191
+ if (!Array.isArray(files)) return [];
192
+ return files.flatMap((file: unknown) =>
193
+ isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
194
+ );
195
+ }
196
+
197
+ /** A file's size in bytes, or undefined when it cannot be read. */
198
+ function fileSize(path: string, workspace: string | undefined) {
199
+ try {
200
+ const info = statSync(resolve(workspace ?? "/", path));
201
+ return info.isFile() ? info.size : undefined;
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
208
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
209
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
210
+ }
@@ -1,20 +1,24 @@
1
- import { statSync } from "node:fs";
2
- import { resolve } from "node:path";
3
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ToolTurn } from "../../define.ts";
4
3
  import type {
5
4
  HeldCall,
6
5
  PendingConfirmation,
7
6
  } from "../../domain/conversation.ts";
8
- import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
7
+ import type { HoldRefusal } from "../../domain/progress.ts";
8
+ import {
9
+ HOLD_DESCRIBE_TIMEOUT_MS,
10
+ type HoldCheck,
11
+ type HoldContext,
12
+ higherTier,
13
+ isPromise,
14
+ } from "../../holds.ts";
9
15
  import { messages } from "../../i18n/index.ts";
10
16
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
11
17
  import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
12
18
  import type { Speaker } from "../../speakers.ts";
13
19
  import type { ToolTiers } from "../../tool-tiers.ts";
14
20
  import type { PromptSlot } from "../prompt-slot.ts";
15
-
16
- /** How much of a held call's input its approval card shows. */
17
- const CARD_INPUT_CHARS = 1_500;
21
+ import { approvalCard, approvalDetails } from "./approval-card.ts";
18
22
 
19
23
  /** Held actions older than this cannot be approved any more. */
20
24
  export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
@@ -30,6 +34,20 @@ export function unexpired(
30
34
  : pending;
31
35
  }
32
36
 
37
+ /** The turn a hold that looks things up sees; see `HoldRule.describeInTurn`. */
38
+ export interface HoldTurn {
39
+ /** Aborts when the turn stops; a call being described then opens no card. */
40
+ signal?: AbortSignal;
41
+ /** The turn a hold receives, with `signal` as the signal that ends its wait. */
42
+ toolTurn(signal: AbortSignal): ToolTurn;
43
+ }
44
+
45
+ /** The answer of a description wait the turn's stop cut short. */
46
+ const STOPPED = Symbol("stopped");
47
+
48
+ /** What waiting for a call's description comes to: its text, or the turn stopped first. */
49
+ type Described = string | undefined | typeof STOPPED;
50
+
33
51
  /** JSON with object keys sorted at every level, so equal inputs compare equal. */
34
52
  // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
35
53
  export function canonicalJson(value: unknown): string {
@@ -82,12 +100,18 @@ export class ConfirmationGate {
82
100
  readonly #tiers: ToolTiers | undefined;
83
101
  /** Where the session's relative file paths resolve, for the sizes a card shows. */
84
102
  readonly #fileRoot: string | undefined;
103
+ readonly #describeTimeoutMs: number;
104
+ /** Settles when the descriptions asked for so far have all been used, in the order they were asked. */
105
+ #describing: Promise<void> = Promise.resolve();
106
+ /** Descriptions asked for and not used yet. */
107
+ #undescribed = 0;
85
108
 
86
109
  /**
87
110
  * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
88
111
  * and its writes outside the workspace and the context's scratch dir are held too.
89
112
  * `fileRoot` is the session's working directory, where a card resolves relative file paths;
90
- * it defaults to the workspace.
113
+ * it defaults to the workspace. A hold that looks things up gets `describeTimeoutMs` to answer
114
+ * (`HOLD_DESCRIBE_TIMEOUT_MS` by default) before its call is held under a generic description.
91
115
  */
92
116
  constructor(
93
117
  holds: HoldCheck,
@@ -96,7 +120,9 @@ export class ConfirmationGate {
96
120
  context: HoldContext = {},
97
121
  tiers?: ToolTiers,
98
122
  fileRoot?: string,
123
+ describeTimeoutMs = HOLD_DESCRIBE_TIMEOUT_MS,
99
124
  ) {
125
+ this.#describeTimeoutMs = describeTimeoutMs;
100
126
  this.#tiers = tiers;
101
127
  this.#fileRoot = fileRoot ?? context.workspace;
102
128
  this.#holds = holds;
@@ -147,44 +173,62 @@ export class ConfirmationGate {
147
173
  tool: string,
148
174
  input: Record<string, unknown>,
149
175
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
176
+ turn?: HoldTurn,
150
177
  ): Promise<string | undefined> {
151
- return (await this.decide(tool, input, ask)).reason;
178
+ return (await this.decide(tool, input, ask, turn)).reason;
152
179
  }
153
180
 
154
181
  /**
155
182
  * `review`, plus whether a card's approval released the call, so its result can say so:
156
- * the card's own message is shown to the person, never to the model.
183
+ * the card's own message is shown to the person, never to the model. When a hold refuses the
184
+ * call, `refused` says how, for the turn's progress to report beside the failed result.
185
+ * With `turn`, a hold that looks things up as the turn's speaker is waited for before any card
186
+ * opens, and a turn that stops meanwhile opens none.
157
187
  */
158
188
  async decide(
159
189
  tool: string,
160
190
  input: Record<string, unknown>,
161
191
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
162
- ): Promise<{ reason?: string; approvedOnCard?: true }> {
163
- const call = this.#needing(tool, input);
192
+ turn?: HoldTurn,
193
+ ): Promise<{
194
+ reason?: string;
195
+ approvedOnCard?: true;
196
+ /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
197
+ refused?: HoldRefusal;
198
+ }> {
199
+ const found = this.#needingIn(tool, input, turn);
200
+ const call = isPromise(found) ? await found : found;
201
+ if (call === STOPPED) return { reason: "The turn was stopped." };
164
202
  if (!call) return {};
165
- if (!ask) return { reason: this.#record(call) };
203
+ if (!ask) return { reason: this.#record(call), refused: "held" };
166
204
  const answer = await ask.prompts.confirm(
167
205
  messages().confirmTitle(ask.asker),
168
206
  approvalCard(call, this.#fileRoot),
169
207
  ask.signal,
170
208
  higherTier(this.#tiers?.minTier(call.tool), call.minTier),
171
209
  { late: (late) => this.#late(call, late) },
210
+ approvalDetails(call, this.#fileRoot),
172
211
  );
173
212
  if (answer === "approved") return { approvedOnCard: true };
174
213
  if (answer === "pending") {
175
214
  const o = ownerWords(this.#addressee);
176
215
  return {
177
216
  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.`,
217
+ refused: "pending",
178
218
  };
179
219
  }
180
220
  if (answer === "declined") {
181
221
  const o = ownerWords(this.#addressee);
182
222
  return {
183
223
  reason: `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`,
224
+ refused: "declined",
184
225
  };
185
226
  }
186
227
  if (answer === "cancelled") return { reason: "The turn was stopped." };
187
- return { reason: this.#record(call) };
228
+ // No card was shown: no one may approve it, or it could not be posted.
229
+ if (answer === "unavailable")
230
+ return { reason: this.#record(call), refused: "held" };
231
+ return { reason: this.#record(call), refused: "expired" };
188
232
  }
189
233
 
190
234
  /**
@@ -201,10 +245,112 @@ export class ConfirmationGate {
201
245
 
202
246
  /** The call when it needs the owner's confirmation and no approval releases it. */
203
247
  #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
204
- const context = this.#context;
205
- const action = this.#holds(tool, input, context);
206
- if (!action) return undefined;
207
- const minTier = this.#holds.approvalTier?.(tool, input, context);
248
+ const action = this.#holds(tool, input, this.#context);
249
+ return action ? this.#unlessApproved(tool, input, action) : undefined;
250
+ }
251
+
252
+ /**
253
+ * `#needing` inside a turn, where a hold may look things up first: the call when it needs the
254
+ * owner's confirmation, or `STOPPED` when the turn stopped while its description was awaited.
255
+ * It answers at once when every hold asked does and no earlier call is still being described.
256
+ */
257
+ #needingIn(
258
+ tool: string,
259
+ input: Record<string, unknown>,
260
+ turn: HoldTurn | undefined,
261
+ ):
262
+ | HeldCall
263
+ | undefined
264
+ | typeof STOPPED
265
+ | Promise<HeldCall | undefined | typeof STOPPED> {
266
+ const inTurn = this.#holds.inTurn;
267
+ if (!turn || !inTurn) return this.#needing(tool, input);
268
+ const controller = new AbortController();
269
+ if (turn.signal?.aborted) controller.abort();
270
+ let answer: string | undefined | Promise<string | undefined>;
271
+ try {
272
+ answer = inTurn.call(
273
+ this.#holds,
274
+ tool,
275
+ input,
276
+ this.#context,
277
+ turn.toolTurn(controller.signal),
278
+ );
279
+ } catch {
280
+ answer = messages().holdGeneric(tool);
281
+ }
282
+ const described: Described | Promise<Described> = isPromise(answer)
283
+ ? this.#awaited(answer, tool, controller, turn.signal)
284
+ : answer;
285
+ return this.#inOrder(described, (action) => {
286
+ if (action === STOPPED) return STOPPED;
287
+ return action ? this.#unlessApproved(tool, input, action) : undefined;
288
+ });
289
+ }
290
+
291
+ /**
292
+ * What a hold that answers later comes to: its text; the generic description when it rejects or
293
+ * outlasts the timeout, so the call is still held; `STOPPED` when the turn stops first. Whatever
294
+ * the hold is still doing is aborted when the wait ends.
295
+ */
296
+ #awaited(
297
+ answer: Promise<string | undefined>,
298
+ tool: string,
299
+ hold: AbortController,
300
+ stop: AbortSignal | undefined,
301
+ ): Promise<Described> {
302
+ return new Promise<Described>((resolve) => {
303
+ const onStop = () => end(STOPPED);
304
+ const timer = setTimeout(
305
+ () => end(messages().holdGeneric(tool)),
306
+ this.#describeTimeoutMs,
307
+ );
308
+ let ended = false;
309
+ const end = (described: Described) => {
310
+ if (ended) return;
311
+ ended = true;
312
+ clearTimeout(timer);
313
+ stop?.removeEventListener("abort", onStop);
314
+ hold.abort();
315
+ resolve(described);
316
+ };
317
+ // Handled first, so a rejection after the wait ended is never left unhandled.
318
+ answer.then(end, () => end(messages().holdGeneric(tool)));
319
+ if (stop?.aborted) return end(STOPPED);
320
+ stop?.addEventListener("abort", onStop, { once: true });
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Hands `described` to `use` after every description asked for before it has been used, so
326
+ * calls made together are held, and their cards opened, in the order they were made whichever
327
+ * description comes back first.
328
+ */
329
+ #inOrder<T, R>(
330
+ described: T | Promise<T>,
331
+ use: (described: T) => R,
332
+ ): R | Promise<R> {
333
+ if (this.#undescribed === 0 && !isPromise(described)) return use(described);
334
+ this.#undescribed++;
335
+ const used = this.#describing.then(() => described).then(use);
336
+ this.#describing = used.then(
337
+ () => {
338
+ this.#undescribed--;
339
+ },
340
+ () => {
341
+ this.#undescribed--;
342
+ },
343
+ );
344
+ return used;
345
+ }
346
+
347
+ /** The call for `action`, unless an approval releases it, which that approval then spends. */
348
+ #unlessApproved(
349
+ tool: string,
350
+ input: Record<string, unknown>,
351
+ action: string,
352
+ ): HeldCall | undefined {
353
+ const minTier = this.#holds.approvalTier?.(tool, input, this.#context);
208
354
  const call: HeldCall = {
209
355
  tool,
210
356
  input: canonicalJson(input),
@@ -261,49 +407,13 @@ export function confirmedTurnText(
261
407
  }
262
408
 
263
409
  /**
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`.
410
+ * Without a slot, or with an empty one, every call that needs confirmation is held. `turnOf` builds
411
+ * the turn a hold that looks things up receives; without it, such a hold is not asked in a turn.
266
412
  */
267
- export function approvalCard(call: HeldCall, workspace?: string): string {
268
- const input =
269
- call.input.length > CARD_INPUT_CHARS
270
- ? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
271
- : call.input;
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
- }
301
- }
302
-
303
- /** Without a slot, or with an empty one, every call that needs confirmation is held. */
304
413
  export function confirmationGateExtension(
305
414
  gate: ConfirmationGate,
306
415
  slot?: PromptSlot,
416
+ turnOf?: (signal: AbortSignal) => ToolTurn,
307
417
  ): ExtensionFactory {
308
418
  return (pi) => {
309
419
  /** Calls a card's approval released, until their results are back. */
@@ -324,12 +434,17 @@ export function confirmationGateExtension(
324
434
  ask = { prompts, asker: slot.asker };
325
435
  if (ctx.signal) ask.signal = ctx.signal;
326
436
  }
327
- const { reason, approvedOnCard } = await gate.decide(
437
+ const turn: HoldTurn | undefined = turnOf
438
+ ? { toolTurn: turnOf, ...(ctx.signal ? { signal: ctx.signal } : {}) }
439
+ : undefined;
440
+ const { reason, approvedOnCard, refused } = await gate.decide(
328
441
  event.toolName,
329
442
  event.input as Record<string, unknown>,
330
443
  ask,
444
+ turn,
331
445
  );
332
446
  if (approvedOnCard) approved.add(event.toolCallId);
447
+ if (refused) slot?.refuse(event.toolCallId, refused);
333
448
  return reason ? { block: true, reason } : undefined;
334
449
  });
335
450
  };
@@ -228,8 +228,11 @@ export class PiAgentRuntime implements AgentRuntime {
228
228
  // Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
229
229
  const interim = interimPoster(request, this.#options);
230
230
  // The caller's live view: the text as it streams and each tool, never the thinking.
231
+ const slot = this.#sessions.slot(key);
231
232
  const progress = request.progress
232
- ? progressReporter(request.progress)
233
+ ? progressReporter(request.progress, {
234
+ refusalOf: (id) => slot.refusalOf(id),
235
+ })
233
236
  : undefined;
234
237
  const unsubscribe = session.subscribe((event) => {
235
238
  progress?.observe(event);
@@ -243,7 +246,6 @@ export class PiAgentRuntime implements AgentRuntime {
243
246
  interim?.messageEnd(event.message);
244
247
  }
245
248
  });
246
- const slot = this.#sessions.slot(key);
247
249
  slot.bind(
248
250
  request.interactive
249
251
  ? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
@@ -1,3 +1,4 @@
1
+ import type { HoldRefusal } from "../domain/progress.ts";
1
2
  import { assistantName } from "../i18n/index.ts";
2
3
  import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
3
4
 
@@ -15,6 +16,7 @@ export class PromptSlot {
15
16
  #open = 0;
16
17
  #openSince = 0;
17
18
  #waited = 0;
19
+ #refused = new Map<string, HoldRefusal>();
18
20
 
19
21
  /**
20
22
  * Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
@@ -30,6 +32,19 @@ export class PromptSlot {
30
32
  this.#asker = asker;
31
33
  this.#open = 0;
32
34
  this.#waited = 0;
35
+ this.#refused.clear();
36
+ }
37
+
38
+ /** Notes that a hold refused a tool call, for the progress of the turn to report once. */
39
+ refuse(toolCallId: string, refusal: HoldRefusal): void {
40
+ this.#refused.set(toolCallId, refusal);
41
+ }
42
+
43
+ /** Why a hold refused a tool call, once; undefined when it did not. */
44
+ refusalOf(toolCallId: string): HoldRefusal | undefined {
45
+ const refusal = this.#refused.get(toolCallId);
46
+ this.#refused.delete(toolCallId);
47
+ return refusal;
33
48
  }
34
49
 
35
50
  unbind(): void {
@@ -46,9 +61,9 @@ export class PromptSlot {
46
61
  const prompts = this.#prompts;
47
62
  if (!prompts) return undefined;
48
63
  return {
49
- confirm: (title, message, signal, minTier, wait) =>
64
+ confirm: (title, message, signal, minTier, wait, details) =>
50
65
  this.#waiting(() =>
51
- prompts.confirm(title, message, signal, minTier, wait),
66
+ prompts.confirm(title, message, signal, minTier, wait, details),
52
67
  ),
53
68
  ask: (title, question: OwnerQuestion, signal, wait) =>
54
69
  this.#waiting(() => prompts.ask(title, question, signal, wait)),