@pontia/pi-client-plugin 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pontia/pi-client-plugin",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Pi extension that connects coding-agent sessions to Pontia.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -880,6 +880,13 @@ export function createPontiaPiExtension(
880
880
  }
881
881
  });
882
882
 
883
+ const publishToolExecution = (event: unknown) => {
884
+ if (activeTurn && !activeTurn.ended) activeTurn.liveOutput.toolExecution(event);
885
+ };
886
+ pi.on("tool_execution_start", publishToolExecution);
887
+ pi.on("tool_execution_update", publishToolExecution);
888
+ pi.on("tool_execution_end", publishToolExecution);
889
+
883
890
  pi.on("message_end", async (event, ctx) => {
884
891
  if (!activeTurn || activeTurn.ended) return;
885
892
  await reportContextUsageFromHookEvent(event, ctx);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { TurnContext } from "./context.js";
3
- import { RpcError, type PiConnection } from "./control-socket.js";
3
+ import { MAX_RPC_FRAME_BYTES, RpcError, type PiConnection } from "./control-socket.js";
4
4
  import { asRecord, optionalString } from "./values.js";
5
5
 
6
6
  const DEFAULT_BATCH_DELAY_MS = 75;
@@ -15,6 +15,20 @@ export type ManagedToolUse = {
15
15
  | { type: "bash"; command: string; timeout?: number };
16
16
  };
17
17
 
18
+ export interface LiveToolStatus {
19
+ phase: "running" | "ended";
20
+ outcome?: "succeeded" | "failed" | "rejected" | "interrupted";
21
+ }
22
+ export interface LiveToolOutput {
23
+ text: string;
24
+ truncated: boolean;
25
+ }
26
+ export interface LiveToolResult {
27
+ content?: unknown;
28
+ error?: unknown;
29
+ truncated: boolean;
30
+ }
31
+
18
32
  export type LiveOutputItem =
19
33
  | { kind: "assistant_text"; item_id: string; text: string }
20
34
  | {
@@ -24,9 +38,16 @@ export type LiveOutputItem =
24
38
  tool_name: string;
25
39
  arguments: unknown;
26
40
  managed_tool_use?: ManagedToolUse;
41
+ status?: LiveToolStatus;
42
+ output?: LiveToolOutput;
43
+ result?: LiveToolResult;
27
44
  };
28
45
 
29
46
  export type LiveOutputUpdate =
47
+ | { type: "tool_status_updated"; call_id: string; status: LiveToolStatus }
48
+ | { type: "tool_result"; call_id: string; result: LiveToolResult }
49
+ | { type: "tool_output_delta"; call_id: string; delta: string; truncated: boolean }
50
+ | { type: "tool_output_updated"; call_id: string; text: string; truncated: boolean }
30
51
  | { type: "assistant_text_delta"; item_id: string; delta: string }
31
52
  | {
32
53
  type: "tool_call";
@@ -46,6 +67,7 @@ export interface CompleteToolCall {
46
67
  export interface LiveOutputPublisherLike {
47
68
  appendText(delta: string): void;
48
69
  appendToolCall(toolCall: CompleteToolCall): void;
70
+ toolExecution(event: unknown): void;
49
71
  close(): Promise<void>;
50
72
  }
51
73
 
@@ -93,6 +115,7 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
93
115
  if (this.closing || this.disabled || delta.length === 0) return;
94
116
  let item = this.items.at(-1);
95
117
  if (item?.kind !== "assistant_text") {
118
+ if (this.items.length >= 256) return;
96
119
  item = { kind: "assistant_text", item_id: this.nextItemId("text"), text: "" };
97
120
  this.items.push(item);
98
121
  }
@@ -107,7 +130,10 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
107
130
  this.items.some((item) => item.kind === "tool_call" && item.call_id === toolCall.callId)
108
131
  )
109
132
  return;
133
+ if (this.items.length >= 256) return;
110
134
  const managedToolUse = managedToolUseFor(toolCall);
135
+ const inputBytes = jsonBytes([toolCall.arguments, managedToolUse ?? null]);
136
+ if (inputBytes > 64 * 1024 || this.toolBytes() + inputBytes > 1024 * 1024) return;
111
137
  const item: LiveOutputItem = {
112
138
  kind: "tool_call",
113
139
  item_id: this.nextItemId("tool"),
@@ -127,6 +153,100 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
127
153
  });
128
154
  }
129
155
 
156
+ toolExecution(event: unknown): void {
157
+ if (this.closing || this.disabled) return;
158
+ const native = asRecord(event);
159
+ const callId = optionalString(native?.toolCallId);
160
+ if (!native || !callId) return;
161
+ if (
162
+ (native.type === "tool_execution_start" || native.type === "tool_execution_update") &&
163
+ "args" in native
164
+ ) {
165
+ const toolName = optionalString(native.toolName);
166
+ if (toolName) this.appendToolCall({ callId, toolName, arguments: native.args });
167
+ }
168
+ const item = this.items.find(
169
+ (item): item is Extract<LiveOutputItem, { kind: "tool_call" }> =>
170
+ item.kind === "tool_call" && item.call_id === callId,
171
+ );
172
+ if (!item) return;
173
+ if (native.type === "tool_execution_start") {
174
+ if (item.status) return;
175
+ item.status = { phase: "running" };
176
+ this.enqueue({ type: "tool_status_updated", call_id: callId, status: item.status });
177
+ } else if (native.type === "tool_execution_update") {
178
+ if (item.status?.phase === "ended" || !("partialResult" in native)) return;
179
+ const partial = asRecord(native.partialResult);
180
+ const content = partial?.content;
181
+ const text = Array.isArray(content)
182
+ ? content
183
+ .map((block) =>
184
+ typeof asRecord(block)?.text === "string"
185
+ ? asRecord(block)!.text
186
+ : JSON.stringify(block),
187
+ )
188
+ .join("\n")
189
+ : typeof native.partialResult === "string"
190
+ ? native.partialResult
191
+ : JSON.stringify(native.partialResult);
192
+ if (text === undefined) return;
193
+ const budget = Math.max(
194
+ 0,
195
+ 1024 * 1024 - this.toolBytes() + escapedTextBytes(item.output?.text ?? ""),
196
+ );
197
+ const retained = truncateText(text, budget);
198
+ const truncated = retained !== text || nativeTruncated(native.partialResult);
199
+ if (item.output?.text === retained && item.output.truncated === truncated) return;
200
+ item.output = { text: retained, truncated };
201
+ this.enqueue({ type: "tool_output_updated", call_id: callId, ...item.output });
202
+ } else if (native.type === "tool_execution_end") {
203
+ if (native.result !== undefined) {
204
+ const truncated = nativeTruncated(native.result);
205
+ const result: LiveToolResult =
206
+ native.isError === true
207
+ ? { error: native.result, truncated }
208
+ : { content: native.result, truncated };
209
+ const budget = Math.max(0, 1024 * 1024 - this.toolBytes() + resultBytes(item.result));
210
+ if (resultBytes(result) > budget) {
211
+ const key = native.isError === true ? "error" : "content";
212
+ const value = result[key];
213
+ result[key] = truncateText(
214
+ typeof value === "string" ? value : JSON.stringify(value),
215
+ budget,
216
+ );
217
+ result.truncated = true;
218
+ }
219
+ if (JSON.stringify(item.result) !== JSON.stringify(result)) {
220
+ item.result = result;
221
+ this.enqueue({ type: "tool_result", call_id: callId, result });
222
+ }
223
+ }
224
+ const status: LiveToolStatus = {
225
+ phase: "ended",
226
+ ...(typeof native.isError === "boolean"
227
+ ? { outcome: native.isError ? ("failed" as const) : ("succeeded" as const) }
228
+ : {}),
229
+ };
230
+ if (!item.status?.outcome && JSON.stringify(item.status) !== JSON.stringify(status)) {
231
+ item.status = status;
232
+ this.enqueue({ type: "tool_status_updated", call_id: callId, status });
233
+ }
234
+ }
235
+ }
236
+
237
+ private toolBytes(): number {
238
+ return this.items.reduce(
239
+ (size, item) =>
240
+ item.kind === "tool_call"
241
+ ? size +
242
+ jsonBytes([item.arguments, item.managed_tool_use ?? null]) +
243
+ escapedTextBytes(item.output?.text ?? "") +
244
+ resultBytes(item.result)
245
+ : size,
246
+ 0,
247
+ );
248
+ }
249
+
130
250
  async close(): Promise<void> {
131
251
  if (this.closing || this.disabled) return;
132
252
  this.closing = true;
@@ -146,7 +266,13 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
146
266
 
147
267
  private enqueue(update: LiveOutputUpdate): void {
148
268
  this.sequence += 1;
149
- this.pending.push({ sequence: this.sequence, update });
269
+ this.pending.push({ sequence: this.sequence, update: structuredClone(update) });
270
+ // Bound retries during arbitrarily long disconnects. The snapshot covers
271
+ // everything omitted from the delta queue.
272
+ if (this.pending.length > 256 || jsonBytes(this.pending) > 1024 * 1024) {
273
+ this.pending.length = 0;
274
+ this.needsSnapshot = true;
275
+ }
150
276
  this.scheduleFlush(this.batchDelayMs);
151
277
  }
152
278
 
@@ -193,6 +319,7 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
193
319
  private async flushCurrent(): Promise<boolean> {
194
320
  if (this.sequence === 0) return true;
195
321
  const targetSequence = this.sequence;
322
+ this.boundSnapshotToFrame();
196
323
  const response = this.needsSnapshot
197
324
  ? await this.publish({
198
325
  ...this.baseRequest(),
@@ -211,11 +338,60 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
211
338
  (entry) => entry.sequence > response.accepted_sequence,
212
339
  );
213
340
  this.pending.splice(0, firstUnaccepted === -1 ? this.pending.length : firstUnaccepted);
214
- this.needsSnapshot = response.resync_required;
215
- if (this.pending.length > 0 && !this.closing) this.scheduleFlush(this.batchDelayMs);
341
+ this.needsSnapshot =
342
+ response.resync_required ||
343
+ (this.sequence > response.accepted_sequence &&
344
+ this.pending[0]?.sequence !== response.accepted_sequence + 1);
345
+ if ((this.pending.length > 0 || this.needsSnapshot) && !this.closing)
346
+ this.scheduleFlush(this.batchDelayMs);
216
347
  return !this.needsSnapshot;
217
348
  }
218
349
 
350
+ private boundSnapshotToFrame(): void {
351
+ const excess = () =>
352
+ jsonBytes({
353
+ jsonrpc: "2.0",
354
+ id: `pi:${Number.MAX_SAFE_INTEGER}`,
355
+ method: "liveOutput.publish",
356
+ params: {
357
+ ...this.baseRequest(),
358
+ type: "snapshot",
359
+ sequence: this.sequence,
360
+ items: this.items,
361
+ },
362
+ }) - MAX_RPC_FRAME_BYTES;
363
+ if (excess() <= 0) return;
364
+ let changed = false;
365
+ for (const item of this.items) {
366
+ if (item.kind !== "tool_call") continue;
367
+ if (item.output && excess() > 0 && item.output.text) {
368
+ item.output.text = truncateText(
369
+ item.output.text,
370
+ Math.max(0, escapedTextBytes(item.output.text) - excess()),
371
+ );
372
+ item.output.truncated = true;
373
+ changed = true;
374
+ }
375
+ if (item.result) {
376
+ for (const key of ["content", "error"] as const) {
377
+ if (!(key in item.result) || excess() <= 0) continue;
378
+ const value = item.result[key];
379
+ const budget = Math.max(0, jsonBytes(value) - excess() - 2);
380
+ item.result[key] = truncateText(
381
+ typeof value === "string" ? value : JSON.stringify(value),
382
+ budget,
383
+ );
384
+ item.result.truncated = true;
385
+ changed = true;
386
+ }
387
+ }
388
+ }
389
+ if (changed) {
390
+ this.pending.length = 0;
391
+ this.needsSnapshot = true;
392
+ }
393
+ }
394
+
219
395
  private async publishAppend(targetSequence: number): Promise<LiveOutputResponse | undefined> {
220
396
  const updates = this.pending.filter((entry) => entry.sequence <= targetSequence);
221
397
  if (updates.length === 0)
@@ -270,6 +446,46 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
270
446
  }
271
447
  }
272
448
 
449
+ function nativeTruncated(value: unknown): boolean {
450
+ const result = asRecord(value);
451
+ const details = asRecord(result?.details);
452
+ return (
453
+ result?.truncated === true ||
454
+ details?.truncated === true ||
455
+ asRecord(details?.truncation)?.truncated === true ||
456
+ asRecord(result?.structuredContent)?.truncated === true
457
+ );
458
+ }
459
+
460
+ function byteLength(text: string): number {
461
+ return Buffer.byteLength(text, "utf8");
462
+ }
463
+ function jsonBytes(value: unknown): number {
464
+ return byteLength(JSON.stringify(value) ?? "");
465
+ }
466
+ function resultBytes(result?: LiveToolResult): number {
467
+ if (!result) return 0;
468
+ return ["content", "error"].reduce((size, key) => {
469
+ if (!(key in result)) return size;
470
+ const value = result[key as "content" | "error"];
471
+ return size + (typeof value === "string" ? escapedTextBytes(value) : jsonBytes(value));
472
+ }, 0);
473
+ }
474
+ function escapedTextBytes(text: string): number {
475
+ return jsonBytes(text) - 2;
476
+ }
477
+ function truncateText(text: string, budget: number): string {
478
+ if (escapedTextBytes(text) <= budget) return text;
479
+ let end = 0;
480
+ let bytes = 0;
481
+ for (const char of text) {
482
+ bytes += escapedTextBytes(char);
483
+ if (bytes > budget) break;
484
+ end += char.length;
485
+ }
486
+ return text.slice(0, end);
487
+ }
488
+
273
489
  function managedToolUseFor(toolCall: CompleteToolCall): ManagedToolUse | undefined {
274
490
  const input = asRecord(toolCall.arguments);
275
491
  if (!input) return undefined;