pi-async 0.1.0 → 0.3.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.
@@ -1,41 +1,105 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { TaskRegistry } from "./registry.ts";
3
- import type { TaskStatus } from "./task.ts";
3
+ import type { FailureKind, KillKind, TaskStatus } from "./task.ts";
4
4
 
5
5
  export type PiCtx = ExtensionAPI;
6
- export interface NotifyPayload { taskId: string; name: string; status: TaskStatus; exitCode?: number; outputFile: string; summary?: string; guidance?: string; }
7
- const escapeXml = (value: unknown): string => String(value).replace(/[<&>\"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&apos;" })[char]!);
6
+ export interface NotifyPayload {
7
+ taskId: string; name: string; status: TaskStatus; exitCode?: number;
8
+ outputFile: string; summary?: string; guidance?: string;
9
+ killKind?: KillKind;
10
+ failureKind?: FailureKind;
11
+ summaryTruncated?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Honest notification outcome (PLAN §2.3): `queued` only means the host accepted the message,
16
+ * not that the model read it. There is no ack, so there is no "delivered" state and no retry loop.
17
+ */
18
+ export type NotifyOutcome = "queued" | "skipped" | "failed";
19
+
20
+ /** XML 1.0 Char excludes C0 (except TAB/LF/CR), lone surrogates, and U+FFFE/FFFF (#19). */
21
+ const ILLEGAL_XML = /[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]/g;
22
+
23
+ const escapeXml = (value: unknown): string =>
24
+ String(value)
25
+ .replace(ILLEGAL_XML, "")
26
+ .replace(/[<&>\"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&apos;" })[char]!);
8
27
 
9
28
  export function buildNotificationXml(p: NotifyPayload): string {
10
29
  const fields: Array<[string, unknown]> = [["task-id", p.taskId], ["name", p.name], ["status", p.status]];
11
30
  if (p.exitCode !== undefined) fields.push(["exit-code", p.exitCode]);
31
+ if (p.killKind) fields.push(["kill-kind", p.killKind]);
32
+ if (p.failureKind) fields.push(["failure-kind", p.failureKind]);
12
33
  fields.push(["output-file", p.outputFile]);
13
34
  if (p.summary) fields.push(["summary", p.summary]);
35
+ if (p.summaryTruncated) fields.push(["summary-truncated", "true"]);
14
36
  if (p.status === "failed" && p.guidance) fields.push(["guidance", p.guidance]);
15
37
  return `<async-task-notification>${fields.map(([tag, value]) => `<${tag}>${escapeXml(value)}</${tag}>`).join("")}</async-task-notification>`;
16
38
  }
17
39
 
40
+ /** Structured twin of the XML body, consumed by the TUI message renderer (PLAN §2.3). */
41
+ function buildNotificationDetails(p: NotifyPayload): Record<string, unknown> {
42
+ return {
43
+ taskId: p.taskId,
44
+ name: p.name,
45
+ status: p.status,
46
+ ...(p.exitCode === undefined ? {} : { exitCode: p.exitCode }),
47
+ ...(p.killKind === undefined ? {} : { killKind: p.killKind }),
48
+ ...(p.failureKind === undefined ? {} : { failureKind: p.failureKind }),
49
+ ...(p.summary === undefined ? {} : { summary: p.summary }),
50
+ ...(p.summaryTruncated === undefined ? {} : { summaryTruncated: p.summaryTruncated }),
51
+ outputFile: p.outputFile,
52
+ ...(p.guidance === undefined ? {} : { guidance: p.guidance }),
53
+ };
54
+ }
55
+
56
+ /** `sendMessage` is `void` in older pi hosts and `Promise<void>` in newer ones (both shapes ship). */
57
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
58
+ (typeof value === "object" ? value !== null : typeof value === "function") &&
59
+ typeof (value as PromiseLike<unknown>).then === "function";
60
+
61
+ /**
62
+ * Send a completion notification exactly once per undelivered task.
63
+ *
64
+ * Order (PLAN §2.3 / D-01): skip checks → in-memory claim (concurrency dedupe only) →
65
+ * persist the attempt → send `sendMessage` (awaited only when the host returns a promise) →
66
+ * latch `notified` on success or drop the claim on failure (`notified` stays false so
67
+ * `resendUnnotified` can retry later).
68
+ */
18
69
  export async function sendCompletionNotification(
19
70
  pi: PiCtx,
20
71
  registry: TaskRegistry,
21
72
  p: NotifyPayload,
22
73
  options: { triggerTurn?: boolean } = {},
23
- ): Promise<boolean> {
24
- if (registry.get(p.taskId)?.outputConsumed || !registry.claimNotification(p.taskId)) return false;
74
+ ): Promise<NotifyOutcome> {
75
+ const task = registry.get(p.taskId);
76
+ // skipped: nothing to notify about (unknown/consumed task) or the caller opted out.
77
+ if (!task || task.outputConsumed || task.metadata.notifyOnCompletion === false) return "skipped";
78
+ // The claim is memory-only (D-01): losing it to a concurrent sender means skipped, not failed.
79
+ if (!registry.claimNotification(p.taskId)) return "skipped";
80
+ // Persist the attempt before the send so a failed host call stays visible after reload/restart.
81
+ registry.noteNotifyAttempt(p.taskId);
25
82
  // SAFETY: The pi extension API provides sendMessage in supported hosts; detect it at runtime for other contexts.
83
+ // The result is typed `unknown` on purpose: pi 1.2/1.3 declare `sendMessage(...): void` while 1.5+ declares
84
+ // `Promise<void>`. A `void` return means "handed to the host"; a promise still has to settle before we
85
+ // can honestly call it queued — and awaiting it keeps a rejection from escaping as unhandledRejection.
26
86
  const target = pi as unknown as { sendMessage?: (message: unknown, options: unknown) => unknown };
27
- if (!target.sendMessage) { registry.rollbackNotification(p.taskId); return false; }
28
- for (let attempt = 0; attempt < 3; attempt++) {
29
- try {
30
- await target.sendMessage(
31
- { customType: "async-task-notification", content: buildNotificationXml(p) },
32
- { deliverAs: "followUp", ...(options.triggerTurn === false ? {} : { triggerTurn: true }) },
33
- );
34
- return true;
35
- } catch {
36
- if (attempt < 2) await Promise.resolve();
37
- }
87
+ if (!target.sendMessage) {
88
+ registry.releaseNotification(p.taskId);
89
+ return "failed";
90
+ }
91
+ try {
92
+ // No retry loop and no rollback — the host offers no ack, so retrying only duplicates messages.
93
+ const result = target.sendMessage(
94
+ { customType: "async-task-notification", content: buildNotificationXml(p), details: buildNotificationDetails(p) },
95
+ { deliverAs: "followUp", ...(options.triggerTurn === false ? {} : { triggerTurn: true }) },
96
+ );
97
+ if (isThenable(result)) await result;
98
+ } catch {
99
+ // Sync throw or rejected promise: same honest outcome, `notified` stays false.
100
+ registry.releaseNotification(p.taskId);
101
+ return "failed";
38
102
  }
39
- registry.rollbackNotification(p.taskId);
40
- return false;
103
+ registry.markNotified(p.taskId);
104
+ return "queued";
41
105
  }
@@ -1,6 +1,52 @@
1
- const ANSI_ESCAPE = /\x1B(?:\][\s\S]*?(?:\x07|\x1B\\|\x9C)|\[[0-?]*[ -/]*[@-~]|[PX^_][\s\S]*?\x1B\\|[ -/]*[0-~])|\x9B[0-?]*[ -/]*[@-~]|\x9D[\s\S]*?(?:\x07|\x1B\\|\x9C)/g;
1
+ const ANSI_ESCAPE =
2
+ /\x1B(?:\][\s\S]*?(?:\x07|\x1B\\|\x9C)|\][\s\S]*$|\[[0-?]*[ -/]*[@-~]|\[[0-?]*[ -/]*$|[PX^_][\s\S]*?\x1B\\|[ -/]*[0-~])|\x9B[0-?]*[ -/]*[@-~]|\x9D[\s\S]*?(?:\x07|\x1B\\|\x9C)/g;
2
3
 
3
- /** Remove ANSI escape, CSI, OSC, and string-control sequences from text. */
4
+ /** Keep tab/LF/CR; drop other C0 controls that must not reach model/TUI/XML. */
5
+ const C0_CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
6
+
7
+ /** Remove ANSI escape, CSI, OSC, and string-control sequences from text.
8
+ * Unterminated OSC/CSI are dropped through end-of-string so truncation mid-sequence
9
+ * does not leave the payload as visible garbage (#32). */
4
10
  export function stripAnsi(text: string): string {
5
11
  return text.replace(ANSI_ESCAPE, "");
6
12
  }
13
+
14
+ /**
15
+ * Strip bare C0 control bytes (except TAB/LF/CR which are folded to spaces)
16
+ * and collapse runs of whitespace. Use after stripAnsi for TUI-visible text.
17
+ * Keeps panel and rail sanitization aligned (CONTRACT §9 / issue #3).
18
+ */
19
+ export function stripControl(text: string): string {
20
+ return text
21
+ .replace(C0_CONTROLS, "")
22
+ .replace(/[\r\n\t]+/g, " ");
23
+ }
24
+
25
+ /** ANSI + C0 sanitizer for TUI labels (task names, panel rows). */
26
+ export function visibleText(value: unknown): string {
27
+ return stripControl(stripAnsi(String(value ?? "")));
28
+ }
29
+
30
+ /** Model/logs path: strip ANSI then bare C0, preserve TAB/LF/CR (#18). */
31
+ export function stripLogControls(text: string): string {
32
+ return stripAnsi(text).replace(C0_CONTROLS, "");
33
+ }
34
+
35
+ /**
36
+ * Truncate a UTF-8 buffer to at most `maxBytes`, cutting on a leading-byte boundary
37
+ * so multi-byte characters are never split into U+FFFD (#17).
38
+ * When `tail` is true, keep the end of the buffer (advance past continuation bytes).
39
+ */
40
+ export function truncateUtf8Bytes(bytes: Buffer, maxBytes: number, tail = true): string {
41
+ if (maxBytes <= 0) return "";
42
+ if (bytes.byteLength <= maxBytes) return bytes.toString("utf8");
43
+ if (!tail) {
44
+ let end = maxBytes;
45
+ // Walk back while on a UTF-8 continuation byte so we don't cut mid-sequence.
46
+ while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end--;
47
+ return bytes.subarray(0, end).toString("utf8");
48
+ }
49
+ let start = bytes.byteLength - maxBytes;
50
+ while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start++;
51
+ return bytes.subarray(start).toString("utf8");
52
+ }