@fickydev/pigent 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.24 - 2026-05-19
6
+
7
+ ### Added
8
+
9
+ - Added Telegram working status UX with `sendChatAction` typing heartbeats and an editable `Working…` message while agent runs execute.
10
+
3
11
  ## 0.1.23 - 2026-05-18
4
12
 
5
13
  ### Fixed
package/TODO.md CHANGED
@@ -90,6 +90,8 @@
90
90
  - [ ] Implement `TelegramApi`
91
91
  - [x] `getUpdates`
92
92
  - [x] `sendMessage`
93
+ - [x] `editMessageText`
94
+ - [x] `sendChatAction`
93
95
  - [x] error handling
94
96
  - [x] retry/backoff
95
97
  - [x] `answerCallbackQuery`
@@ -100,6 +102,7 @@
100
102
  - [x] offset tracking
101
103
  - [x] graceful stop
102
104
  - [x] update normalization
105
+ - [x] visible working status with typing indicator and editable status message
103
106
  - [x] Persist Telegram offset in DB
104
107
 
105
108
  ## Routing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
@@ -1,7 +1,9 @@
1
1
  import type {
2
2
  TelegramAnswerCallbackQueryResponse,
3
+ TelegramEditMessageTextResponse,
3
4
  TelegramGetMeResponse,
4
5
  TelegramGetUpdatesResponse,
6
+ TelegramSendChatActionResponse,
5
7
  TelegramSendMessageResponse,
6
8
  TelegramSetMyCommandsResponse,
7
9
  TelegramUpdate,
@@ -68,14 +70,39 @@ export class TelegramApi {
68
70
  text: string;
69
71
  threadId?: string | null;
70
72
  inlineKeyboard?: InlineKeyboardButton[][];
71
- }): Promise<void> {
72
- await this.request<TelegramSendMessageResponse>("sendMessage", {
73
+ }): Promise<number | null> {
74
+ const response = await this.request<TelegramSendMessageResponse>("sendMessage", {
73
75
  chat_id: input.chatId,
74
76
  text: input.text,
75
77
  message_thread_id: input.threadId ? Number(input.threadId) : undefined,
76
78
  disable_web_page_preview: true,
77
79
  reply_markup: input.inlineKeyboard ? toTelegramInlineKeyboard(input.inlineKeyboard) : undefined,
78
80
  });
81
+
82
+ return response.result?.message_id ?? null;
83
+ }
84
+
85
+ async editMessageText(input: {
86
+ chatId: string;
87
+ messageId: number;
88
+ text: string;
89
+ inlineKeyboard?: InlineKeyboardButton[][];
90
+ }): Promise<void> {
91
+ await this.request<TelegramEditMessageTextResponse>("editMessageText", {
92
+ chat_id: input.chatId,
93
+ message_id: input.messageId,
94
+ text: input.text,
95
+ disable_web_page_preview: true,
96
+ reply_markup: input.inlineKeyboard ? toTelegramInlineKeyboard(input.inlineKeyboard) : undefined,
97
+ });
98
+ }
99
+
100
+ async sendChatAction(input: { chatId: string; action: "typing"; threadId?: string | null }): Promise<void> {
101
+ await this.request<TelegramSendChatActionResponse>("sendChatAction", {
102
+ chat_id: input.chatId,
103
+ action: input.action,
104
+ message_thread_id: input.threadId ? Number(input.threadId) : undefined,
105
+ });
79
106
  }
80
107
 
81
108
  async answerCallbackQuery(input: { callbackQueryId: string; text?: string }): Promise<void> {
@@ -1,6 +1,6 @@
1
1
  import type { RuntimeKvRepository } from "../../db/repositories/RuntimeKvRepository";
2
2
  import { logger } from "../../logging/logger";
3
- import type { ChannelAdapter, InboundMessage, MessageHandler, OutboundMessage } from "../types";
3
+ import type { ChannelAdapter, InboundMessage, MessageHandler, OutboundMessage, RunStatusHandle } from "../types";
4
4
  import { TelegramApi, type TelegramBotCommand } from "./TelegramApi";
5
5
  import type { TelegramCallbackQuery, TelegramMessage, TelegramUpdate } from "./types";
6
6
 
@@ -63,6 +63,14 @@ export class TelegramPollingAdapter implements ChannelAdapter {
63
63
  });
64
64
  }
65
65
 
66
+ createRunStatus(message: OutboundMessage): RunStatusHandle {
67
+ if (message.channel !== "telegram") {
68
+ throw new Error(`unsupported channel for telegram adapter: ${message.channel}`);
69
+ }
70
+
71
+ return new TelegramRunStatusHandle(this.options.api, message);
72
+ }
73
+
66
74
  private async poll(handler: MessageHandler): Promise<void> {
67
75
  while (this.running) {
68
76
  try {
@@ -161,6 +169,84 @@ function normalizeTelegramCallbackQuery(updateId: number, callbackQuery: Telegra
161
169
  };
162
170
  }
163
171
 
172
+ class TelegramRunStatusHandle implements RunStatusHandle {
173
+ private messageId: number | null = null;
174
+ private typingTimer: ReturnType<typeof setInterval> | null = null;
175
+
176
+ constructor(
177
+ private readonly api: TelegramApi,
178
+ private readonly message: OutboundMessage,
179
+ ) {}
180
+
181
+ async start(): Promise<void> {
182
+ await this.sendTyping();
183
+ this.messageId = await this.api.sendMessage({
184
+ chatId: this.message.chatId,
185
+ threadId: this.message.threadId,
186
+ text: this.message.text,
187
+ });
188
+
189
+ this.typingTimer = setInterval(() => {
190
+ void this.sendTyping();
191
+ }, 4000);
192
+ }
193
+
194
+ async complete(message: OutboundMessage): Promise<void> {
195
+ await this.stopAndPublish(message);
196
+ }
197
+
198
+ async fail(message: OutboundMessage): Promise<void> {
199
+ await this.stopAndPublish(message);
200
+ }
201
+
202
+ private async stopAndPublish(message: OutboundMessage): Promise<void> {
203
+ this.stopTyping();
204
+
205
+ if (this.messageId) {
206
+ try {
207
+ await this.api.editMessageText({
208
+ chatId: message.chatId,
209
+ messageId: this.messageId,
210
+ text: message.text,
211
+ inlineKeyboard: message.inlineKeyboard,
212
+ });
213
+ return;
214
+ } catch (error) {
215
+ logger.warn("telegram status edit failed; sending final message", {
216
+ error: error instanceof Error ? error.message : String(error),
217
+ });
218
+ }
219
+ }
220
+
221
+ await this.api.sendMessage({
222
+ chatId: message.chatId,
223
+ threadId: message.threadId,
224
+ text: message.text,
225
+ inlineKeyboard: message.inlineKeyboard,
226
+ });
227
+ }
228
+
229
+ private async sendTyping(): Promise<void> {
230
+ try {
231
+ await this.api.sendChatAction({
232
+ chatId: this.message.chatId,
233
+ threadId: this.message.threadId,
234
+ action: "typing",
235
+ });
236
+ } catch (error) {
237
+ logger.debug("telegram typing indicator failed", {
238
+ error: error instanceof Error ? error.message : String(error),
239
+ });
240
+ }
241
+ }
242
+
243
+ private stopTyping(): void {
244
+ if (!this.typingTimer) return;
245
+ clearInterval(this.typingTimer);
246
+ this.typingTimer = null;
247
+ }
248
+ }
249
+
164
250
  function sleep(ms: number): Promise<void> {
165
251
  return new Promise((resolve) => setTimeout(resolve, ms));
166
252
  }
@@ -63,6 +63,18 @@ export type TelegramAnswerCallbackQueryResponse = {
63
63
  description?: string;
64
64
  };
65
65
 
66
+ export type TelegramEditMessageTextResponse = {
67
+ ok: boolean;
68
+ result?: TelegramMessage | boolean;
69
+ description?: string;
70
+ };
71
+
72
+ export type TelegramSendChatActionResponse = {
73
+ ok: boolean;
74
+ result: boolean;
75
+ description?: string;
76
+ };
77
+
66
78
  export type TelegramSetMyCommandsResponse = {
67
79
  ok: boolean;
68
80
  result: boolean;
@@ -30,9 +30,16 @@ export type OutboundMessage = {
30
30
 
31
31
  export type MessageHandler = (message: InboundMessage) => Promise<void>;
32
32
 
33
+ export interface RunStatusHandle {
34
+ start(): Promise<void>;
35
+ complete(message: OutboundMessage): Promise<void>;
36
+ fail(message: OutboundMessage): Promise<void>;
37
+ }
38
+
33
39
  export interface ChannelAdapter {
34
40
  readonly id: ChannelId;
35
41
  start(handler: MessageHandler): Promise<void>;
36
42
  stop(): Promise<void>;
37
43
  send(message: OutboundMessage): Promise<void>;
44
+ createRunStatus?(message: OutboundMessage): RunStatusHandle;
38
45
  }
@@ -117,18 +117,54 @@ export class AgentDaemon {
117
117
  return;
118
118
  }
119
119
 
120
- const response = await this.runner.run({
121
- agentId: route.agentId,
122
- text: route.text,
123
- message,
124
- });
125
-
126
- await this.send({
120
+ const status = this.createRunStatus({
127
121
  channel: message.channel,
128
122
  chatId: message.chatId,
129
123
  threadId: message.threadId,
130
- text: response,
124
+ text: "Working…",
131
125
  });
126
+
127
+ await status?.start();
128
+
129
+ try {
130
+ const response = await this.runner.run({
131
+ agentId: route.agentId,
132
+ text: route.text,
133
+ message,
134
+ });
135
+
136
+ const outbound = {
137
+ channel: message.channel,
138
+ chatId: message.chatId,
139
+ threadId: message.threadId,
140
+ text: response,
141
+ };
142
+
143
+ if (status) {
144
+ await status.complete(outbound);
145
+ } else {
146
+ await this.send(outbound);
147
+ }
148
+ } catch (error) {
149
+ const failure = {
150
+ channel: message.channel,
151
+ chatId: message.chatId,
152
+ threadId: message.threadId,
153
+ text: "Agent failed to respond. Please try again later.",
154
+ };
155
+
156
+ if (status) {
157
+ await status.fail(failure);
158
+ } else {
159
+ await this.send(failure);
160
+ }
161
+
162
+ logger.error("agent run failed", {
163
+ channel: message.channel,
164
+ chatId: message.chatId,
165
+ error: error instanceof Error ? error.message : String(error),
166
+ });
167
+ }
132
168
  }
133
169
 
134
170
  private async autoConfigureChat(message: InboundMessage): Promise<void> {
@@ -142,13 +178,25 @@ export class AgentDaemon {
142
178
  }
143
179
 
144
180
  private async send(message: OutboundMessage): Promise<void> {
145
- const adapter = this.adapters.find((candidate) => candidate.id === message.channel);
181
+ const adapter = this.findAdapter(message.channel);
182
+ if (!adapter) return;
183
+
184
+ await adapter.send(message);
185
+ }
186
+
187
+ private createRunStatus(message: OutboundMessage) {
188
+ const adapter = this.findAdapter(message.channel);
189
+ return adapter?.createRunStatus?.(message) ?? null;
190
+ }
191
+
192
+ private findAdapter(channel: OutboundMessage["channel"]): ChannelAdapter | null {
193
+ const adapter = this.adapters.find((candidate) => candidate.id === channel);
146
194
  if (!adapter) {
147
- logger.warn("no adapter available for outbound message", { channel: message.channel });
148
- return;
195
+ logger.warn("no adapter available for outbound message", { channel });
196
+ return null;
149
197
  }
150
198
 
151
- await adapter.send(message);
199
+ return adapter;
152
200
  }
153
201
  }
154
202