@osolmaz/pi-workflows 0.9.1 → 0.11.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 (147) hide show
  1. package/README.md +64 -19
  2. package/dist/builtins/autodoc.workflow.d.ts +58 -0
  3. package/dist/builtins/autodoc.workflow.js +266 -0
  4. package/dist/builtins/autodoc.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +353 -0
  6. package/dist/builtins/autoimplement.workflow.js +961 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/autoplan.workflow.d.ts +58 -0
  9. package/dist/builtins/autoplan.workflow.js +190 -0
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +9 -1
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -0
  14. package/dist/builtins/index.js +7 -0
  15. package/dist/builtins/index.js.map +1 -0
  16. package/dist/builtins/monitor.workflow.d.ts +29 -3
  17. package/dist/builtins/monitor.workflow.js +325 -13
  18. package/dist/builtins/monitor.workflow.js.map +1 -1
  19. package/dist/builtins/plan-approval.workflow.d.ts +83 -0
  20. package/dist/builtins/plan-approval.workflow.js +148 -0
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -0
  22. package/dist/builtins/plan-presentation.d.ts +7 -0
  23. package/dist/builtins/plan-presentation.js +44 -0
  24. package/dist/builtins/plan-presentation.js.map +1 -0
  25. package/dist/extension/decision-channels.d.ts +132 -0
  26. package/dist/extension/decision-channels.js +1082 -0
  27. package/dist/extension/decision-channels.js.map +1 -0
  28. package/dist/extension/index.d.ts +1 -0
  29. package/dist/extension/index.js +426 -11
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/widget.js +26 -2
  32. package/dist/extension/widget.js.map +1 -1
  33. package/dist/extension/workflow-tool.d.ts +1 -37
  34. package/dist/extension/workflow-tool.js +1 -42
  35. package/dist/extension/workflow-tool.js.map +1 -1
  36. package/dist/host/rpc-bridge.js +4 -21
  37. package/dist/host/rpc-bridge.js.map +1 -1
  38. package/dist/render/graph-render.js +46 -10
  39. package/dist/render/graph-render.js.map +1 -1
  40. package/dist/viewer/render.js +36 -2
  41. package/dist/viewer/render.js.map +1 -1
  42. package/dist/workflows/catalog.d.ts +1 -0
  43. package/dist/workflows/catalog.js +6 -0
  44. package/dist/workflows/catalog.js.map +1 -1
  45. package/dist/workflows/composition.d.ts +45 -0
  46. package/dist/workflows/composition.js +485 -0
  47. package/dist/workflows/composition.js.map +1 -0
  48. package/dist/workflows/decision-presentation.d.ts +18 -0
  49. package/dist/workflows/decision-presentation.js +417 -0
  50. package/dist/workflows/decision-presentation.js.map +1 -0
  51. package/dist/workflows/decision.d.ts +11 -5
  52. package/dist/workflows/decision.js.map +1 -1
  53. package/dist/workflows/definition.d.ts +22 -3
  54. package/dist/workflows/definition.js +46 -3
  55. package/dist/workflows/definition.js.map +1 -1
  56. package/dist/workflows/engine.d.ts +2 -1
  57. package/dist/workflows/engine.js +199 -18
  58. package/dist/workflows/engine.js.map +1 -1
  59. package/dist/workflows/graph.js +8 -6
  60. package/dist/workflows/graph.js.map +1 -1
  61. package/dist/workflows/human-decision.d.ts +87 -0
  62. package/dist/workflows/human-decision.js +583 -0
  63. package/dist/workflows/human-decision.js.map +1 -0
  64. package/dist/workflows/index.d.ts +5 -2
  65. package/dist/workflows/index.js +4 -1
  66. package/dist/workflows/index.js.map +1 -1
  67. package/dist/workflows/loader.d.ts +5 -4
  68. package/dist/workflows/loader.js +118 -18
  69. package/dist/workflows/loader.js.map +1 -1
  70. package/dist/workflows/schema.d.ts +3 -1
  71. package/dist/workflows/schema.js +64 -2
  72. package/dist/workflows/schema.js.map +1 -1
  73. package/dist/workflows/store.js +39 -2
  74. package/dist/workflows/store.js.map +1 -1
  75. package/dist/workflows/tool-input.d.ts +72 -0
  76. package/dist/workflows/tool-input.js +141 -0
  77. package/dist/workflows/tool-input.js.map +1 -0
  78. package/dist/workflows/types.d.ts +300 -2
  79. package/docs/CONTROLLERS.md +1 -1
  80. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  81. package/docs/HUMAN_DECISIONS.md +371 -0
  82. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  83. package/docs/MONITOR.md +46 -18
  84. package/docs/WORKFLOW_COMPOSITION.md +333 -0
  85. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  86. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  87. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  88. package/docs/plans/2026-08-19-workflow-composition-plan.md +308 -0
  89. package/docs/run-bundles.md +45 -10
  90. package/docs/workflows.md +106 -15
  91. package/examples/workflows/approved-plan.workflow.ts +58 -0
  92. package/examples/workflows/autoimplement.workflow.ts +1 -92
  93. package/examples/workflows/autoplan.workflow.ts +1 -0
  94. package/examples/workflows/human-decision.workflow.ts +62 -0
  95. package/herdr-plugin.toml +1 -1
  96. package/package.json +6 -1
  97. package/schemas/decision-presentation-v1.schema.json +83 -0
  98. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  99. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  100. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  101. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  102. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  103. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  104. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  105. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  106. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  107. package/schemas/human-decision-request-v1.schema.json +59 -0
  108. package/schemas/human-decision-request-v2.schema.json +69 -0
  109. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  110. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  111. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  112. package/skills/autodoc/SKILL.md +43 -0
  113. package/skills/autoimplement/SKILL.md +58 -0
  114. package/skills/autoimplement/agents/openai.yaml +4 -0
  115. package/skills/autoplan/SKILL.md +25 -0
  116. package/skills/monitor/SKILL.md +8 -1
  117. package/skills/pi-workflows/SKILL.md +5 -3
  118. package/src/builtins/autodoc.workflow.ts +325 -0
  119. package/src/builtins/autoimplement.workflow.ts +1123 -0
  120. package/src/builtins/autoplan.workflow.ts +231 -0
  121. package/src/builtins/catalog.ts +9 -1
  122. package/src/builtins/index.ts +32 -0
  123. package/src/builtins/monitor.workflow.ts +396 -15
  124. package/src/builtins/plan-approval.workflow.ts +190 -0
  125. package/src/builtins/plan-presentation.ts +57 -0
  126. package/src/extension/decision-channels.ts +1533 -0
  127. package/src/extension/index.ts +499 -9
  128. package/src/extension/widget.ts +39 -2
  129. package/src/extension/workflow-tool.ts +5 -81
  130. package/src/host/rpc-bridge.ts +7 -30
  131. package/src/render/graph-render.ts +54 -5
  132. package/src/viewer/render.ts +56 -3
  133. package/src/workflows/catalog.ts +7 -0
  134. package/src/workflows/composition.ts +642 -0
  135. package/src/workflows/decision-presentation.ts +501 -0
  136. package/src/workflows/decision.ts +12 -5
  137. package/src/workflows/definition.ts +118 -8
  138. package/src/workflows/engine.ts +257 -20
  139. package/src/workflows/graph.ts +8 -6
  140. package/src/workflows/human-decision.ts +819 -0
  141. package/src/workflows/index.ts +89 -0
  142. package/src/workflows/loader.ts +186 -18
  143. package/src/workflows/schema.ts +79 -2
  144. package/src/workflows/store.ts +45 -2
  145. package/src/workflows/tool-input.ts +202 -0
  146. package/src/workflows/types.ts +374 -2
  147. package/examples/workflows/elegant-solution.workflow.ts +0 -95
@@ -0,0 +1,1533 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
7
+ import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+ import Database from "better-sqlite3";
9
+ import {
10
+ MAX_PRESENTATION_TRANSPORT_PARTS,
11
+ decisionDocumentSegments,
12
+ decisionPresentationFingerprint,
13
+ digestCanonical,
14
+ } from "../workflows/decision-presentation.js";
15
+ import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
16
+ import type {
17
+ AcceptedHumanDecision,
18
+ HumanDecisionAnswerSource,
19
+ HumanDecisionCancellationRecord,
20
+ HumanDecisionChannelRequest,
21
+ HumanDecisionDeliveryRecord,
22
+ HumanDecisionResponse,
23
+ HumanDecisionSettlementRecord,
24
+ } from "../workflows/types.js";
25
+
26
+ const TELEGRAM_TEXT_LIMIT = 4_096;
27
+ const PI_PRESENTATION_WINDOW_LINES = 18;
28
+ const DEFAULT_API_BASE = "https://api.telegram.org";
29
+ const LEASE_TTL_MS = 60_000;
30
+ const LEASE_RETRY_MS = 5_000;
31
+ const POLL_BACKOFF_MS = 1_000;
32
+ const MAX_SETTLEMENT_ATTEMPTS = 3;
33
+ const NUMERIC_ID = /^-?[0-9]+$/;
34
+ const SIMPLE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
35
+
36
+ export type HumanDecisionChannelAnswer = {
37
+ request: HumanDecisionChannelRequest;
38
+ response: HumanDecisionResponse;
39
+ source: HumanDecisionAnswerSource;
40
+ idempotencyKey: string;
41
+ };
42
+
43
+ export type HumanDecisionDeliveryResult = {
44
+ status: "confirmed" | "failed" | "unknown";
45
+ channel: string;
46
+ attemptId: string;
47
+ errorCode?: string;
48
+ };
49
+
50
+ export type SettledHumanDecision = AcceptedHumanDecision | HumanDecisionCancellationRecord;
51
+
52
+ export interface HumanDecisionChannel {
53
+ readonly id: string;
54
+ start(): Promise<void>;
55
+ deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult>;
56
+ settle(decision: SettledHumanDecision): Promise<void>;
57
+ stop(): Promise<void>;
58
+ }
59
+
60
+ export type DecisionChannelConfig = {
61
+ schema: "pi-workflows.channels.v1";
62
+ audiences: Record<string, { channels: string[]; accept: "first-valid-answer" }>;
63
+ telegramProfiles?: Record<
64
+ string,
65
+ {
66
+ credential: string;
67
+ allowedUserIds: string[];
68
+ allowedChatIds: string[];
69
+ }
70
+ >;
71
+ };
72
+
73
+ export type DecisionCredentialConfig = {
74
+ schema: "pi-workflows.credentials.v1";
75
+ telegram: Record<string, { tokenFile: string }>;
76
+ };
77
+
78
+ type ResolvedDecisionCredentials = Record<string, string>;
79
+
80
+ export type LoadedDecisionChannelConfig = {
81
+ channels: DecisionChannelConfig;
82
+ credentials: ResolvedDecisionCredentials;
83
+ configDir: string;
84
+ };
85
+
86
+ export type TelegramFetch = (
87
+ input: string,
88
+ init?: RequestInit,
89
+ ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
90
+
91
+ export type PiDecisionUi = Pick<ExtensionContext["ui"], "custom" | "input">;
92
+
93
+ export function decisionConfigDir(env: NodeJS.ProcessEnv = process.env): string {
94
+ return env.PI_WORKFLOWS_CONFIG_DIR ?? path.join(os.homedir(), ".config", "pi-workflows");
95
+ }
96
+
97
+ export async function loadDecisionChannelConfig(
98
+ configDir = decisionConfigDir(),
99
+ ): Promise<LoadedDecisionChannelConfig | null> {
100
+ const channelPath = path.join(configDir, "channels.json");
101
+ let channelsRaw: unknown;
102
+ try {
103
+ channelsRaw = JSON.parse(await fsp.readFile(channelPath, "utf8"));
104
+ } catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
106
+ throw error;
107
+ }
108
+ await requirePrivateFile(channelPath, "Pi Workflows channel profile");
109
+ const channels = parseChannelConfig(channelsRaw);
110
+ const profileNames = Object.keys(channels.telegramProfiles ?? {});
111
+ if (profileNames.length === 0) return { channels, credentials: {}, configDir };
112
+
113
+ const credentialPath = path.join(configDir, "credentials.json");
114
+ await requirePrivateFile(credentialPath, "Pi Workflows credential profile");
115
+ const credentialConfig = parseCredentialConfig(
116
+ JSON.parse(await fsp.readFile(credentialPath, "utf8")),
117
+ );
118
+ const credentials: ResolvedDecisionCredentials = {};
119
+ for (const [name, credential] of Object.entries(credentialConfig.telegram)) {
120
+ if (!path.isAbsolute(credential.tokenFile)) {
121
+ throw new Error(`Telegram credential ${name} tokenFile must be absolute`);
122
+ }
123
+ await requirePrivateFile(credential.tokenFile, `Telegram credential ${name} token file`);
124
+ const token = (await fsp.readFile(credential.tokenFile, "utf8")).trim();
125
+ if (token.length === 0) throw new Error(`Telegram credential ${name} token file is empty`);
126
+ credentials[name] = token;
127
+ }
128
+ return { channels, credentials, configDir };
129
+ }
130
+
131
+ export function audienceChannels(config: DecisionChannelConfig | null, audience: string): string[] {
132
+ const configured = config?.audiences[audience]?.channels;
133
+ return configured === undefined ? ["pi"] : [...configured];
134
+ }
135
+
136
+ export class PiDecisionChannel implements HumanDecisionChannel {
137
+ readonly id = "pi";
138
+ private promptAbort: AbortController | null = null;
139
+ private settlementTask: Promise<void> | null = null;
140
+
141
+ constructor(
142
+ private readonly options: {
143
+ actorId: string;
144
+ ui: PiDecisionUi;
145
+ store: HumanDecisionStore;
146
+ onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
147
+ },
148
+ ) {}
149
+
150
+ async start(): Promise<void> {}
151
+
152
+ async stop(): Promise<void> {
153
+ this.promptAbort?.abort();
154
+ }
155
+
156
+ async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
157
+ const attemptId = createHumanDecisionAttemptId();
158
+ const createdAt = new Date().toISOString();
159
+ const controller = new AbortController();
160
+ this.promptAbort = controller;
161
+ try {
162
+ await this.options.store.recordDelivery(
163
+ request,
164
+ this.id,
165
+ v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
166
+ );
167
+ const entries = Object.entries(request.choices);
168
+ const selectedChoice = await this.options.ui.custom<string | undefined>(
169
+ (tui, theme, _keybindings, done) => {
170
+ let choiceIndex = 0;
171
+ let scroll = 0;
172
+ let settled = false;
173
+ const finish = (value: string | undefined) => {
174
+ if (settled) return;
175
+ settled = true;
176
+ done(value);
177
+ };
178
+ const abort = () => finish(undefined);
179
+ controller.signal.addEventListener("abort", abort, { once: true });
180
+ return {
181
+ render(width: number): string[] {
182
+ const content = renderPiPresentationLines(request, Math.max(20, width), theme);
183
+ const maxScroll = Math.max(0, content.length - PI_PRESENTATION_WINDOW_LINES);
184
+ scroll = Math.min(scroll, maxScroll);
185
+ const visible = content.slice(scroll, scroll + PI_PRESENTATION_WINDOW_LINES);
186
+ const lines = [...visible];
187
+ if (content.length > PI_PRESENTATION_WINDOW_LINES) {
188
+ lines.push(
189
+ theme.fg(
190
+ "dim",
191
+ `Decision text ${scroll + 1}-${Math.min(content.length, scroll + PI_PRESENTATION_WINDOW_LINES)}/${content.length} · PgUp/PgDn scroll`,
192
+ ),
193
+ );
194
+ }
195
+ lines.push("");
196
+ for (const [index, [, definition]] of entries.entries()) {
197
+ const marker = index === choiceIndex ? "›" : " ";
198
+ lines.push(
199
+ ...wrapTextWithAnsi(
200
+ `${theme.fg(index === choiceIndex ? "accent" : "text", `${marker} ${definition.label}`)}`,
201
+ Math.max(1, width),
202
+ ),
203
+ );
204
+ if (definition.input !== undefined) {
205
+ lines.push(
206
+ ...wrapTextWithAnsi(
207
+ theme.fg("dim", ` ${definition.input.prompt}`),
208
+ Math.max(1, width),
209
+ ),
210
+ );
211
+ }
212
+ }
213
+ lines.push("");
214
+ lines.push(theme.fg("dim", "↑/↓ choose · Enter confirm · Esc cancel"));
215
+ return lines;
216
+ },
217
+ invalidate() {},
218
+ handleInput(data: string): void {
219
+ if (matchesKey(data, Key.escape)) finish(undefined);
220
+ else if (matchesKey(data, Key.enter)) finish(entries[choiceIndex]?.[0]);
221
+ else if (matchesKey(data, Key.up)) choiceIndex = Math.max(0, choiceIndex - 1);
222
+ else if (matchesKey(data, Key.down)) {
223
+ choiceIndex = Math.min(entries.length - 1, choiceIndex + 1);
224
+ } else if (matchesKey(data, Key.pageUp)) {
225
+ scroll = Math.max(0, scroll - PI_PRESENTATION_WINDOW_LINES);
226
+ } else if (matchesKey(data, Key.pageDown)) {
227
+ scroll += PI_PRESENTATION_WINDOW_LINES;
228
+ } else return;
229
+ tui.requestRender();
230
+ },
231
+ dispose(): void {
232
+ controller.signal.removeEventListener("abort", abort);
233
+ },
234
+ };
235
+ },
236
+ );
237
+ if (selectedChoice === undefined) {
238
+ const errorCode = controller.signal.aborted
239
+ ? "pi_selection_settled_elsewhere"
240
+ : "pi_selection_cancelled";
241
+ await this.options.store.recordDelivery(
242
+ request,
243
+ this.id,
244
+ v2DeliveryRecord(
245
+ request,
246
+ this.id,
247
+ `${attemptId}-cancelled`,
248
+ "complete",
249
+ "failed",
250
+ createdAt,
251
+ { errorCode },
252
+ ),
253
+ );
254
+ return { status: "failed", channel: this.id, attemptId, errorCode };
255
+ }
256
+ const definition = request.choices[selectedChoice];
257
+ if (definition === undefined) throw new Error("Pi decision selection is not in the request");
258
+ let response: HumanDecisionResponse = { choice: selectedChoice };
259
+ if (definition.input !== undefined) {
260
+ const text = await this.options.ui.input(definition.input.prompt, "", {
261
+ signal: controller.signal,
262
+ });
263
+ if (text === undefined) {
264
+ const errorCode = controller.signal.aborted
265
+ ? "pi_input_settled_elsewhere"
266
+ : "pi_input_cancelled";
267
+ await this.options.store.recordDelivery(
268
+ request,
269
+ this.id,
270
+ v2DeliveryRecord(
271
+ request,
272
+ this.id,
273
+ `${attemptId}-cancelled`,
274
+ "complete",
275
+ "failed",
276
+ createdAt,
277
+ { errorCode },
278
+ ),
279
+ );
280
+ return { status: "failed", channel: this.id, attemptId, errorCode };
281
+ }
282
+ response = { choice: selectedChoice, input: { [definition.input.name]: text } };
283
+ }
284
+ const eventId = createHumanDecisionAttemptId();
285
+ await this.options.onAnswer({
286
+ request,
287
+ response,
288
+ source: { channel: this.id, actorId: this.options.actorId, eventId },
289
+ idempotencyKey: `${this.id}:${eventId}`,
290
+ });
291
+ await this.options.store.recordDelivery(
292
+ request,
293
+ this.id,
294
+ v2DeliveryRecord(
295
+ request,
296
+ this.id,
297
+ `${attemptId}-confirmed`,
298
+ "complete",
299
+ "confirmed",
300
+ createdAt,
301
+ { messageCount: 1 },
302
+ ),
303
+ );
304
+ return { status: "confirmed", channel: this.id, attemptId };
305
+ } finally {
306
+ if (this.promptAbort === controller) this.promptAbort = null;
307
+ }
308
+ }
309
+
310
+ async settle(decision: SettledHumanDecision): Promise<void> {
311
+ this.promptAbort?.abort();
312
+ if (this.settlementTask !== null) return await this.settlementTask;
313
+ const task = this.settleOnce(decision);
314
+ this.settlementTask = task;
315
+ try {
316
+ await task;
317
+ } finally {
318
+ if (this.settlementTask === task) this.settlementTask = null;
319
+ }
320
+ }
321
+
322
+ private async settleOnce(decision: SettledHumanDecision): Promise<void> {
323
+ const prior = await this.options.store.listSettlements(decision.decisionId, this.id);
324
+ if (prior.some((record) => record.state === "confirmed")) return;
325
+ const createdAt = new Date().toISOString();
326
+ const record: HumanDecisionSettlementRecord = {
327
+ schema: "pi-workflows.human-decision-settlement.v1",
328
+ attemptId: createHumanDecisionAttemptId(),
329
+ decisionId: decision.decisionId,
330
+ requestDigest: decision.requestDigest,
331
+ channel: this.id,
332
+ state: "confirmed",
333
+ createdAt,
334
+ finishedAt: createdAt,
335
+ };
336
+ await this.options.store.recordSettlement(decision.decisionId, this.id, record);
337
+ }
338
+ }
339
+
340
+ type TelegramProfile = {
341
+ name: string;
342
+ token: string;
343
+ allowedUserIds: Set<string>;
344
+ allowedChatIds: Set<string>;
345
+ };
346
+
347
+ type CallbackBinding = {
348
+ request: HumanDecisionChannelRequest;
349
+ choice: string;
350
+ };
351
+
352
+ type ReplyBinding = CallbackBinding & {
353
+ chatId: string;
354
+ promptMessageId: string;
355
+ };
356
+
357
+ type CallbackRow = { request_json: string; choice_id: string };
358
+ type ReplyRow = { request_json: string; choice_id: string; chat_id: string; message_id: string };
359
+ type MessageRow = { chat_id: string; message_id: string };
360
+ type MessagePartRow = {
361
+ recipient_index: number;
362
+ part_index: number;
363
+ content_digest: string;
364
+ chat_id: string;
365
+ message_id: string;
366
+ };
367
+ type OffsetRow = { next_offset: number };
368
+
369
+ class TelegramProjection {
370
+ private readonly db: Database.Database;
371
+
372
+ constructor(
373
+ filePath: string,
374
+ private readonly profile: string,
375
+ private readonly owner: string,
376
+ ) {
377
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
378
+ this.db = new Database(filePath);
379
+ fs.chmodSync(filePath, 0o600);
380
+ this.db.pragma("journal_mode = WAL");
381
+ this.db.pragma("busy_timeout = 5000");
382
+ this.db.exec(`
383
+ CREATE TABLE IF NOT EXISTS leases (
384
+ profile TEXT PRIMARY KEY,
385
+ owner TEXT NOT NULL,
386
+ expires_at INTEGER NOT NULL
387
+ );
388
+ CREATE TABLE IF NOT EXISTS offsets (
389
+ profile TEXT PRIMARY KEY,
390
+ next_offset INTEGER NOT NULL
391
+ );
392
+ CREATE TABLE IF NOT EXISTS callbacks (
393
+ profile TEXT NOT NULL,
394
+ token TEXT NOT NULL,
395
+ request_json TEXT NOT NULL,
396
+ choice_id TEXT NOT NULL,
397
+ PRIMARY KEY (profile, token)
398
+ );
399
+ CREATE TABLE IF NOT EXISTS replies (
400
+ profile TEXT NOT NULL,
401
+ chat_id TEXT NOT NULL,
402
+ message_id TEXT NOT NULL,
403
+ request_json TEXT NOT NULL,
404
+ choice_id TEXT NOT NULL,
405
+ PRIMARY KEY (profile, chat_id, message_id)
406
+ );
407
+ CREATE TABLE IF NOT EXISTS messages (
408
+ profile TEXT NOT NULL,
409
+ decision_id TEXT NOT NULL,
410
+ chat_id TEXT NOT NULL,
411
+ message_id TEXT NOT NULL,
412
+ PRIMARY KEY (profile, decision_id, chat_id, message_id)
413
+ );
414
+ CREATE TABLE IF NOT EXISTS message_parts (
415
+ profile TEXT NOT NULL,
416
+ decision_id TEXT NOT NULL,
417
+ recipient_index INTEGER NOT NULL,
418
+ part_index INTEGER NOT NULL,
419
+ content_digest TEXT NOT NULL,
420
+ chat_id TEXT NOT NULL,
421
+ message_id TEXT NOT NULL,
422
+ PRIMARY KEY (profile, decision_id, recipient_index, part_index)
423
+ );
424
+ `);
425
+ }
426
+
427
+ close(): void {
428
+ this.release();
429
+ this.db.close();
430
+ }
431
+
432
+ acquire(now = Date.now()): boolean {
433
+ const transaction = this.db.transaction(() => {
434
+ const row = this.db
435
+ .prepare("SELECT owner, expires_at FROM leases WHERE profile = ?")
436
+ .get(this.profile) as { owner: string; expires_at: number } | undefined;
437
+ if (row !== undefined && row.owner !== this.owner && row.expires_at > now) return false;
438
+ this.db
439
+ .prepare(
440
+ "INSERT INTO leases(profile, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(profile) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at",
441
+ )
442
+ .run(this.profile, this.owner, now + LEASE_TTL_MS);
443
+ return true;
444
+ });
445
+ return transaction();
446
+ }
447
+
448
+ renew(now = Date.now()): boolean {
449
+ return (
450
+ this.db
451
+ .prepare("UPDATE leases SET expires_at = ? WHERE profile = ? AND owner = ?")
452
+ .run(now + LEASE_TTL_MS, this.profile, this.owner).changes === 1
453
+ );
454
+ }
455
+
456
+ release(): void {
457
+ this.db
458
+ .prepare("DELETE FROM leases WHERE profile = ? AND owner = ?")
459
+ .run(this.profile, this.owner);
460
+ }
461
+
462
+ offset(): number {
463
+ return (
464
+ (
465
+ this.db.prepare("SELECT next_offset FROM offsets WHERE profile = ?").get(this.profile) as
466
+ | OffsetRow
467
+ | undefined
468
+ )?.next_offset ?? 0
469
+ );
470
+ }
471
+
472
+ setOffset(offset: number): void {
473
+ this.db
474
+ .prepare(
475
+ "INSERT INTO offsets(profile, next_offset) VALUES (?, ?) ON CONFLICT(profile) DO UPDATE SET next_offset = MAX(next_offset, excluded.next_offset)",
476
+ )
477
+ .run(this.profile, offset);
478
+ }
479
+
480
+ putCallback(token: string, binding: CallbackBinding): void {
481
+ this.db
482
+ .prepare(
483
+ "INSERT INTO callbacks(profile, token, request_json, choice_id) VALUES (?, ?, ?, ?) ON CONFLICT(profile, token) DO UPDATE SET request_json = excluded.request_json, choice_id = excluded.choice_id",
484
+ )
485
+ .run(this.profile, token, JSON.stringify(binding.request), binding.choice);
486
+ }
487
+
488
+ callback(token: string): CallbackBinding | undefined {
489
+ const row = this.db
490
+ .prepare("SELECT request_json, choice_id FROM callbacks WHERE profile = ? AND token = ?")
491
+ .get(this.profile, token) as CallbackRow | undefined;
492
+ return row === undefined
493
+ ? undefined
494
+ : {
495
+ request: JSON.parse(row.request_json) as HumanDecisionChannelRequest,
496
+ choice: row.choice_id,
497
+ };
498
+ }
499
+
500
+ putReply(binding: ReplyBinding): void {
501
+ this.db
502
+ .prepare(
503
+ "INSERT INTO replies(profile, chat_id, message_id, request_json, choice_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT(profile, chat_id, message_id) DO UPDATE SET request_json = excluded.request_json, choice_id = excluded.choice_id",
504
+ )
505
+ .run(
506
+ this.profile,
507
+ binding.chatId,
508
+ binding.promptMessageId,
509
+ JSON.stringify(binding.request),
510
+ binding.choice,
511
+ );
512
+ }
513
+
514
+ reply(chatId: string, messageId: string): ReplyBinding | undefined {
515
+ const row = this.db
516
+ .prepare(
517
+ "SELECT request_json, choice_id, chat_id, message_id FROM replies WHERE profile = ? AND chat_id = ? AND message_id = ?",
518
+ )
519
+ .get(this.profile, chatId, messageId) as ReplyRow | undefined;
520
+ return row === undefined
521
+ ? undefined
522
+ : {
523
+ request: JSON.parse(row.request_json) as HumanDecisionChannelRequest,
524
+ choice: row.choice_id,
525
+ chatId: row.chat_id,
526
+ promptMessageId: row.message_id,
527
+ };
528
+ }
529
+
530
+ deleteReply(chatId: string, messageId: string): void {
531
+ this.db
532
+ .prepare("DELETE FROM replies WHERE profile = ? AND chat_id = ? AND message_id = ?")
533
+ .run(this.profile, chatId, messageId);
534
+ }
535
+
536
+ putMessage(decisionId: string, chatId: string, messageId: string): void {
537
+ this.db
538
+ .prepare(
539
+ "INSERT OR IGNORE INTO messages(profile, decision_id, chat_id, message_id) VALUES (?, ?, ?, ?)",
540
+ )
541
+ .run(this.profile, decisionId, chatId, messageId);
542
+ }
543
+
544
+ messages(decisionId: string): MessageRow[] {
545
+ return this.db
546
+ .prepare(
547
+ "SELECT chat_id, message_id FROM messages WHERE profile = ? AND decision_id = ? ORDER BY chat_id, message_id",
548
+ )
549
+ .all(this.profile, decisionId) as MessageRow[];
550
+ }
551
+
552
+ putMessagePart(
553
+ decisionId: string,
554
+ recipientIndex: number,
555
+ partIndex: number,
556
+ contentDigest: string,
557
+ chatId: string,
558
+ messageId: string,
559
+ ): void {
560
+ this.db
561
+ .prepare(
562
+ "INSERT INTO message_parts(profile, decision_id, recipient_index, part_index, content_digest, chat_id, message_id) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(profile, decision_id, recipient_index, part_index) DO UPDATE SET content_digest = excluded.content_digest, chat_id = excluded.chat_id, message_id = excluded.message_id",
563
+ )
564
+ .run(this.profile, decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId);
565
+ }
566
+
567
+ messagePart(
568
+ decisionId: string,
569
+ recipientIndex: number,
570
+ partIndex: number,
571
+ ): MessagePartRow | undefined {
572
+ return this.db
573
+ .prepare(
574
+ "SELECT recipient_index, part_index, content_digest, chat_id, message_id FROM message_parts WHERE profile = ? AND decision_id = ? AND recipient_index = ? AND part_index = ?",
575
+ )
576
+ .get(this.profile, decisionId, recipientIndex, partIndex) as MessagePartRow | undefined;
577
+ }
578
+ }
579
+
580
+ class TelegramCallError extends Error {
581
+ constructor(
582
+ message: string,
583
+ readonly ambiguous: boolean,
584
+ readonly code: string,
585
+ ) {
586
+ super(message);
587
+ }
588
+ }
589
+
590
+ export class TelegramDecisionChannel implements HumanDecisionChannel {
591
+ readonly id: string;
592
+ private readonly profile: TelegramProfile;
593
+ private readonly store: HumanDecisionStore;
594
+ private readonly fetchFn: TelegramFetch;
595
+ private readonly apiBase: string;
596
+ private readonly onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
597
+ private readonly projection: TelegramProjection;
598
+ private running = false;
599
+ private pollAbort: AbortController | null = null;
600
+ private pollTask: Promise<void> | null = null;
601
+ private readonly settlementTasks = new Map<string, Promise<void>>();
602
+
603
+ constructor(options: {
604
+ profileName: string;
605
+ token: string;
606
+ allowedUserIds: string[];
607
+ allowedChatIds: string[];
608
+ store: HumanDecisionStore;
609
+ configDir?: string;
610
+ onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
611
+ fetchFn?: TelegramFetch;
612
+ apiBase?: string;
613
+ ownerId?: string;
614
+ }) {
615
+ requireSimpleId(options.profileName, "Telegram profile");
616
+ if (options.token.trim().length === 0) throw new Error("Telegram token must not be empty");
617
+ validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
618
+ validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
619
+ this.id = `telegram:${options.profileName}`;
620
+ this.profile = {
621
+ name: options.profileName,
622
+ token: options.token,
623
+ allowedUserIds: new Set(options.allowedUserIds),
624
+ allowedChatIds: new Set(options.allowedChatIds),
625
+ };
626
+ this.store = options.store;
627
+ this.onAnswer = options.onAnswer;
628
+ this.fetchFn = options.fetchFn ?? (fetch as TelegramFetch);
629
+ this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
630
+ this.projection = new TelegramProjection(
631
+ path.join(options.configDir ?? decisionConfigDir(), "telegram-state.sqlite"),
632
+ options.profileName,
633
+ options.ownerId ?? randomUUID(),
634
+ );
635
+ }
636
+
637
+ async start(): Promise<void> {
638
+ if (this.running) return;
639
+ this.running = true;
640
+ this.pollAbort = new AbortController();
641
+ this.pollTask = this.poll(this.pollAbort.signal);
642
+ }
643
+
644
+ async stop(): Promise<void> {
645
+ this.running = false;
646
+ this.pollAbort?.abort();
647
+ await this.pollTask?.catch(() => undefined);
648
+ await Promise.allSettled(this.settlementTasks.values());
649
+ this.pollAbort = null;
650
+ this.pollTask = null;
651
+ this.projection.close();
652
+ }
653
+
654
+ async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
655
+ const channelPath = this.channelPath();
656
+ const parts = renderTelegramParts(request);
657
+ const partDigests = parts.map((part) => digestCanonical(part));
658
+ const prior = await this.store.listDeliveries(request.decisionId, channelPath);
659
+ if (prior.some(isCompleteDelivery)) {
660
+ this.registerCallbacks(request);
661
+ return { status: "confirmed", channel: this.id, attemptId: "adopted" };
662
+ }
663
+ if (prior.some((record) => record.state === "unknown")) {
664
+ this.registerCallbacks(request);
665
+ return {
666
+ status: "unknown",
667
+ channel: this.id,
668
+ attemptId: "adopted",
669
+ errorCode: "ambiguous_delivery_not_retried",
670
+ };
671
+ }
672
+ if (hasUnsettledDeliveryIntent(prior)) {
673
+ this.registerCallbacks(request);
674
+ return {
675
+ status: "unknown",
676
+ channel: this.id,
677
+ attemptId: "adopted",
678
+ errorCode: "unsettled_delivery_intent_not_retried",
679
+ };
680
+ }
681
+
682
+ const attemptId = createHumanDecisionAttemptId();
683
+ const createdAt = new Date().toISOString();
684
+ await this.store.recordDelivery(
685
+ request,
686
+ channelPath,
687
+ v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
688
+ partCount: parts.length,
689
+ }),
690
+ );
691
+ const keyboard = this.registerCallbacks(request);
692
+ const recipients = [...this.profile.allowedChatIds].sort((left, right) =>
693
+ left.localeCompare(right),
694
+ );
695
+ for (const [recipientOffset, chatId] of recipients.entries()) {
696
+ const recipientIndex = recipientOffset + 1;
697
+ for (const [partOffset, text] of parts.entries()) {
698
+ const partIndex = partOffset + 1;
699
+ const contentDigest = partDigests[partOffset]!;
700
+ const projected = this.projection.messagePart(
701
+ request.decisionId,
702
+ recipientIndex,
703
+ partIndex,
704
+ );
705
+ const confirmed = prior.some(
706
+ (record) =>
707
+ record.schema === "pi-workflows.human-decision-delivery.v2" &&
708
+ record.phase === "part" &&
709
+ record.state === "confirmed" &&
710
+ record.recipientIndex === recipientIndex &&
711
+ record.partIndex === partIndex &&
712
+ record.partCount === parts.length &&
713
+ record.contentDigest === contentDigest,
714
+ );
715
+ if (
716
+ confirmed &&
717
+ projected?.content_digest === contentDigest &&
718
+ projected.chat_id === chatId
719
+ ) {
720
+ continue;
721
+ }
722
+ if (confirmed || projected !== undefined) {
723
+ const errorCode = "telegram_part_evidence_mismatch";
724
+ await this.store.recordDelivery(
725
+ request,
726
+ channelPath,
727
+ v2DeliveryRecord(
728
+ request,
729
+ this.id,
730
+ `${attemptId}-r${recipientIndex}-p${partIndex}-unknown`,
731
+ "part",
732
+ "unknown",
733
+ createdAt,
734
+ { recipientIndex, partIndex, partCount: parts.length, contentDigest, errorCode },
735
+ ),
736
+ );
737
+ return { status: "unknown", channel: this.id, attemptId, errorCode };
738
+ }
739
+ const partAttemptId = `${attemptId}-r${recipientIndex}-p${partIndex}`;
740
+ await this.store.recordDelivery(
741
+ request,
742
+ channelPath,
743
+ v2DeliveryRecord(
744
+ request,
745
+ this.id,
746
+ `${partAttemptId}-intent`,
747
+ "part",
748
+ "intent",
749
+ createdAt,
750
+ { recipientIndex, partIndex, partCount: parts.length, contentDigest },
751
+ ),
752
+ );
753
+ try {
754
+ const result = await this.call("sendMessage", {
755
+ chat_id: chatId,
756
+ text,
757
+ ...(partIndex === parts.length ? { reply_markup: { inline_keyboard: keyboard } } : {}),
758
+ });
759
+ const messageId = telegramMessageId(result);
760
+ this.projection.putMessage(request.decisionId, chatId, messageId);
761
+ this.projection.putMessagePart(
762
+ request.decisionId,
763
+ recipientIndex,
764
+ partIndex,
765
+ contentDigest,
766
+ chatId,
767
+ messageId,
768
+ );
769
+ await this.store.recordDelivery(
770
+ request,
771
+ channelPath,
772
+ v2DeliveryRecord(
773
+ request,
774
+ this.id,
775
+ `${partAttemptId}-confirmed`,
776
+ "part",
777
+ "confirmed",
778
+ createdAt,
779
+ { recipientIndex, partIndex, partCount: parts.length, contentDigest },
780
+ ),
781
+ );
782
+ } catch (error) {
783
+ const callError = normalizeCallError(error);
784
+ const state = callError.ambiguous ? "unknown" : "failed";
785
+ await this.store.recordDelivery(
786
+ request,
787
+ channelPath,
788
+ v2DeliveryRecord(
789
+ request,
790
+ this.id,
791
+ `${partAttemptId}-${state}`,
792
+ "part",
793
+ state,
794
+ createdAt,
795
+ {
796
+ recipientIndex,
797
+ partIndex,
798
+ partCount: parts.length,
799
+ contentDigest,
800
+ errorCode: callError.code,
801
+ },
802
+ ),
803
+ );
804
+ await this.store.recordDelivery(
805
+ request,
806
+ channelPath,
807
+ v2DeliveryRecord(
808
+ request,
809
+ this.id,
810
+ `${attemptId}-${state}`,
811
+ "complete",
812
+ state,
813
+ createdAt,
814
+ { partCount: parts.length, errorCode: callError.code },
815
+ ),
816
+ );
817
+ return { status: state, channel: this.id, attemptId, errorCode: callError.code };
818
+ }
819
+ }
820
+ }
821
+ await this.store.recordDelivery(
822
+ request,
823
+ channelPath,
824
+ v2DeliveryRecord(
825
+ request,
826
+ this.id,
827
+ `${attemptId}-confirmed`,
828
+ "complete",
829
+ "confirmed",
830
+ createdAt,
831
+ { partCount: parts.length, messageCount: parts.length * recipients.length },
832
+ ),
833
+ );
834
+ return { status: "confirmed", channel: this.id, attemptId };
835
+ }
836
+
837
+ async settle(decision: SettledHumanDecision): Promise<void> {
838
+ const existing = this.settlementTasks.get(decision.decisionId);
839
+ if (existing !== undefined) return await existing;
840
+ const task = this.settleOnce(decision);
841
+ this.settlementTasks.set(decision.decisionId, task);
842
+ try {
843
+ await task;
844
+ } finally {
845
+ if (this.settlementTasks.get(decision.decisionId) === task) {
846
+ this.settlementTasks.delete(decision.decisionId);
847
+ }
848
+ }
849
+ }
850
+
851
+ private async settleOnce(decision: SettledHumanDecision): Promise<void> {
852
+ const channelPath = this.channelPath();
853
+ const prior = await this.store.listSettlements(decision.decisionId, channelPath);
854
+ if (
855
+ prior.some((record) => record.state === "confirmed") ||
856
+ prior.length >= MAX_SETTLEMENT_ATTEMPTS
857
+ ) {
858
+ return;
859
+ }
860
+ const attemptId = createHumanDecisionAttemptId();
861
+ const createdAt = new Date().toISOString();
862
+ let failed = false;
863
+ for (const message of this.projection.messages(decision.decisionId)) {
864
+ try {
865
+ await this.call("editMessageReplyMarkup", {
866
+ chat_id: message.chat_id,
867
+ message_id: Number(message.message_id),
868
+ reply_markup: { inline_keyboard: [] },
869
+ });
870
+ } catch (error) {
871
+ if (normalizeCallError(error).code !== "telegram_message_not_modified") failed = true;
872
+ }
873
+ }
874
+ const record: HumanDecisionSettlementRecord = {
875
+ schema: "pi-workflows.human-decision-settlement.v1",
876
+ attemptId,
877
+ decisionId: decision.decisionId,
878
+ requestDigest: decision.requestDigest,
879
+ channel: this.id,
880
+ state: failed ? "failed" : "confirmed",
881
+ createdAt,
882
+ finishedAt: new Date().toISOString(),
883
+ ...(failed ? { errorCode: "telegram_settlement_failed" } : {}),
884
+ };
885
+ await this.store.recordSettlement(decision.decisionId, channelPath, record);
886
+ }
887
+
888
+ async handleUpdate(value: unknown): Promise<void> {
889
+ const update = record(value, "Telegram update");
890
+ const updateId = String(update.update_id ?? "");
891
+ const callback = asRecord(update.callback_query);
892
+ if (callback !== null) {
893
+ const actorId = String(asRecord(callback.from)?.id ?? "");
894
+ const message = asRecord(callback.message);
895
+ const chatId = String(asRecord(message?.chat)?.id ?? "");
896
+ if (!this.allowed(actorId, chatId)) return;
897
+ const binding = this.projection.callback(String(callback.data ?? ""));
898
+ if (binding === undefined || this.isStale(binding.request)) return;
899
+ const definition = binding.request.choices[binding.choice];
900
+ if (definition === undefined) return;
901
+ if (definition.input === undefined) {
902
+ await this.onAnswer({
903
+ request: binding.request,
904
+ response: { choice: binding.choice },
905
+ source: { channel: this.id, actorId, eventId: String(callback.id ?? updateId) },
906
+ idempotencyKey: `${this.id}:${String(callback.id ?? updateId)}`,
907
+ });
908
+ } else {
909
+ const prompt = await this.call("sendMessage", {
910
+ chat_id: chatId,
911
+ text: definition.input.prompt,
912
+ reply_markup: { force_reply: true, selective: true },
913
+ });
914
+ this.projection.putReply({
915
+ ...binding,
916
+ chatId,
917
+ promptMessageId: telegramMessageId(prompt),
918
+ });
919
+ }
920
+ await this.call("answerCallbackQuery", { callback_query_id: callback.id }).catch(
921
+ () => undefined,
922
+ );
923
+ return;
924
+ }
925
+
926
+ const message = asRecord(update.message);
927
+ if (message === null) return;
928
+ const actorId = String(asRecord(message.from)?.id ?? "");
929
+ const chatId = String(asRecord(message.chat)?.id ?? "");
930
+ if (!this.allowed(actorId, chatId)) return;
931
+ const replyTo = asRecord(message.reply_to_message);
932
+ const replyId = String(replyTo?.message_id ?? "");
933
+ const binding = this.projection.reply(chatId, replyId);
934
+ if (binding === undefined || this.isStale(binding.request)) return;
935
+ const definition = binding.request.choices[binding.choice];
936
+ const text = message.text;
937
+ if (definition?.input === undefined || typeof text !== "string") return;
938
+ await this.onAnswer({
939
+ request: binding.request,
940
+ response: { choice: binding.choice, input: { [definition.input.name]: text } },
941
+ source: { channel: this.id, actorId, eventId: String(message.message_id ?? updateId) },
942
+ idempotencyKey: `${this.id}:${String(message.message_id ?? updateId)}`,
943
+ });
944
+ this.projection.deleteReply(chatId, replyId);
945
+ }
946
+
947
+ private registerCallbacks(
948
+ request: HumanDecisionChannelRequest,
949
+ ): Array<Array<Record<string, string>>> {
950
+ return Object.entries(request.choices).map(([choiceId, definition]) => {
951
+ const token = callbackToken(request.decisionId, request.requestDigest, choiceId);
952
+ this.projection.putCallback(token, { request, choice: choiceId });
953
+ return [{ text: definition.label, callback_data: token }];
954
+ });
955
+ }
956
+
957
+ private channelPath(): string {
958
+ return this.id.replace(":", "-");
959
+ }
960
+
961
+ private allowed(userId: string, chatId: string): boolean {
962
+ return this.profile.allowedUserIds.has(userId) && this.profile.allowedChatIds.has(chatId);
963
+ }
964
+
965
+ private isStale(request: HumanDecisionChannelRequest): boolean {
966
+ return request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now();
967
+ }
968
+
969
+ private async poll(signal: AbortSignal): Promise<void> {
970
+ while (this.running && !signal.aborted) {
971
+ if (!this.projection.acquire()) {
972
+ await wait(LEASE_RETRY_MS, signal);
973
+ continue;
974
+ }
975
+ try {
976
+ const result = await this.call(
977
+ "getUpdates",
978
+ {
979
+ offset: this.projection.offset(),
980
+ timeout: 20,
981
+ allowed_updates: ["callback_query", "message"],
982
+ },
983
+ signal,
984
+ );
985
+ const updates = Array.isArray(result) ? result : [];
986
+ for (const update of updates) {
987
+ await this.handleUpdate(update);
988
+ const id = Number(asRecord(update)?.update_id);
989
+ if (Number.isSafeInteger(id)) this.projection.setOffset(id + 1);
990
+ }
991
+ if (!this.projection.renew()) this.projection.release();
992
+ } catch {
993
+ if (signal.aborted) return;
994
+ await wait(POLL_BACKOFF_MS, signal);
995
+ }
996
+ }
997
+ }
998
+
999
+ private async call(method: string, payload: unknown, signal?: AbortSignal): Promise<unknown> {
1000
+ let response: Awaited<ReturnType<TelegramFetch>>;
1001
+ try {
1002
+ response = await this.fetchFn(
1003
+ `${this.apiBase}/bot${encodeURIComponent(this.profile.token)}/${method}`,
1004
+ {
1005
+ method: "POST",
1006
+ headers: { "content-type": "application/json" },
1007
+ body: JSON.stringify(payload),
1008
+ ...(signal !== undefined ? { signal } : {}),
1009
+ },
1010
+ );
1011
+ } catch {
1012
+ throw new TelegramCallError(
1013
+ `Telegram ${method} request did not return a response`,
1014
+ true,
1015
+ "telegram_response_unknown",
1016
+ );
1017
+ }
1018
+ const body = record(await response.json(), `Telegram ${method} response`);
1019
+ if (!response.ok) {
1020
+ const description = typeof body.description === "string" ? body.description : "";
1021
+ if (
1022
+ method === "editMessageReplyMarkup" &&
1023
+ description.toLowerCase().includes("message is not modified")
1024
+ ) {
1025
+ throw new TelegramCallError(
1026
+ "Telegram message was already settled",
1027
+ false,
1028
+ "telegram_message_not_modified",
1029
+ );
1030
+ }
1031
+ throw new TelegramCallError(
1032
+ `Telegram ${method} failed with HTTP ${response.status}`,
1033
+ false,
1034
+ `telegram_http_${response.status}`,
1035
+ );
1036
+ }
1037
+ if (body.ok !== true) {
1038
+ throw new TelegramCallError(
1039
+ `Telegram ${method} rejected the request`,
1040
+ false,
1041
+ "telegram_rejected",
1042
+ );
1043
+ }
1044
+ return body.result;
1045
+ }
1046
+ }
1047
+
1048
+ export function createTelegramChannels(options: {
1049
+ config: DecisionChannelConfig;
1050
+ credentials: ResolvedDecisionCredentials;
1051
+ store: HumanDecisionStore;
1052
+ configDir?: string;
1053
+ onAnswer: (answer: HumanDecisionChannelAnswer) => Promise<void>;
1054
+ fetchFn?: TelegramFetch;
1055
+ apiBase?: string;
1056
+ }): Map<string, TelegramDecisionChannel> {
1057
+ const channels = new Map<string, TelegramDecisionChannel>();
1058
+ for (const [name, profile] of Object.entries(options.config.telegramProfiles ?? {})) {
1059
+ const token = options.credentials[profile.credential];
1060
+ if (token === undefined) {
1061
+ throw new Error(`Telegram profile ${name} references a missing credential`);
1062
+ }
1063
+ const channel = new TelegramDecisionChannel({
1064
+ profileName: name,
1065
+ token,
1066
+ allowedUserIds: profile.allowedUserIds,
1067
+ allowedChatIds: profile.allowedChatIds,
1068
+ store: options.store,
1069
+ onAnswer: options.onAnswer,
1070
+ ...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
1071
+ ...(options.fetchFn !== undefined ? { fetchFn: options.fetchFn } : {}),
1072
+ ...(options.apiBase !== undefined ? { apiBase: options.apiBase } : {}),
1073
+ });
1074
+ channels.set(channel.id, channel);
1075
+ }
1076
+ return channels;
1077
+ }
1078
+
1079
+ export async function verifyTelegramTokenFile(
1080
+ tokenFile: string,
1081
+ fetchFn: TelegramFetch = fetch as TelegramFetch,
1082
+ apiBase = DEFAULT_API_BASE,
1083
+ ): Promise<void> {
1084
+ if (!path.isAbsolute(tokenFile)) throw new Error("Telegram token file path must be absolute");
1085
+ await requirePrivateFile(tokenFile, "Telegram token file");
1086
+ const token = (await fsp.readFile(tokenFile, "utf8")).trim();
1087
+ if (token.length === 0) throw new Error("Telegram token file is empty");
1088
+ let response;
1089
+ try {
1090
+ response = await fetchFn(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
1091
+ method: "POST",
1092
+ headers: { "content-type": "application/json" },
1093
+ body: "{}",
1094
+ });
1095
+ } catch {
1096
+ throw new Error("Telegram credential verification did not return a response");
1097
+ }
1098
+ if (!response.ok)
1099
+ throw new Error(`Telegram credential verification failed with HTTP ${response.status}`);
1100
+ const body = record(await response.json(), "Telegram credential verification response");
1101
+ if (body.ok !== true) throw new Error("Telegram credential verification was rejected");
1102
+ }
1103
+
1104
+ export async function writeDecisionChannelProfile(options: {
1105
+ configDir?: string;
1106
+ audience: string;
1107
+ profile: string;
1108
+ credential: string;
1109
+ tokenFile: string;
1110
+ allowedUserIds: string[];
1111
+ allowedChatIds: string[];
1112
+ }): Promise<void> {
1113
+ const configDir = options.configDir ?? decisionConfigDir();
1114
+ requireSimpleId(options.audience, "Decision audience");
1115
+ requireSimpleId(options.profile, "Telegram profile");
1116
+ requireSimpleId(options.credential, "Telegram credential");
1117
+ validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
1118
+ validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
1119
+ if (!path.isAbsolute(options.tokenFile)) {
1120
+ throw new Error("Telegram token file path must be absolute");
1121
+ }
1122
+ await requirePrivateFile(options.tokenFile, "Telegram token file");
1123
+ await fsp.mkdir(configDir, { recursive: true, mode: 0o700 });
1124
+ const channelPath = path.join(configDir, "channels.json");
1125
+ const credentialPath = path.join(configDir, "credentials.json");
1126
+ const existingChannels = await readOptionalJson(channelPath);
1127
+ const existingCredentials = await readOptionalJson(credentialPath);
1128
+ const parsedChannels =
1129
+ existingChannels === null
1130
+ ? { schema: "pi-workflows.channels.v1" as const, audiences: {} }
1131
+ : parseChannelConfig(existingChannels);
1132
+ const parsedCredentials =
1133
+ existingCredentials === null
1134
+ ? { schema: "pi-workflows.credentials.v1" as const, telegram: {} }
1135
+ : parseCredentialConfig(existingCredentials);
1136
+ const channels: DecisionChannelConfig = {
1137
+ schema: "pi-workflows.channels.v1",
1138
+ audiences: {
1139
+ ...parsedChannels.audiences,
1140
+ [options.audience]: {
1141
+ channels: ["pi", `telegram:${options.profile}`],
1142
+ accept: "first-valid-answer",
1143
+ },
1144
+ },
1145
+ telegramProfiles: {
1146
+ ...parsedChannels.telegramProfiles,
1147
+ [options.profile]: {
1148
+ credential: options.credential,
1149
+ allowedUserIds: options.allowedUserIds,
1150
+ allowedChatIds: options.allowedChatIds,
1151
+ },
1152
+ },
1153
+ };
1154
+ const credentials: DecisionCredentialConfig = {
1155
+ schema: "pi-workflows.credentials.v1",
1156
+ telegram: {
1157
+ ...parsedCredentials.telegram,
1158
+ [options.credential]: { tokenFile: options.tokenFile },
1159
+ },
1160
+ };
1161
+ await writePrivateJson(channelPath, channels);
1162
+ await writePrivateJson(credentialPath, credentials);
1163
+ }
1164
+
1165
+ function parseChannelConfig(value: unknown): DecisionChannelConfig {
1166
+ const config = record(value, "Pi Workflows channel config");
1167
+ if (config.schema !== "pi-workflows.channels.v1") {
1168
+ throw new Error("Pi Workflows channel config schema is invalid");
1169
+ }
1170
+ const telegramProfiles: NonNullable<DecisionChannelConfig["telegramProfiles"]> = {};
1171
+ if (config.telegramProfiles !== undefined) {
1172
+ for (const [name, raw] of Object.entries(
1173
+ record(config.telegramProfiles, "Telegram profiles"),
1174
+ )) {
1175
+ requireSimpleId(name, "Telegram profile");
1176
+ const profile = record(raw, `Telegram profile ${name}`);
1177
+ const allowedUserIds = stringArray(
1178
+ profile.allowedUserIds,
1179
+ `Telegram profile ${name} user IDs`,
1180
+ );
1181
+ const allowedChatIds = stringArray(
1182
+ profile.allowedChatIds,
1183
+ `Telegram profile ${name} chat IDs`,
1184
+ );
1185
+ validateNumericIds(allowedUserIds, `Telegram profile ${name} user IDs`);
1186
+ validateNumericIds(allowedChatIds, `Telegram profile ${name} chat IDs`);
1187
+ telegramProfiles[name] = {
1188
+ credential: requireSimpleId(profile.credential, `Telegram profile ${name} credential`),
1189
+ allowedUserIds,
1190
+ allowedChatIds,
1191
+ };
1192
+ }
1193
+ }
1194
+ const audiencesRaw = record(config.audiences, "Pi Workflows audiences");
1195
+ const audiences: DecisionChannelConfig["audiences"] = {};
1196
+ for (const [name, raw] of Object.entries(audiencesRaw)) {
1197
+ requireSimpleId(name, "Decision audience");
1198
+ const audience = record(raw, `Audience ${name}`);
1199
+ if (audience.accept !== "first-valid-answer") {
1200
+ throw new Error(`Audience ${name} accept policy must be first-valid-answer`);
1201
+ }
1202
+ const channels = stringArray(audience.channels, `Audience ${name} channels`);
1203
+ if (channels.length === 0) throw new Error(`Audience ${name} must have at least one channel`);
1204
+ for (const channel of channels) {
1205
+ if (channel !== "pi") {
1206
+ const match = /^telegram:([A-Za-z0-9][A-Za-z0-9._-]{0,199})$/.exec(channel);
1207
+ if (match === null || telegramProfiles[match[1]!] === undefined) {
1208
+ throw new Error(`Audience ${name} references an unknown channel`);
1209
+ }
1210
+ }
1211
+ }
1212
+ audiences[name] = { channels, accept: "first-valid-answer" };
1213
+ }
1214
+ return {
1215
+ schema: "pi-workflows.channels.v1",
1216
+ audiences,
1217
+ ...(Object.keys(telegramProfiles).length > 0 ? { telegramProfiles } : {}),
1218
+ };
1219
+ }
1220
+
1221
+ function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
1222
+ const config = record(value, "Pi Workflows credential config");
1223
+ if (config.schema !== "pi-workflows.credentials.v1") {
1224
+ throw new Error("Pi Workflows credential config schema is invalid");
1225
+ }
1226
+ const telegram: DecisionCredentialConfig["telegram"] = {};
1227
+ for (const [name, raw] of Object.entries(record(config.telegram, "Telegram credentials"))) {
1228
+ requireSimpleId(name, "Telegram credential");
1229
+ const credential = record(raw, `Telegram credential ${name}`);
1230
+ telegram[name] = {
1231
+ tokenFile: requireString(credential.tokenFile, `Telegram credential ${name} tokenFile`),
1232
+ };
1233
+ }
1234
+ return { schema: "pi-workflows.credentials.v1", telegram };
1235
+ }
1236
+
1237
+ type DeliveryV2 = Extract<
1238
+ HumanDecisionDeliveryRecord,
1239
+ { schema: "pi-workflows.human-decision-delivery.v2" }
1240
+ >;
1241
+
1242
+ type DeliveryV2Extra = Partial<
1243
+ Pick<
1244
+ DeliveryV2,
1245
+ "recipientIndex" | "partIndex" | "partCount" | "contentDigest" | "messageCount" | "errorCode"
1246
+ >
1247
+ >;
1248
+
1249
+ function v2DeliveryRecord(
1250
+ request: HumanDecisionChannelRequest,
1251
+ channel: string,
1252
+ attemptId: string,
1253
+ phase: DeliveryV2["phase"],
1254
+ state: DeliveryV2["state"],
1255
+ createdAt: string,
1256
+ extra: DeliveryV2Extra = {},
1257
+ ): DeliveryV2 {
1258
+ return {
1259
+ schema: "pi-workflows.human-decision-delivery.v2",
1260
+ attemptId,
1261
+ decisionId: request.decisionId,
1262
+ requestDigest: request.requestDigest,
1263
+ presentationDigest: request.presentationDigest,
1264
+ channel,
1265
+ phase,
1266
+ state,
1267
+ createdAt,
1268
+ ...(state === "intent" ? {} : { finishedAt: new Date().toISOString() }),
1269
+ ...extra,
1270
+ };
1271
+ }
1272
+
1273
+ function isCompleteDelivery(record: HumanDecisionDeliveryRecord): boolean {
1274
+ return (
1275
+ record.state === "confirmed" &&
1276
+ (record.schema === "pi-workflows.human-decision-delivery.v1" || record.phase === "complete")
1277
+ );
1278
+ }
1279
+
1280
+ function hasUnsettledDeliveryIntent(records: HumanDecisionDeliveryRecord[]): boolean {
1281
+ const hasLegacyIntent = records.some(
1282
+ (record) =>
1283
+ record.schema === "pi-workflows.human-decision-delivery.v1" && record.state === "intent",
1284
+ );
1285
+ if (
1286
+ hasLegacyIntent &&
1287
+ !records.some(
1288
+ (record) =>
1289
+ record.schema === "pi-workflows.human-decision-delivery.v1" && record.state !== "intent",
1290
+ )
1291
+ ) {
1292
+ return true;
1293
+ }
1294
+ return records.some((record) => {
1295
+ if (
1296
+ record.schema !== "pi-workflows.human-decision-delivery.v2" ||
1297
+ record.phase !== "part" ||
1298
+ record.state !== "intent"
1299
+ ) {
1300
+ return false;
1301
+ }
1302
+ return !records.some(
1303
+ (other) =>
1304
+ other.schema === "pi-workflows.human-decision-delivery.v2" &&
1305
+ other.phase === "part" &&
1306
+ other.state !== "intent" &&
1307
+ other.recipientIndex === record.recipientIndex &&
1308
+ other.partIndex === record.partIndex &&
1309
+ other.contentDigest === record.contentDigest,
1310
+ );
1311
+ });
1312
+ }
1313
+
1314
+ export function renderDecisionText(request: HumanDecisionChannelRequest): string {
1315
+ const content = decisionDocumentSegments(request)
1316
+ .map((segment) => operatorSafeText(segment.text))
1317
+ .join("\n\n");
1318
+ return `${content}\n\nDecision ${decisionPresentationFingerprint(request)}`;
1319
+ }
1320
+
1321
+ export function renderTelegramParts(
1322
+ request: HumanDecisionChannelRequest,
1323
+ textLimit = TELEGRAM_TEXT_LIMIT,
1324
+ ): string[] {
1325
+ const fingerprint = decisionPresentationFingerprint(request);
1326
+ const maximumHeader = telegramPartHeader(
1327
+ MAX_PRESENTATION_TRANSPORT_PARTS,
1328
+ MAX_PRESENTATION_TRANSPORT_PARTS,
1329
+ fingerprint,
1330
+ );
1331
+ const capacity = textLimit - maximumHeader.length;
1332
+ if (capacity < 1)
1333
+ throw new Error("Telegram decision text limit is too small for its part header");
1334
+ const bodyParts = packDecisionSegments(
1335
+ decisionDocumentSegments(request).map((segment) => operatorSafeText(segment.text)),
1336
+ capacity,
1337
+ );
1338
+ if (bodyParts.length > MAX_PRESENTATION_TRANSPORT_PARTS) {
1339
+ throw new Error(
1340
+ `Decision presentation renders as ${bodyParts.length} Telegram parts; limit is ${MAX_PRESENTATION_TRANSPORT_PARTS}`,
1341
+ );
1342
+ }
1343
+ return bodyParts.map((body, index) => {
1344
+ const text = `${telegramPartHeader(index + 1, bodyParts.length, fingerprint)}${body}`;
1345
+ if (text.length > textLimit) {
1346
+ throw new Error(
1347
+ `Telegram decision part ${index + 1} exceeds the ${textLimit} character limit`,
1348
+ );
1349
+ }
1350
+ return text;
1351
+ });
1352
+ }
1353
+
1354
+ function renderPiPresentationLines(
1355
+ request: HumanDecisionChannelRequest,
1356
+ width: number,
1357
+ theme: Theme,
1358
+ ): string[] {
1359
+ const lines: string[] = [];
1360
+ for (const segment of decisionDocumentSegments(request).filter(
1361
+ (candidate) => candidate.kind !== "choices",
1362
+ )) {
1363
+ const text = operatorSafeText(segment.text);
1364
+ const styled =
1365
+ segment.kind === "title"
1366
+ ? theme.fg("accent", theme.bold(text))
1367
+ : segment.kind === "section"
1368
+ ? theme.fg("text", theme.bold(text))
1369
+ : segment.kind === "preformatted"
1370
+ ? theme.fg("muted", text)
1371
+ : theme.fg("text", text);
1372
+ for (const rawLine of styled.split("\n")) {
1373
+ lines.push(...wrapTextWithAnsi(rawLine.length === 0 ? " " : rawLine, Math.max(1, width)));
1374
+ }
1375
+ lines.push("");
1376
+ }
1377
+ lines.push(theme.fg("dim", `Decision ${decisionPresentationFingerprint(request)}`));
1378
+ return lines;
1379
+ }
1380
+
1381
+ function telegramPartHeader(part: number, total: number, fingerprint: string): string {
1382
+ return `Part ${part}/${total}\nDecision ${fingerprint}\n\n`;
1383
+ }
1384
+
1385
+ function packDecisionSegments(segments: string[], capacity: number): string[] {
1386
+ const parts: string[] = [];
1387
+ let current = "";
1388
+ const flush = () => {
1389
+ if (current.length === 0) return;
1390
+ parts.push(current);
1391
+ current = "";
1392
+ };
1393
+ for (const segment of segments) {
1394
+ const pieces = splitTransportText(segment, capacity);
1395
+ for (const piece of pieces) {
1396
+ const candidate = current.length === 0 ? piece : `${current}\n\n${piece}`;
1397
+ if (candidate.length <= capacity) {
1398
+ current = candidate;
1399
+ } else {
1400
+ flush();
1401
+ current = piece;
1402
+ }
1403
+ }
1404
+ }
1405
+ flush();
1406
+ return parts.length === 0 ? ["Decision"] : parts;
1407
+ }
1408
+
1409
+ function splitTransportText(value: string, capacity: number): string[] {
1410
+ const parts: string[] = [];
1411
+ let remaining = value;
1412
+ while (remaining.length > capacity) {
1413
+ let end = codePointBoundary(remaining, capacity);
1414
+ const newline = remaining.lastIndexOf("\n", end);
1415
+ if (newline > 0) end = newline;
1416
+ parts.push(remaining.slice(0, end));
1417
+ remaining = remaining.slice(end);
1418
+ }
1419
+ if (remaining.length > 0) parts.push(remaining);
1420
+ return parts;
1421
+ }
1422
+
1423
+ function codePointBoundary(value: string, limit: number): number {
1424
+ let end = Math.min(limit, value.length);
1425
+ const last = value.charCodeAt(end - 1);
1426
+ if (last >= 0xd800 && last <= 0xdbff && end < value.length) end -= 1;
1427
+ return Math.max(1, end);
1428
+ }
1429
+
1430
+ function operatorSafeText(value: string): string {
1431
+ return value
1432
+ .replaceAll("\r\n", "\n")
1433
+ .replaceAll("\r", "\n")
1434
+ .split("")
1435
+ .map((character) => {
1436
+ const code = character.charCodeAt(0);
1437
+ return code <= 8 || (code >= 11 && code <= 31) || code === 127 ? "�" : character;
1438
+ })
1439
+ .join("");
1440
+ }
1441
+
1442
+ function callbackToken(decisionId: string, requestDigest: string, choice: string): string {
1443
+ return `piw:${createHash("sha256")
1444
+ .update(`${decisionId}\0${requestDigest}\0${choice}`)
1445
+ .digest("hex")
1446
+ .slice(0, 24)}`;
1447
+ }
1448
+
1449
+ function telegramMessageId(value: unknown): string {
1450
+ const message = record(value, "Telegram message");
1451
+ const id = message.message_id;
1452
+ if (!Number.isSafeInteger(id)) throw new Error("Telegram message id is missing");
1453
+ return String(id);
1454
+ }
1455
+
1456
+ function normalizeCallError(error: unknown): TelegramCallError {
1457
+ return error instanceof TelegramCallError
1458
+ ? error
1459
+ : new TelegramCallError("Telegram request failed", true, "telegram_response_unknown");
1460
+ }
1461
+
1462
+ function validateNumericIds(values: string[], label: string): void {
1463
+ if (values.length === 0 || values.some((value) => !NUMERIC_ID.test(value))) {
1464
+ throw new Error(`${label} must contain numeric IDs`);
1465
+ }
1466
+ }
1467
+
1468
+ function requireSimpleId(value: unknown, label: string): string {
1469
+ const text = requireString(value, label);
1470
+ if (!SIMPLE_ID.test(text)) throw new Error(`${label} is invalid`);
1471
+ return text;
1472
+ }
1473
+
1474
+ function requireString(value: unknown, label: string): string {
1475
+ if (typeof value !== "string" || value.trim().length === 0) {
1476
+ throw new Error(`${label} must be a non-empty string`);
1477
+ }
1478
+ return value.trim();
1479
+ }
1480
+
1481
+ function stringArray(value: unknown, label: string): string[] {
1482
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1483
+ throw new Error(`${label} must be an array of strings`);
1484
+ }
1485
+ return [...value] as string[];
1486
+ }
1487
+
1488
+ function record(value: unknown, label: string): Record<string, unknown> {
1489
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1490
+ throw new Error(`${label} must be an object`);
1491
+ }
1492
+ return value as Record<string, unknown>;
1493
+ }
1494
+
1495
+ function asRecord(value: unknown): Record<string, unknown> | null {
1496
+ return value !== null && typeof value === "object" && !Array.isArray(value)
1497
+ ? (value as Record<string, unknown>)
1498
+ : null;
1499
+ }
1500
+
1501
+ async function requirePrivateFile(filePath: string, label: string): Promise<void> {
1502
+ const stat = await fsp.stat(filePath);
1503
+ if (!stat.isFile()) throw new Error(`${label} must be a file`);
1504
+ if ((stat.mode & 0o077) !== 0) throw new Error(`${label} permissions must be 0600`);
1505
+ }
1506
+
1507
+ async function readOptionalJson(filePath: string): Promise<unknown | null> {
1508
+ try {
1509
+ return JSON.parse(await fsp.readFile(filePath, "utf8"));
1510
+ } catch (error) {
1511
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1512
+ throw error;
1513
+ }
1514
+ }
1515
+
1516
+ async function writePrivateJson(filePath: string, value: unknown): Promise<void> {
1517
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
1518
+ await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1519
+ await fsp.rename(temporary, filePath);
1520
+ await fsp.chmod(filePath, 0o600);
1521
+ }
1522
+
1523
+ async function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
1524
+ await new Promise<void>((resolve) => {
1525
+ const timer = setTimeout(resolve, milliseconds);
1526
+ const abort = () => {
1527
+ clearTimeout(timer);
1528
+ resolve();
1529
+ };
1530
+ if (signal.aborted) abort();
1531
+ else signal.addEventListener("abort", abort, { once: true });
1532
+ });
1533
+ }