pi-afk 0.1.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.
@@ -0,0 +1,316 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { AfkAskCancelledError, AskQueue, type ActiveTelegramQuestion, type AskQueueTransport } from "./ask-queue.ts";
3
+ import { getAfkHome, readConfig, writeConfig } from "./config.ts";
4
+ import { AfkLock } from "./lock.ts";
5
+ import { parseCallbackData } from "./telegram-format.ts";
6
+ import { TelegramBridge, type TelegramBridgeHandlers, type TelegramBridgePort } from "./telegram.ts";
7
+ import type {
8
+ AfkAnswer,
9
+ AfkConfig,
10
+ AfkToolDetails,
11
+ AfkToolParams,
12
+ LinkedTelegramCallback,
13
+ LinkedTelegramMessage,
14
+ } from "./types.ts";
15
+
16
+ export interface AfkLockPort {
17
+ acquire(): Promise<{ ok: true } | { ok: false; reason: string }>;
18
+ release(): Promise<void>;
19
+ }
20
+
21
+ export interface AfkControllerOptions {
22
+ home?: string;
23
+ settingsLinkTimeoutMs?: number;
24
+ settingsPollIntervalMs?: number;
25
+ createBridge?: (token: string, handlers: TelegramBridgeHandlers) => TelegramBridgePort;
26
+ createLock?: (token: string, home: string) => AfkLockPort;
27
+ onDisabled?: (reason: string) => void | Promise<void>;
28
+ }
29
+
30
+ export type EnableResult = { ok: true } | { ok: false; reason: string };
31
+
32
+ export interface AfkToolResponse {
33
+ content: Array<{ type: "text"; text: string }>;
34
+ details: AfkToolDetails;
35
+ }
36
+
37
+ const NO_CONFIG_REASON = "Telegram is not configured. Run /afk-settings first.";
38
+ const DISABLED_REASON = "AFK mode is off";
39
+ const NO_PENDING_MESSAGE = "No AFK question is pending right now. Use Pi locally, or wait for the agent to ask something.";
40
+ const DEFAULT_SETTINGS_LINK_TIMEOUT_MS = 120_000;
41
+ const DEFAULT_SETTINGS_POLL_INTERVAL_MS = 250;
42
+
43
+ export class AfkController implements AskQueueTransport {
44
+ private readonly home: string;
45
+ private readonly createBridge: (token: string, handlers: TelegramBridgeHandlers) => TelegramBridgePort;
46
+ private readonly createLock: (token: string, home: string) => AfkLockPort;
47
+ private readonly onDisabled: ((reason: string) => void | Promise<void>) | undefined;
48
+ private readonly settingsLinkTimeoutMs: number;
49
+ private readonly settingsPollIntervalMs: number;
50
+ private config: AfkConfig | undefined;
51
+ private bridge: TelegramBridgePort | undefined;
52
+ private lock: AfkLockPort | undefined;
53
+ private askQueue: AskQueue;
54
+ private afkEnabled = false;
55
+
56
+ constructor(options: AfkControllerOptions = {}) {
57
+ this.home = options.home ?? getAfkHome();
58
+ this.settingsLinkTimeoutMs = options.settingsLinkTimeoutMs ?? DEFAULT_SETTINGS_LINK_TIMEOUT_MS;
59
+ this.settingsPollIntervalMs = options.settingsPollIntervalMs ?? DEFAULT_SETTINGS_POLL_INTERVAL_MS;
60
+ this.createBridge = options.createBridge ?? ((token, handlers) => new TelegramBridge(token, handlers));
61
+ this.createLock = options.createLock ?? ((token, home) => new AfkLock(token, home));
62
+ this.onDisabled = options.onDisabled;
63
+ this.askQueue = new AskQueue(this);
64
+ }
65
+
66
+ get isAfkEnabled(): boolean {
67
+ return this.afkEnabled;
68
+ }
69
+
70
+ async enable(): Promise<EnableResult> {
71
+ if (this.afkEnabled) return { ok: true };
72
+
73
+ const config = await readConfig(this.home);
74
+ if (!config) return { ok: false, reason: NO_CONFIG_REASON };
75
+
76
+ const lock = this.createLock(config.botToken, this.home);
77
+ const acquired = await lock.acquire();
78
+ if (!acquired.ok) return { ok: false, reason: acquired.reason };
79
+
80
+ let bridge: TelegramBridgePort | undefined;
81
+ try {
82
+ bridge = this.createBridge(config.botToken, this.handlers());
83
+ this.config = config;
84
+ this.lock = lock;
85
+ this.bridge = bridge;
86
+ await bridge.start();
87
+ this.afkEnabled = true;
88
+ return { ok: true };
89
+ } catch (error) {
90
+ try {
91
+ bridge?.stop();
92
+ } finally {
93
+ await lock.release();
94
+ this.config = undefined;
95
+ this.lock = undefined;
96
+ this.bridge = undefined;
97
+ this.afkEnabled = false;
98
+ }
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ async disable(reason: string): Promise<void> {
104
+ this.afkEnabled = false;
105
+ this.askQueue.cancelAll(reason);
106
+
107
+ try {
108
+ this.bridge?.stop();
109
+ } finally {
110
+ await this.lock?.release();
111
+ this.bridge = undefined;
112
+ this.lock = undefined;
113
+ this.config = undefined;
114
+ this.askQueue = new AskQueue(this);
115
+ }
116
+ }
117
+
118
+ promptGuidance(): string | undefined {
119
+ if (!this.afkEnabled) return undefined;
120
+ return "The user is AFK. For questions, clarification, progress updates, or decisions, use the afk tool. Do not use local-only user prompt tools.";
121
+ }
122
+
123
+ async executeTool(params: AfkToolParams, signal?: AbortSignal): Promise<AfkToolResponse> {
124
+ if (!this.afkEnabled || !this.config || !this.bridge) {
125
+ return {
126
+ content: [{ type: "text", text: "AFK mode is off. Continue normally and ask the user locally if needed." }],
127
+ details: { mode: "disabled", reason: DISABLED_REASON },
128
+ };
129
+ }
130
+
131
+ if (params.mode === "notify") {
132
+ const message = params.message?.trim();
133
+ if (!message) {
134
+ return {
135
+ content: [{ type: "text", text: "AFK notify requires a nonblank message." }],
136
+ details: { mode: "error", reason: "AFK notify requires a nonblank message" },
137
+ };
138
+ }
139
+
140
+ await this.bridge.sendMessage(this.config.chatId, message);
141
+ return { content: [{ type: "text", text: "AFK notification sent." }], details: { mode: "notify", sent: true } };
142
+ }
143
+
144
+ if (!params.questions?.length) {
145
+ return {
146
+ content: [{ type: "text", text: "AFK ask requires at least one question." }],
147
+ details: { mode: "error", reason: "AFK ask requires at least one question" },
148
+ };
149
+ }
150
+
151
+ try {
152
+ const answers = await this.askQueue.enqueue(params.questions, signal);
153
+ return { content: [{ type: "text", text: this.formatAnswers(answers) }], details: { mode: "ask", answers } };
154
+ } catch (error) {
155
+ if (error instanceof AfkAskCancelledError) {
156
+ return { content: [{ type: "text", text: error.message }], details: { mode: "cancelled", reason: error.message } };
157
+ }
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ async sendQuestion(active: ActiveTelegramQuestion): Promise<void> {
163
+ if (!this.config || !this.bridge) return;
164
+ await this.bridge.sendQuestion(this.config, active);
165
+ }
166
+
167
+ async sendCancellation(reason: string): Promise<void> {
168
+ if (!this.config || !this.bridge) return;
169
+ await this.bridge.sendMessage(this.config.chatId, `AFK question cancelled: ${reason}`);
170
+ }
171
+
172
+ async runSettings(ctx: ExtensionCommandContext): Promise<void> {
173
+ if (!ctx.hasUI) {
174
+ ctx.ui.notify("AFK settings require interactive UI.", "warning");
175
+ return;
176
+ }
177
+
178
+ const token = (await ctx.ui.input("Telegram bot token", "123456:ABCDEF"))?.trim();
179
+ if (!token) {
180
+ ctx.ui.notify("AFK settings cancelled.", "warning");
181
+ return;
182
+ }
183
+
184
+ const code = `AFK-${Math.floor(100_000 + Math.random() * 900_000)}`;
185
+ const lock = this.createLock(token, this.home);
186
+ const acquired = await lock.acquire();
187
+ if (!acquired.ok) {
188
+ ctx.ui.notify(acquired.reason, "warning");
189
+ return;
190
+ }
191
+
192
+ let linked = false;
193
+ let pollingError: unknown;
194
+ let bridge: TelegramBridgePort | undefined;
195
+
196
+ try {
197
+ bridge = this.createBridge(token, {
198
+ onText: async (message) => {
199
+ if (!message.isPrivate || message.text.trim() !== code || !bridge) return;
200
+ const me = await bridge.getMe();
201
+ await writeConfig(
202
+ { botToken: token, botUsername: me.username, chatId: message.chatId, userId: message.userId },
203
+ this.home,
204
+ );
205
+ await bridge.sendMessage(message.chatId, "AFK linked successfully ✅");
206
+ linked = true;
207
+ },
208
+ onPollingError: (error) => {
209
+ pollingError = error;
210
+ },
211
+ });
212
+
213
+ const me = await bridge.getMe();
214
+ await bridge.start();
215
+ ctx.ui.notify(`Send this one-time code to @${me.username}: ${code}`, "info");
216
+
217
+ const deadline = Date.now() + this.settingsLinkTimeoutMs;
218
+ while (!linked) {
219
+ if (pollingError) {
220
+ ctx.ui.notify("AFK Telegram polling failed during settings link.", "warning");
221
+ return;
222
+ }
223
+ if (Date.now() >= deadline) {
224
+ ctx.ui.notify("AFK Telegram link timed out.", "warning");
225
+ return;
226
+ }
227
+ await new Promise((resolve) =>
228
+ setTimeout(resolve, Math.max(1, Math.min(this.settingsPollIntervalMs, deadline - Date.now()))),
229
+ );
230
+ }
231
+ ctx.ui.notify("AFK Telegram settings saved.", "info");
232
+ } catch {
233
+ ctx.ui.notify("AFK Telegram settings failed.", "error");
234
+ } finally {
235
+ try {
236
+ bridge?.stop();
237
+ } finally {
238
+ await lock.release();
239
+ }
240
+ }
241
+ }
242
+
243
+ private handlers(): TelegramBridgeHandlers {
244
+ return {
245
+ onText: (message) => this.handleText(message),
246
+ onCallback: (callback) => this.handleCallback(callback),
247
+ onPollingError: () => {
248
+ const reason = "AFK Telegram polling failed";
249
+ void this.disable(reason)
250
+ .then(() => this.onDisabled?.(reason))
251
+ .catch(() => {});
252
+ },
253
+ };
254
+ }
255
+
256
+ private async handleText(message: LinkedTelegramMessage): Promise<void> {
257
+ if (!this.config || !this.bridge) return;
258
+ if (!this.isLinkedMessage(message)) return;
259
+
260
+ const answered = await this.askQueue.answerWithText(message.text);
261
+ if (!answered) await this.bridge.sendMessage(this.config.chatId, NO_PENDING_MESSAGE);
262
+ }
263
+
264
+ private async handleCallback(callback: LinkedTelegramCallback): Promise<void> {
265
+ if (!this.config || !this.bridge) return;
266
+ if (callback.chatId !== this.config.chatId || callback.userId !== this.config.userId) {
267
+ await this.bridge.answerCallback(callback.callbackQueryId, "Unauthorized AFK answer");
268
+ return;
269
+ }
270
+
271
+ const parsed = parseCallbackData(callback.data);
272
+ if (!parsed) {
273
+ await this.bridge.answerCallback(callback.callbackQueryId, "Invalid AFK answer");
274
+ return;
275
+ }
276
+
277
+ const answered = await this.askQueue.answerWithOption(parsed.nonce, parsed.optionIndex);
278
+ await this.bridge.answerCallback(callback.callbackQueryId, answered ? "Answer received" : "This question is no longer active");
279
+ }
280
+
281
+ private isLinkedMessage(message: LinkedTelegramMessage): boolean {
282
+ return Boolean(
283
+ this.config && message.isPrivate && message.chatId === this.config.chatId && message.userId === this.config.userId,
284
+ );
285
+ }
286
+
287
+ private formatAnswers(answers: AfkAnswer[]): string {
288
+ if (answers.length === 0) return "No AFK answers received.";
289
+ return answers
290
+ .map((answer) => `${answer.id}: ${answer.wasCustom ? "user wrote" : "user selected"}: ${answer.label}`)
291
+ .join("\n");
292
+ }
293
+ }
294
+
295
+ export async function toggleAfk(controller: AfkController, ctx: ExtensionCommandContext): Promise<void> {
296
+ if (controller.isAfkEnabled) {
297
+ await controller.disable("AFK disabled");
298
+ ctx.ui.setStatus("afk", undefined);
299
+ ctx.ui.notify("AFK mode off", "info");
300
+ return;
301
+ }
302
+
303
+ const result = await controller.enable();
304
+ if (!result.ok) {
305
+ ctx.ui.notify(result.reason, "warning");
306
+ return;
307
+ }
308
+
309
+ ctx.ui.setStatus("afk", "AFK: on");
310
+ ctx.ui.notify("AFK mode on", "info");
311
+ }
312
+
313
+ export async function shutdownAfk(controller: AfkController, ctx: ExtensionContext, reason: string): Promise<void> {
314
+ await controller.disable(reason);
315
+ ctx.ui.setStatus("afk", undefined);
316
+ }
@@ -0,0 +1,86 @@
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { AfkController, shutdownAfk, toggleAfk } from "./controller.ts";
4
+ import { AfkToolParamsSchema, type AfkToolDetails, type AfkToolParams } from "./types.ts";
5
+
6
+ const TOOL_DESCRIPTION =
7
+ "Send Telegram notifications or blocking questions to the user while AFK mode is enabled.";
8
+
9
+ function describeCall(params: AfkToolParams): string {
10
+ if (params.mode === "notify") return `AFK notify via Telegram: ${params.message?.trim() || "(no message)"}`;
11
+ return `AFK ask via Telegram: ${params.questions?.length ?? 0} question(s)`;
12
+ }
13
+
14
+ function describeResult(result: AgentToolResult<AfkToolDetails>): string {
15
+ const details = result.details;
16
+ if (details?.mode === "notify") return details.sent ? "AFK Telegram notification sent." : "AFK Telegram notification not sent.";
17
+ if (details?.mode === "ask") return `AFK received ${details.answers.length} Telegram answer(s).`;
18
+ if (details?.mode === "disabled") return `AFK disabled: ${details.reason}`;
19
+ if (details?.mode === "cancelled") return `AFK cancelled: ${details.reason}`;
20
+ if (details?.mode === "error") return `AFK error: ${details.reason}`;
21
+
22
+ const text = result.content.find((item) => item.type === "text")?.text;
23
+ return text ?? "AFK tool finished.";
24
+ }
25
+
26
+ /** AFK extension entry point. */
27
+ export default function extension(pi: ExtensionAPI): void {
28
+ let latestUi: ExtensionContext["ui"] | undefined;
29
+ const captureUi = (ctx: ExtensionContext): void => {
30
+ latestUi = ctx.ui;
31
+ };
32
+ const controller = new AfkController({
33
+ onDisabled(reason) {
34
+ latestUi?.setStatus("afk", undefined);
35
+ latestUi?.notify(reason, "warning");
36
+ },
37
+ });
38
+
39
+ pi.registerCommand("afk", {
40
+ description: "Toggle AFK mode and Telegram relay",
41
+ handler: async (_args, ctx) => {
42
+ captureUi(ctx);
43
+ await toggleAfk(controller, ctx);
44
+ },
45
+ });
46
+
47
+ pi.registerCommand("afk-settings", {
48
+ description: "Configure AFK Telegram settings",
49
+ handler: async (_args, ctx) => {
50
+ captureUi(ctx);
51
+ await controller.runSettings(ctx);
52
+ },
53
+ });
54
+
55
+ pi.registerTool<typeof AfkToolParamsSchema, AfkToolDetails>({
56
+ name: "afk",
57
+ label: "AFK",
58
+ description: TOOL_DESCRIPTION,
59
+ promptSnippet: "Use the afk tool to notify or ask the user through Telegram when AFK mode is active.",
60
+ promptGuidelines: [
61
+ "Use the afk tool for user notifications, questions, and decisions when AFK mode indicates the user is away.",
62
+ "AFK tool calls relay through Telegram; keep messages concise and include clear answer options for blocking questions.",
63
+ ],
64
+ parameters: AfkToolParamsSchema,
65
+ async execute(_toolCallId, params, signal) {
66
+ return controller.executeTool(params, signal);
67
+ },
68
+ renderCall(params) {
69
+ return new Text(describeCall(params), 0, 0);
70
+ },
71
+ renderResult(result) {
72
+ return new Text(describeResult(result), 0, 0);
73
+ },
74
+ });
75
+
76
+ pi.on("before_agent_start", (event) => {
77
+ const guidance = controller.promptGuidance();
78
+ if (!guidance) return undefined;
79
+ return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
80
+ });
81
+
82
+ pi.on("session_shutdown", async (event, ctx) => {
83
+ captureUi(ctx);
84
+ await shutdownAfk(controller, ctx, event.reason);
85
+ });
86
+ }