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,320 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { getAfkHome } from "./config.ts";
5
+
6
+ export interface LockOwner {
7
+ pid: number;
8
+ createdAt: number;
9
+ cwd: string;
10
+ }
11
+
12
+ export type LockAcquireResult = { ok: true } | { ok: false; owner?: LockOwner; reason: string };
13
+
14
+ export interface AfkLockOptions {
15
+ pid?: number;
16
+ cwd?: string;
17
+ isProcessAlive?: (pid: number) => boolean;
18
+ invalidLockStaleMs?: number;
19
+ }
20
+
21
+ interface LegacyAfkLockOptions extends AfkLockOptions {
22
+ token: string;
23
+ home: string;
24
+ }
25
+
26
+ const DEFAULT_INVALID_LOCK_STALE_MS = 30_000;
27
+ const OWNER_FILE = "owner.json";
28
+
29
+ export function lockPathForToken(token: string, home = getAfkHome()): string {
30
+ const hash = createHash("sha256").update(token).digest("hex").slice(0, 32);
31
+ return join(home, "locks", `${hash}.lock`);
32
+ }
33
+
34
+ function defaultIsProcessAlive(pid: number): boolean {
35
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
36
+
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch (error) {
41
+ if (error && typeof error === "object" && "code" in error) {
42
+ if (error.code === "ESRCH") return false;
43
+ if (error.code === "EPERM") return true;
44
+ }
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function hasErrorCode(error: unknown, code: string): boolean {
50
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
51
+ }
52
+
53
+ function parseLockOwner(raw: string): LockOwner | undefined {
54
+ try {
55
+ const parsed = JSON.parse(raw) as unknown;
56
+ if (!parsed || typeof parsed !== "object") return undefined;
57
+ const candidate = parsed as Record<string, unknown>;
58
+ if (!Number.isSafeInteger(candidate.pid) || Number(candidate.pid) <= 0) return undefined;
59
+ if (typeof candidate.createdAt !== "number" || !Number.isFinite(candidate.createdAt)) return undefined;
60
+ if (typeof candidate.cwd !== "string" || candidate.cwd.length === 0) return undefined;
61
+ return { pid: candidate.pid as number, createdAt: candidate.createdAt, cwd: candidate.cwd };
62
+ } catch (error) {
63
+ if (error instanceof SyntaxError) return undefined;
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ function sameOwner(left: LockOwner, right: LockOwner): boolean {
69
+ return left.pid === right.pid && left.createdAt === right.createdAt && left.cwd === right.cwd;
70
+ }
71
+
72
+ async function fsyncDirectory(path: string): Promise<void> {
73
+ if (process.platform === "win32") return;
74
+
75
+ let handle;
76
+ try {
77
+ handle = await open(path, "r");
78
+ await handle.sync();
79
+ } catch (error) {
80
+ if (error && typeof error === "object" && "code" in error) {
81
+ const code = error.code;
82
+ if (code === "EINVAL" || code === "ENOTSUP" || code === "EISDIR" || code === "EPERM") return;
83
+ }
84
+ throw error;
85
+ } finally {
86
+ await handle?.close();
87
+ }
88
+ }
89
+
90
+ async function validateExistingDirectory(path: string, label: string): Promise<boolean> {
91
+ let info;
92
+ try {
93
+ info = await lstat(path);
94
+ } catch (error) {
95
+ if (hasErrorCode(error, "ENOENT")) return false;
96
+ throw error;
97
+ }
98
+
99
+ if (info.isSymbolicLink()) throw new Error(`${label} is a symlink: ${path}`);
100
+ if (!info.isDirectory()) throw new Error(`${label} must be a real directory: ${path}`);
101
+ return true;
102
+ }
103
+
104
+ async function writeSyncedFile(path: string, content: string): Promise<void> {
105
+ let handle;
106
+ try {
107
+ handle = await open(path, "wx", 0o600);
108
+ await handle.writeFile(content, "utf8");
109
+ await handle.sync();
110
+ } finally {
111
+ await handle?.close();
112
+ }
113
+ }
114
+
115
+ export class AfkLock {
116
+ private readonly token: string;
117
+ private readonly home: string;
118
+ private readonly path: string;
119
+ private readonly lockDir: string;
120
+ private readonly ownerPath: string;
121
+ private readonly pid: number;
122
+ private readonly cwd: string;
123
+ private readonly isProcessAlive: (pid: number) => boolean;
124
+ private readonly invalidLockStaleMs: number;
125
+ private acquiredOwner: LockOwner | undefined;
126
+
127
+ constructor(token: string, home?: string, options?: AfkLockOptions);
128
+ constructor(options: LegacyAfkLockOptions);
129
+ constructor(tokenOrOptions: string | LegacyAfkLockOptions, home = getAfkHome(), options: AfkLockOptions = {}) {
130
+ if (typeof tokenOrOptions === "string") {
131
+ this.token = tokenOrOptions;
132
+ this.home = home;
133
+ } else {
134
+ this.token = tokenOrOptions.token;
135
+ this.home = tokenOrOptions.home;
136
+ options = tokenOrOptions;
137
+ }
138
+ this.path = lockPathForToken(this.token, this.home);
139
+ this.lockDir = join(this.home, "locks");
140
+ this.ownerPath = join(this.path, OWNER_FILE);
141
+ this.pid = options.pid ?? process.pid;
142
+ this.cwd = options.cwd ?? process.cwd();
143
+ this.isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive;
144
+ this.invalidLockStaleMs = options.invalidLockStaleMs ?? DEFAULT_INVALID_LOCK_STALE_MS;
145
+ }
146
+
147
+ async acquire(): Promise<LockAcquireResult> {
148
+ await this.ensureLockDir();
149
+
150
+ for (;;) {
151
+ const owner: LockOwner = { pid: this.pid, createdAt: Date.now(), cwd: this.cwd };
152
+ const publishResult = await this.tryCreateLockDirectory(owner);
153
+ if (publishResult === "published") {
154
+ this.acquiredOwner = owner;
155
+ return { ok: true };
156
+ }
157
+
158
+ const existing = await this.readExistingLock(this.path);
159
+ if (!existing) continue;
160
+
161
+ if (!existing.owner) {
162
+ if (!(await this.isInvalidLockStale(this.path))) {
163
+ return { ok: false, reason: "lock owner metadata is invalid and fresh" };
164
+ }
165
+ if (await this.claimAndRemoveStaleLock(existing)) continue;
166
+ return { ok: false, reason: "lock changed during invalid cleanup" };
167
+ }
168
+
169
+ if (this.isProcessAlive(existing.owner.pid)) {
170
+ return { ok: false, owner: existing.owner, reason: "owner process is alive" };
171
+ }
172
+
173
+ if (await this.claimAndRemoveStaleLock(existing)) continue;
174
+ const changed = await this.readExistingLock(this.path);
175
+ if (changed?.owner) return { ok: false, owner: changed.owner, reason: "lock changed during stale cleanup" };
176
+ return { ok: false, reason: "lock changed during stale cleanup" };
177
+ }
178
+ }
179
+
180
+ async release(): Promise<void> {
181
+ if (!this.acquiredOwner) return;
182
+ const claimPath = await this.claimLockDirectory("release");
183
+ if (!claimPath) {
184
+ this.acquiredOwner = undefined;
185
+ return;
186
+ }
187
+
188
+ const claimed = await this.readExistingLock(claimPath);
189
+ if (claimed?.owner && sameOwner(claimed.owner, this.acquiredOwner)) {
190
+ await rm(claimPath, { recursive: true, force: true });
191
+ await fsyncDirectory(this.lockDir);
192
+ } else {
193
+ await this.restoreClaimedLock(claimPath);
194
+ }
195
+ this.acquiredOwner = undefined;
196
+ }
197
+
198
+ private async ensureLockDir(): Promise<void> {
199
+ const homeExists = await validateExistingDirectory(this.home, "AFK home");
200
+ if (!homeExists) await mkdir(this.home, { recursive: true, mode: 0o700 });
201
+ await chmod(this.home, 0o700);
202
+
203
+ try {
204
+ await mkdir(this.lockDir, { mode: 0o700 });
205
+ await fsyncDirectory(this.home);
206
+ } catch (error) {
207
+ if (!hasErrorCode(error, "EEXIST")) throw error;
208
+ }
209
+
210
+ await validateExistingDirectory(this.lockDir, "lock directory");
211
+ await chmod(this.lockDir, 0o700);
212
+ await fsyncDirectory(this.lockDir);
213
+ }
214
+
215
+ private async tryCreateLockDirectory(owner: LockOwner): Promise<"published" | "exists"> {
216
+ try {
217
+ await mkdir(this.path, { mode: 0o700 });
218
+ } catch (error) {
219
+ if (hasErrorCode(error, "EEXIST")) return "exists";
220
+ throw error;
221
+ }
222
+
223
+ try {
224
+ await writeSyncedFile(this.ownerPath, `${JSON.stringify(owner)}\n`);
225
+ await fsyncDirectory(this.path);
226
+ await fsyncDirectory(this.lockDir);
227
+ return "published";
228
+ } catch (error) {
229
+ await rm(this.path, { recursive: true, force: true });
230
+ await fsyncDirectory(this.lockDir);
231
+ throw error;
232
+ }
233
+ }
234
+
235
+ private async readExistingLock(path: string): Promise<{ raw: string | undefined; owner: LockOwner | undefined } | undefined> {
236
+ let info;
237
+ try {
238
+ info = await lstat(path);
239
+ } catch (error) {
240
+ if (hasErrorCode(error, "ENOENT")) return undefined;
241
+ throw error;
242
+ }
243
+
244
+ if (info.isSymbolicLink() || !info.isDirectory()) {
245
+ throw new Error(`lock path exists but is not a directory: ${path}`);
246
+ }
247
+
248
+ try {
249
+ const raw = await readFile(join(path, OWNER_FILE), "utf8");
250
+ return { raw, owner: parseLockOwner(raw) };
251
+ } catch (error) {
252
+ if (hasErrorCode(error, "ENOENT")) return { raw: undefined, owner: undefined };
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ private async isInvalidLockStale(path: string): Promise<boolean> {
258
+ try {
259
+ const directoryInfo = await stat(path);
260
+ let newestMetadataMtime = directoryInfo.mtimeMs;
261
+ try {
262
+ const ownerInfo = await stat(join(path, OWNER_FILE));
263
+ newestMetadataMtime = Math.max(newestMetadataMtime, ownerInfo.mtimeMs);
264
+ } catch (error) {
265
+ if (!hasErrorCode(error, "ENOENT")) throw error;
266
+ }
267
+ return Date.now() - newestMetadataMtime > this.invalidLockStaleMs;
268
+ } catch (error) {
269
+ if (hasErrorCode(error, "ENOENT")) return false;
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ private async claimAndRemoveStaleLock(expected: { raw: string | undefined; owner: LockOwner | undefined }): Promise<boolean> {
275
+ const claimPath = await this.claimLockDirectory("stale");
276
+ if (!claimPath) return false;
277
+
278
+ const claimed = await this.readExistingLock(claimPath);
279
+ if (!claimed || !this.sameLockMetadata(claimed, expected)) {
280
+ await this.restoreClaimedLock(claimPath);
281
+ return false;
282
+ }
283
+
284
+ await rm(claimPath, { recursive: true, force: true });
285
+ await fsyncDirectory(this.lockDir);
286
+ return true;
287
+ }
288
+
289
+ private async claimLockDirectory(reason: "release" | "stale"): Promise<string | undefined> {
290
+ const claimPath = join(this.lockDir, `.${basename(this.path)}.${process.pid}.${randomUUID()}.${reason}`);
291
+ try {
292
+ await rename(this.path, claimPath);
293
+ await fsyncDirectory(this.lockDir);
294
+ return claimPath;
295
+ } catch (error) {
296
+ if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "EEXIST")) return undefined;
297
+ throw error;
298
+ }
299
+ }
300
+
301
+ private async restoreClaimedLock(claimPath: string): Promise<void> {
302
+ try {
303
+ await rename(claimPath, this.path);
304
+ await fsyncDirectory(this.lockDir);
305
+ } catch (error) {
306
+ if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "EEXIST")) return;
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ private sameLockMetadata(
312
+ left: { raw: string | undefined; owner: LockOwner | undefined },
313
+ right: { raw: string | undefined; owner: LockOwner | undefined },
314
+ ): boolean {
315
+ if (left.raw !== right.raw) return false;
316
+ if (!left.owner && !right.owner) return true;
317
+ if (!left.owner || !right.owner) return false;
318
+ return sameOwner(left.owner, right.owner);
319
+ }
320
+ }
@@ -0,0 +1,58 @@
1
+ import type { ActiveTelegramQuestion } from "./ask-queue.ts";
2
+
3
+ export interface TelegramQuestionButton {
4
+ text: string;
5
+ callbackData: string;
6
+ }
7
+
8
+ export interface TelegramQuestionPayload {
9
+ text: string;
10
+ buttons: TelegramQuestionButton[][];
11
+ }
12
+
13
+ export interface ParsedCallbackData {
14
+ nonce: string;
15
+ optionIndex: number;
16
+ }
17
+
18
+ export function buildCallbackData(nonce: string, optionIndex: number): string {
19
+ return `afk:${nonce}:${optionIndex}`;
20
+ }
21
+
22
+ export function parseCallbackData(data: string): ParsedCallbackData | undefined {
23
+ const parts = data.split(":");
24
+ if (parts.length !== 3) return undefined;
25
+
26
+ const [prefix, nonce, rawOptionIndex] = parts;
27
+ if (prefix !== "afk" || !nonce || !rawOptionIndex) return undefined;
28
+
29
+ const optionIndex = Number(rawOptionIndex);
30
+ if (!Number.isSafeInteger(optionIndex) || optionIndex < 0) return undefined;
31
+
32
+ return { nonce, optionIndex };
33
+ }
34
+
35
+ export function buildQuestionPayload(active: ActiveTelegramQuestion): TelegramQuestionPayload {
36
+ const lines = [
37
+ `Question ${active.questionIndex + 1}/${active.totalQuestions}`,
38
+ "",
39
+ active.question.question,
40
+ "",
41
+ ...active.question.options.map((option, index) => {
42
+ const description = option.description ? ` — ${option.description}` : "";
43
+ return `${index + 1}. ${option.label}${description}`;
44
+ }),
45
+ "",
46
+ "Choose an option below, or reply with custom text.",
47
+ ];
48
+
49
+ return {
50
+ text: lines.join("\n"),
51
+ buttons: active.question.options.map((option, index) => [
52
+ {
53
+ text: `${option.recommended ? "⭐ " : ""}${option.label}`,
54
+ callbackData: buildCallbackData(active.nonce, index),
55
+ },
56
+ ]),
57
+ };
58
+ }
@@ -0,0 +1,176 @@
1
+ import { Bot, InlineKeyboard } from "grammy";
2
+ import type { ActiveTelegramQuestion } from "./ask-queue.ts";
3
+ import type { AfkConfig, LinkedTelegramCallback, LinkedTelegramMessage } from "./types.ts";
4
+ import { buildQuestionPayload } from "./telegram-format.ts";
5
+
6
+ export interface TelegramBridgeHandlers {
7
+ onText?: (message: LinkedTelegramMessage) => void | Promise<void>;
8
+ onMessage?: (message: LinkedTelegramMessage) => void | Promise<void>;
9
+ onCallback?: (callback: LinkedTelegramCallback) => void | Promise<void>;
10
+ onPollingError?: (error: unknown) => void;
11
+ }
12
+
13
+ interface TelegramBotLike {
14
+ on(event: "message:text" | "callback_query:data", handler: (ctx: TelegramContextLike) => void | Promise<void>): void;
15
+ catch?(handler: (error: unknown) => void): void;
16
+ init(): Promise<void>;
17
+ start(): void | Promise<void>;
18
+ stop(): void;
19
+ api: {
20
+ getMe(): Promise<{ username?: string; first_name: string }>;
21
+ sendMessage(chatId: number, text: string, options?: unknown): Promise<void>;
22
+ answerCallbackQuery(callbackQueryId: string, options?: { text: string }): Promise<void>;
23
+ };
24
+ }
25
+
26
+ interface TelegramContextLike {
27
+ chat: { id: number; type?: string };
28
+ from: { id: number };
29
+ message: { text: string };
30
+ callbackQuery: { id: string; data: string; message?: { chat: { id: number } } };
31
+ }
32
+
33
+ export interface TelegramBridgePort {
34
+ getMe(): Promise<{ username: string }>;
35
+ start(): Promise<void>;
36
+ stop(): void;
37
+ sendMessage(chatId: number, text: string): Promise<void>;
38
+ sendQuestion(config: Pick<AfkConfig, "chatId">, active: ActiveTelegramQuestion): Promise<void>;
39
+ answerCallback(callbackQueryId: string, text?: string): Promise<void>;
40
+ }
41
+
42
+ export class TelegramBridge implements TelegramBridgePort {
43
+ private readonly bot: TelegramBotLike;
44
+ private pollingPromise: Promise<void> | undefined;
45
+
46
+ constructor(token: string, private readonly handlers: TelegramBridgeHandlers, bot?: TelegramBotLike) {
47
+ this.bot = bot ?? (new Bot(token) as unknown as TelegramBotLike);
48
+ this.bot.on("message:text", (ctx) =>
49
+ this.isolateHandlerError(async () => {
50
+ const handler = this.handlers.onText ?? this.handlers.onMessage;
51
+ await handler?.({
52
+ chatId: ctx.chat.id,
53
+ userId: ctx.from.id,
54
+ text: ctx.message.text,
55
+ isPrivate: ctx.chat.type === "private",
56
+ });
57
+ }),
58
+ );
59
+ this.bot.on("callback_query:data", (ctx) => this.handleCallbackQuery(ctx));
60
+ this.bot.catch?.((error) => this.reportPollingError(error));
61
+ }
62
+
63
+ async getMe(): Promise<{ username: string }> {
64
+ const me = await this.bot.api.getMe();
65
+ return { username: me.username ?? me.first_name };
66
+ }
67
+
68
+ async start(): Promise<void> {
69
+ if (this.pollingPromise) return;
70
+
71
+ await this.bot.init();
72
+ if (this.pollingPromise) return;
73
+
74
+ let pollingResult: void | Promise<void>;
75
+ try {
76
+ pollingResult = this.bot.start();
77
+ } catch (error) {
78
+ this.pollingPromise = undefined;
79
+ throw error;
80
+ }
81
+
82
+ const polling = Promise.resolve(pollingResult);
83
+ this.pollingPromise = polling.catch((error: unknown) => {
84
+ this.pollingPromise = undefined;
85
+ this.reportPollingError(error);
86
+ });
87
+ await this.observeImmediatePollingFailure(polling);
88
+ }
89
+
90
+ stop(): void {
91
+ if (!this.pollingPromise) return;
92
+ try {
93
+ this.bot.stop();
94
+ } catch (error) {
95
+ this.reportPollingError(error);
96
+ } finally {
97
+ this.pollingPromise = undefined;
98
+ }
99
+ }
100
+
101
+ async sendMessage(chatId: number, text: string): Promise<void> {
102
+ await this.bot.api.sendMessage(chatId, text);
103
+ }
104
+
105
+ async sendQuestion(config: Pick<AfkConfig, "chatId">, active: ActiveTelegramQuestion): Promise<void> {
106
+ const payload = buildQuestionPayload(active);
107
+ const keyboard = new InlineKeyboard();
108
+ payload.buttons.forEach((row, rowIndex) => {
109
+ if (rowIndex > 0) keyboard.row();
110
+ for (const button of row) {
111
+ keyboard.text(button.text, button.callbackData);
112
+ }
113
+ });
114
+
115
+ await this.bot.api.sendMessage(config.chatId, payload.text, { reply_markup: keyboard });
116
+ }
117
+
118
+ async answerCallback(callbackQueryId: string, text?: string): Promise<void> {
119
+ if (text) {
120
+ await this.bot.api.answerCallbackQuery(callbackQueryId, { text });
121
+ return;
122
+ }
123
+ await this.bot.api.answerCallbackQuery(callbackQueryId);
124
+ }
125
+
126
+ private async handleCallbackQuery(ctx: TelegramContextLike): Promise<void> {
127
+ const { callbackQuery } = ctx;
128
+ const message = callbackQuery.message;
129
+ if (!message) {
130
+ await this.isolateHandlerError(() => this.answerCallback(callbackQuery.id));
131
+ return;
132
+ }
133
+
134
+ try {
135
+ await this.handlers.onCallback?.({
136
+ callbackQueryId: callbackQuery.id,
137
+ chatId: message.chat.id,
138
+ userId: ctx.from.id,
139
+ data: callbackQuery.data,
140
+ });
141
+ } catch (error) {
142
+ try {
143
+ await this.answerCallback(callbackQuery.id);
144
+ } catch (answerError) {
145
+ this.reportPollingError(answerError);
146
+ }
147
+ this.reportPollingError(error);
148
+ }
149
+ }
150
+
151
+ private async observeImmediatePollingFailure(polling: Promise<void>): Promise<void> {
152
+ const stillPolling = Symbol("stillPolling");
153
+ await Promise.race([
154
+ polling,
155
+ new Promise<typeof stillPolling>((resolve) => {
156
+ setImmediate(() => resolve(stillPolling));
157
+ }),
158
+ ]);
159
+ }
160
+
161
+ private async isolateHandlerError(handler: () => void | Promise<void>): Promise<void> {
162
+ try {
163
+ await handler();
164
+ } catch (error) {
165
+ this.reportPollingError(error);
166
+ }
167
+ }
168
+
169
+ private reportPollingError(error: unknown): void {
170
+ try {
171
+ this.handlers.onPollingError?.(error);
172
+ } catch {
173
+ // Keep polling alive even if the error reporter fails.
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,65 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type, type Static } from "typebox";
3
+
4
+ export interface AfkConfig {
5
+ botToken: string;
6
+ botUsername: string;
7
+ chatId: number;
8
+ userId: number;
9
+ }
10
+
11
+ export interface LinkedTelegramMessage {
12
+ chatId: number;
13
+ userId: number;
14
+ text: string;
15
+ isPrivate: boolean;
16
+ }
17
+
18
+ export interface LinkedTelegramCallback {
19
+ callbackQueryId: string;
20
+ chatId: number;
21
+ userId: number;
22
+ data: string;
23
+ }
24
+
25
+ export const AfkOptionSchema = Type.Object({
26
+ label: Type.String({ minLength: 1, description: "Button label shown in Telegram" }),
27
+ value: Type.String({ minLength: 1, description: "Value returned to the agent if selected" }),
28
+ description: Type.Optional(Type.String({ description: "Optional context shown in the Telegram message body" })),
29
+ recommended: Type.Optional(Type.Boolean({ description: "Marks this option as recommended in Telegram" })),
30
+ });
31
+
32
+ export const AfkQuestionSchema = Type.Object({
33
+ id: Type.String({ minLength: 1, description: "Stable identifier used in the returned answer" }),
34
+ question: Type.String({ minLength: 1, description: "Question text sent to Telegram" }),
35
+ options: Type.Array(AfkOptionSchema, { minItems: 1, description: "Single-select answer options" }),
36
+ });
37
+
38
+ export const AfkToolParamsSchema = Type.Object({
39
+ mode: StringEnum(["notify", "ask"] as const, { description: "Send a notification or ask blocking questions" }),
40
+ message: Type.Optional(Type.String({ description: "Notification text for mode=notify" })),
41
+ questions: Type.Optional(Type.Array(AfkQuestionSchema, { description: "Questions for mode=ask" })),
42
+ });
43
+
44
+ export type AfkOption = Static<typeof AfkOptionSchema>;
45
+ export type AfkQuestion = Static<typeof AfkQuestionSchema>;
46
+ export type AfkToolParams = Static<typeof AfkToolParamsSchema>;
47
+
48
+ export interface AfkAnswer {
49
+ id: string;
50
+ value: string;
51
+ label: string;
52
+ wasCustom: boolean;
53
+ }
54
+
55
+ export interface AfkAskResult {
56
+ mode: "ask";
57
+ answers: AfkAnswer[];
58
+ }
59
+
60
+ export interface AfkNotifyResult {
61
+ mode: "notify";
62
+ sent: boolean;
63
+ }
64
+
65
+ export type AfkToolDetails = AfkAskResult | AfkNotifyResult | { mode: "disabled" | "cancelled" | "error"; reason: string };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "pi-afk",
3
+ "version": "0.1.0",
4
+ "description": "A Pi package extension that allows the agent to communicate with the user when they're afk.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Maz Li",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/HamdiMaz/afk.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/HamdiMaz/afk/issues"
14
+ },
15
+ "homepage": "https://github.com/HamdiMaz/afk#readme",
16
+ "engines": {
17
+ "node": ">=20.6.0"
18
+ },
19
+ "keywords": [
20
+ "pi-package",
21
+ "pi-extension",
22
+ "afk"
23
+ ],
24
+ "files": [
25
+ "extensions",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "LICENSE"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions/index.ts"
33
+ ]
34
+ },
35
+ "scripts": {
36
+ "test": "tsx --test tests/**/*.test.ts",
37
+ "lint": "eslint .",
38
+ "typecheck": "tsc --noEmit",
39
+ "check": "npm run typecheck && npm run lint && npm test",
40
+ "prepublishOnly": "npm run check"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "typebox": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-ai": "^0.74.1",
50
+ "@earendil-works/pi-coding-agent": "^0.74.0",
51
+ "@earendil-works/pi-tui": "^0.74.0",
52
+ "@eslint/js": "^10.0.1",
53
+ "@types/node": "^25.7.0",
54
+ "eslint": "^10.3.0",
55
+ "tsx": "^4.21.0",
56
+ "typebox": "^1.1.38",
57
+ "typescript": "^6.0.3",
58
+ "typescript-eslint": "^8.59.3"
59
+ },
60
+ "dependencies": {
61
+ "grammy": "^1.43.0"
62
+ }
63
+ }