pi-telegram-plus 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,83 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { CommandRegistry } from "./register.ts";
3
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
3
  import { escapeHtml } from "../html.ts";
5
- import type { CapturedAgentSession } from "../types.ts";
4
+ import type { CapturedAgentSession, TelegramTransport } from "../types.ts";
5
+
6
+ function formatFooterLikeTokenCount(value: number): string {
7
+ if (value < 1_000) return value.toString();
8
+ if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`;
9
+ if (value < 1_000_000) return `${Math.round(value / 1_000)}k`;
10
+ if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
11
+ return `${Math.round(value / 1_000_000)}M`;
12
+ }
13
+
14
+ const STATUS_TEXT_LIMIT = 200;
15
+
16
+ function truncateStatusText(value: string | undefined): string | undefined {
17
+ if (value === undefined) return undefined;
18
+ if (value.length <= STATUS_TEXT_LIMIT) return value;
19
+ return `${value.slice(0, STATUS_TEXT_LIMIT - 1)}…`;
20
+ }
21
+
22
+ /**
23
+ * @internal Exported for tests; not part of the public module API.
24
+ * Keeps the rendered snapshot under Telegram's 4096-byte (and `splitTelegramText`'s
25
+ * 3600-byte single-chunk) limit by capping free-form text fields up front.
26
+ */
27
+ export function buildStatusSnapshot(session: CapturedAgentSession): string {
28
+ const stats = session.getSessionStats();
29
+ const usage = session.getContextUsage();
30
+ const model = session.model;
31
+ const safeCwd = truncateStatusText(session.sessionManager.getCwd()) ?? "";
32
+ const safeSessionId = truncateStatusText(stats.sessionId) ?? "";
33
+ const safeSessionFile = truncateStatusText(stats.sessionFile);
34
+ const safeSessionName = truncateStatusText(session.sessionManager.getSessionName());
35
+ const contextWindow = usage?.contextWindow ?? 0;
36
+ const contextPercent = usage?.percent;
37
+
38
+ const stateBadge = session.isStreaming
39
+ ? "🟢 active"
40
+ : session.pendingMessageCount > 0
41
+ ? "🟡 queueing"
42
+ : "⚪ idle";
43
+
44
+ const contextDisplay = contextPercent === null || contextPercent === undefined
45
+ ? `?/${contextWindow}`
46
+ : `${contextPercent.toFixed(1)}%/${contextWindow}`;
47
+
48
+ // Labels are hard-coded, so escaping is unnecessary; only the value is dynamic.
49
+ const line = (label: string, value: string): string =>
50
+ ` <b>${label}</b> ${escapeHtml(value)}`;
51
+
52
+ return [
53
+ "<b>🛰 TUI Status</b>",
54
+ "━━━━━━━━━━━━━━━━━━━━",
55
+ "<b>📂 Workspace</b>",
56
+ line("cwd", safeCwd),
57
+ line("session", safeSessionId + (safeSessionName ? ` • ${safeSessionName}` : "")),
58
+ line("file", safeSessionFile ?? "ephemeral"),
59
+ "",
60
+ "<b>🤖 Model</b>",
61
+ line("model", model ? `${model.provider}/${model.id}` : "(none)"),
62
+ line("thinking", session.thinkingLevel),
63
+ line("state", stateBadge),
64
+ line("queued", String(session.pendingMessageCount)),
65
+ "",
66
+ "<b>📊 Context &amp; Tokens</b>",
67
+ line("context", contextDisplay),
68
+ line("in", formatFooterLikeTokenCount(stats.tokens.input)),
69
+ line("out", formatFooterLikeTokenCount(stats.tokens.output)),
70
+ line("cache R", formatFooterLikeTokenCount(stats.tokens.cacheRead)),
71
+ line("cache W", formatFooterLikeTokenCount(stats.tokens.cacheWrite)),
72
+ line("total", formatFooterLikeTokenCount(stats.tokens.total)),
73
+ line("cost", `$${stats.cost.toFixed(4)}`),
74
+ "",
75
+ "<b>💬 Messages</b>",
76
+ line("user", String(stats.userMessages)),
77
+ line("assistant", String(stats.assistantMessages)),
78
+ line("tool calls", String(stats.toolCalls)),
79
+ ].join("\n");
80
+ }
6
81
 
7
82
  /**
8
83
  * All command handlers capture ctx.ui at entry and use the captured reference.
@@ -10,7 +85,13 @@ import type { CapturedAgentSession } from "../types.ts";
10
85
  */
11
86
  export function registerInfoCommands(
12
87
  registry: CommandRegistry,
13
- deps: { getSession: () => CapturedAgentSession | undefined },
88
+ deps: {
89
+ getSession: () => CapturedAgentSession | undefined;
90
+ /** Optional transport for direct sends (bypasses ui.notify wrapping). */
91
+ getTransport?: () => TelegramTransport | undefined;
92
+ /** Optional active chat id used for direct sends. */
93
+ getActiveChatId?: () => number | undefined;
94
+ },
14
95
  ): void {
15
96
  // ── /copy ──────────────────────────────────────────────────────────────
16
97
  registry.registerCommand("copy", {
@@ -27,6 +108,31 @@ export function registerInfoCommands(
27
108
  },
28
109
  });
29
110
 
111
+ // ── /status ───────────────────────────────────────────────────────────
112
+ registry.registerCommand("status", {
113
+ description: "Show runtime snapshot (workspace, model, context, messages)",
114
+ handler: async (_args, ctx) => {
115
+ const ui = ctx.ui;
116
+ const session = deps.getSession();
117
+ if (!session) {
118
+ ui.notify("No active session", "error");
119
+ return;
120
+ }
121
+
122
+ const html = buildStatusSnapshot(session);
123
+ const transport = deps.getTransport?.();
124
+ const chatId = deps.getActiveChatId?.();
125
+ if (transport && chatId !== undefined) {
126
+ await transport.sendText(chatId, html).catch(() => {
127
+ // Fall back to the standard notify path if the direct send fails.
128
+ ui.notify(html.replace(/<[^>]+>/g, ""), "info");
129
+ });
130
+ return;
131
+ }
132
+ ui.notify(html, "info");
133
+ },
134
+ });
135
+
30
136
  // ── /export ────────────────────────────────────────────────────────────
31
137
  registry.registerCommand("export", {
32
138
  description: "Export session to HTML or JSONL",
@@ -1,4 +1,4 @@
1
- import type { CapturedAgentSession } from "../types.ts";
1
+ import type { CapturedAgentSession, TelegramTransport } from "../types.ts";
2
2
  import { registerModelCommands } from "./model.ts";
3
3
  import { registerSessionCommands } from "./session.ts";
4
4
  import { registerAuthCommands } from "./auth.ts";
@@ -29,12 +29,24 @@ export type TgConfigDeps = SessionDeps & {
29
29
  persistConfig: (c: import("../types.ts").TelegramConfig) => Promise<void>;
30
30
  };
31
31
 
32
- export function registerAllCommands(registry: CommandRegistry, sessionDeps: SessionDeps, sessionNameDeps: SessionNameDeps, tgConfigDeps?: TgConfigDeps): void {
32
+ /** Optional deps for commands that need to send preformatted HTML directly. */
33
+ export type InfoDeps = {
34
+ getTransport?: () => TelegramTransport | undefined;
35
+ getActiveChatId?: () => number | undefined;
36
+ };
37
+
38
+ export function registerAllCommands(
39
+ registry: CommandRegistry,
40
+ sessionDeps: SessionDeps,
41
+ sessionNameDeps: SessionNameDeps,
42
+ tgConfigDeps?: TgConfigDeps,
43
+ infoDeps?: InfoDeps,
44
+ ): void {
33
45
  registerSettingsCommands(registry, sessionDeps);
34
46
  registerModelCommands(registry, sessionDeps);
35
47
  registerSessionCommands(registry, sessionNameDeps);
36
48
  registerAuthCommands(registry, sessionDeps);
37
- registerInfoCommands(registry, sessionDeps);
49
+ registerInfoCommands(registry, { ...sessionDeps, ...(infoDeps ?? {}) });
38
50
  registerLifecycleCommands(registry);
39
51
  if (tgConfigDeps) registerTgConfigCommands(registry, tgConfigDeps);
40
52
  }
@@ -5,6 +5,9 @@ import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-age
5
5
  import type { SessionNameDeps, CommandRegistry } from "./register.ts";
6
6
  import { escapeHtml } from "../html.ts";
7
7
  import type { CapturedAgentSession } from "../types.ts";
8
+ import { log } from "../logger.ts";
9
+
10
+ const sessionLog = log.child("session");
8
11
 
9
12
  function truncate(text: string, max: number): string {
10
13
  return text.length <= max ? text : text.slice(0, max - 1) + "…";
@@ -187,7 +190,7 @@ export function registerSessionCommands(
187
190
  if (!rawPath) return;
188
191
 
189
192
  const targetCwd = resolveTargetCwd(rawPath, ctx.cwd);
190
- const info = await stat(targetCwd).catch(() => undefined);
193
+ const info = await stat(targetCwd).catch(sessionLog.swallow("debug", "stat target cwd failed (treated as not-a-directory)", { targetCwd }));
191
194
  if (!info?.isDirectory()) {
192
195
  ui.notify(`Not a directory: ${escapeHtml(targetCwd)}`, "error");
193
196
  return;
@@ -1,9 +1,12 @@
1
1
  import { resolve } from "node:path";
2
- import { bindWorkspaceTelegramConfig, readTelegramConfigStore, unbindWorkspaceTelegramConfig } from "../config.ts";
2
+ import { bindWorkspaceTelegramConfig, readTelegramConfigStore, unbindWorkspaceTelegramConfig, writeGlobalTelegramConfig } from "../config.ts";
3
3
  import { escapeHtml } from "../html.ts";
4
4
  import { getTelegramBotUsername } from "../telegram-api.ts";
5
5
  import type { ResolvedTelegramConfig, TelegramConfig, TelegramTransport } from "../types.ts";
6
6
  import type { TelegramPollingRuntime } from "../polling.ts";
7
+ import { log } from "../logger.ts";
8
+
9
+ const tgCmdLog = log.child("tg-commands");
7
10
 
8
11
  export type TelegramCommandDeps = {
9
12
  getConfig: () => TelegramConfig;
@@ -20,82 +23,103 @@ export type TelegramCommandDeps = {
20
23
  clearStatusError: () => void;
21
24
  };
22
25
 
23
- /** Shared "connect and start" sequence used by both /tg-setup and /tg-connect. */
24
- async function connectAndStart(
26
+ /** Shared "connect and start" sequence for the global bot. */
27
+ async function globalConnectAndStart(
25
28
  deps: TelegramCommandDeps,
26
29
  token: string,
27
30
  botUsername: string | undefined,
28
31
  ): Promise<void> {
29
32
  const config = deps.getConfig();
30
33
  deps.setConfig({ ...config, botToken: token, botUsername, telegramEnabled: true });
31
- await deps.persistConfig(deps.getConfig());
34
+ await writeGlobalTelegramConfig({ botToken: token, botUsername, telegramEnabled: true });
32
35
  deps.getPolling().start();
33
36
  await deps.syncTelegramCommands();
34
37
  deps.refreshStatus();
35
38
  }
36
39
 
37
- export function configureTelegramToken(
40
+ async function configureGlobalTelegramToken(
38
41
  ui: { input: (title: string, placeholder?: string) => Promise<string | undefined>; inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> },
39
42
  deps: TelegramCommandDeps,
40
43
  ): Promise<boolean> {
41
- return (async () => {
42
- const token = await (ui.inputSecret?.("Telegram bot token") ?? ui.input("Telegram bot token"));
43
- if (!token) return false;
44
- const botUsername = await getTelegramBotUsername(token).catch(() => undefined);
45
- if (!deps.getResolvedConfig()) {
46
- deps.switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
44
+ const token = await (ui.inputSecret?.("Telegram bot token") ?? ui.input("Telegram bot token"));
45
+ if (!token) return false;
46
+ const botUsername = await getTelegramBotUsername(token).catch(tgCmdLog.swallow("warn", "getTelegramBotUsername failed during global token setup"));
47
+ if (!deps.getResolvedConfig()) {
48
+ deps.switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
49
+ }
50
+ await globalConnectAndStart(deps, token, botUsername);
51
+ return true;
52
+ }
53
+
54
+ // ── Handler implementations for reuse by aliases ──────────────────────────
55
+
56
+ async function handleTgGlobalSetup(
57
+ _args: string,
58
+ ctx: any,
59
+ deps: TelegramCommandDeps,
60
+ ): Promise<void> {
61
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
62
+ if (!(await configureGlobalTelegramToken(ui, deps))) return;
63
+ deps.clearStatusError();
64
+ deps.startStatusHeartbeat();
65
+ ui.notify("Telegram global bot token saved and connected.", "info");
66
+ }
67
+
68
+ async function handleTgGlobalConnect(
69
+ _args: string,
70
+ ctx: any,
71
+ deps: TelegramCommandDeps,
72
+ ): Promise<void> {
73
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
74
+ try {
75
+ if (!deps.getConfig().botToken) {
76
+ if (!(await configureGlobalTelegramToken(ui, deps))) return;
77
+ } else {
78
+ const botUsername = deps.getConfig().botUsername ?? await getTelegramBotUsername(deps.getConfig().botToken!).catch(tgCmdLog.swallow("warn", "getTelegramBotUsername failed during global connect"));
79
+ await globalConnectAndStart(deps, deps.getConfig().botToken!, botUsername);
47
80
  }
48
- await connectAndStart(deps, token, botUsername);
49
- return true;
50
- })();
81
+ } catch (err) { tgCmdLog.debug("global connect swallowed error (reported via polling onError)", { err }); }
82
+ deps.clearStatusError();
83
+ deps.startStatusHeartbeat();
84
+ ui.notify("Telegram global bot connected.", "info");
85
+ }
86
+
87
+ async function handleTgGlobalDisconnect(
88
+ _args: string,
89
+ ctx: any,
90
+ deps: TelegramCommandDeps,
91
+ ): Promise<void> {
92
+ await deps.getPolling().stop();
93
+ const config = deps.getConfig();
94
+ deps.setConfig({ ...config, telegramEnabled: false });
95
+ await writeGlobalTelegramConfig({ telegramEnabled: false });
96
+ deps.clearStatusError();
97
+ deps.refreshStatus();
98
+ ctx.ui.notify("Telegram global bot disconnected. Token is kept; use /tg-global-connect to reconnect.", "info");
51
99
  }
52
100
 
101
+ // ── Registration ──────────────────────────────────────────────────────────
102
+
53
103
  export function registerTelegramCommands(
54
104
  registry: { registerCommand: (name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }) => void },
55
105
  deps: TelegramCommandDeps,
56
106
  ): void {
57
- // ── /tg-setup ─────────────────────────────────────────────────────────
58
- registry.registerCommand("tg-setup", {
59
- description: "Configure Telegram bot token",
60
- handler: async (_args, ctx) => {
61
- const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
62
- if (!(await configureTelegramToken(ui, deps))) return;
63
- deps.clearStatusError();
64
- deps.startStatusHeartbeat();
65
- ui.notify("Telegram bot token saved and connected.", "info");
66
- },
107
+ // ── /tg-global-setup ──────────────────────────────────────────────────
108
+ registry.registerCommand("tg-global-setup", {
109
+ description: "Configure global Telegram bot token and connect",
110
+ handler: (args, ctx) => handleTgGlobalSetup(args, ctx, deps),
67
111
  });
68
112
 
69
- // ── /tg-connect ───────────────────────────────────────────────────────
70
- registry.registerCommand("tg-connect", {
71
- description: "Enable/start Telegram connection",
72
- handler: async (_args, ctx) => {
73
- const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
74
- try {
75
- if (!deps.getConfig().botToken) {
76
- if (!(await configureTelegramToken(ui, deps))) return;
77
- } else {
78
- const botUsername = deps.getConfig().botUsername ?? await getTelegramBotUsername(deps.getConfig().botToken!).catch(() => undefined);
79
- await connectAndStart(deps, deps.getConfig().botToken!, botUsername);
80
- }
81
- } catch { /* connection errors reported via polling onError */ }
82
- deps.clearStatusError();
83
- deps.startStatusHeartbeat();
84
- ui.notify("Telegram connected.", "info");
85
- },
113
+ // ── /tg-global-connect ────────────────────────────────────────────────
114
+ registry.registerCommand("tg-global-connect", {
115
+ description: "Enable/start the global Telegram bot connection",
116
+ handler: (args, ctx) => handleTgGlobalConnect(args, ctx, deps),
86
117
  });
87
118
 
88
- // ── /tg-disconnect ────────────────────────────────────────────────────
89
- registry.registerCommand("tg-disconnect", {
90
- description: "Disable/stop Telegram connection without deleting the token",
91
- handler: async (_args, ctx) => {
92
- await deps.getPolling().stop();
93
- deps.setConfig({ ...deps.getConfig(), telegramEnabled: false });
94
- await deps.persistConfig(deps.getConfig());
95
- deps.clearStatusError();
96
- deps.refreshStatus();
97
- ctx.ui.notify("Telegram disconnected. Token is kept; use /tg-connect to reconnect.", "info");
98
- },
119
+ // ── /tg-global-disconnect ─────────────────────────────────────────────
120
+ registry.registerCommand("tg-global-disconnect", {
121
+ description: "Disable/stop the global Telegram bot without deleting the token",
122
+ handler: (args, ctx) => handleTgGlobalDisconnect(args, ctx, deps),
99
123
  });
100
124
 
101
125
  // ── /tg-bind-cwd ──────────────────────────────────────────────────────
@@ -107,7 +131,7 @@ export function registerTelegramCommands(
107
131
  const workspacePath = resolve(args.trim() || ctx.cwd || process.cwd());
108
132
  const token = await (ui.inputSecret?.(`Telegram bot token for ${workspacePath}`) ?? ui.input(`Telegram bot token for ${workspacePath}`));
109
133
  if (!token) return;
110
- const botUsername = await getTelegramBotUsername(token).catch(() => undefined);
134
+ const botUsername = await getTelegramBotUsername(token).catch(tgCmdLog.swallow("warn", "getTelegramBotUsername failed during workspace token setup", { workspacePath }));
111
135
  await deps.getPolling().stop();
112
136
  deps.switchResolvedConfig(await bindWorkspaceTelegramConfig(workspacePath, {
113
137
  botToken: token,
@@ -125,6 +149,57 @@ export function registerTelegramCommands(
125
149
  },
126
150
  });
127
151
 
152
+ // ── /tg-cwd-connect ───────────────────────────────────────────────────
153
+ registry.registerCommand("tg-cwd-connect", {
154
+ description: "Enable the Telegram bot for the current directory",
155
+ handler: async (_args, ctx) => {
156
+ const previousScope = deps.getResolvedConfig()?.scope;
157
+ const config = deps.getConfig();
158
+ // If no bot token is configured at all, prompt user to bind or set up global first
159
+ if (!config.botToken && !deps.getResolvedConfig()?.store.global?.botToken) {
160
+ ctx.ui.notify("No Telegram bot configured. Use /tg-global-setup or /tg-bind-cwd first.", "error");
161
+ return;
162
+ }
163
+ // If no token in current scope, try falling back to global
164
+ if (!config.botToken) {
165
+ const global = deps.getResolvedConfig()?.store.global;
166
+ if (global?.botToken) {
167
+ deps.setConfig({ ...config, botToken: global.botToken, botUsername: global.botUsername });
168
+ }
169
+ }
170
+ deps.setConfig({ ...deps.getConfig(), telegramEnabled: true });
171
+ await deps.persistConfig(deps.getConfig());
172
+ await deps.getPolling().stop();
173
+ if (deps.isTelegramEnabled()) deps.getPolling().start();
174
+ deps.clearStatusError();
175
+ deps.startStatusHeartbeat();
176
+ ctx.ui.notify(`Telegram bot enabled for current scope (${previousScope}).`, "info");
177
+ },
178
+ });
179
+
180
+ // ── /tg-cwd-disconnect ────────────────────────────────────────────────
181
+ registry.registerCommand("tg-cwd-disconnect", {
182
+ description: "Disable the Telegram bot for the current directory",
183
+ handler: async (_args, ctx) => {
184
+ const previousScope = deps.getResolvedConfig()?.scope;
185
+ await deps.getPolling().stop();
186
+ deps.setConfig({ ...deps.getConfig(), telegramEnabled: false });
187
+ await deps.persistConfig(deps.getConfig());
188
+ // If there's a global bot token, switch to it when disabling workspace bot
189
+ if (previousScope === "workspace") {
190
+ const global = deps.getResolvedConfig()?.store.global;
191
+ if (global?.botToken && global?.telegramEnabled !== false) {
192
+ deps.setConfig({ ...deps.getConfig(), botToken: global.botToken, botUsername: global.botUsername, telegramEnabled: true });
193
+ await deps.persistConfig(deps.getConfig());
194
+ deps.getPolling().start();
195
+ }
196
+ }
197
+ deps.clearStatusError();
198
+ deps.refreshStatus();
199
+ ctx.ui.notify(`Telegram bot disabled for current scope (${previousScope}).`, "info");
200
+ },
201
+ });
202
+
128
203
  // ── /tg-unbind-cwd ────────────────────────────────────────────────────
129
204
  registry.registerCommand("tg-unbind-cwd", {
130
205
  description: "Remove current directory Telegram bot binding",
@@ -135,7 +210,14 @@ export function registerTelegramCommands(
135
210
  return;
136
211
  }
137
212
  await deps.getPolling().stop();
213
+ const global = previous.store.global;
138
214
  deps.switchResolvedConfig(await unbindWorkspaceTelegramConfig(ctx.cwd || process.cwd()));
215
+ // Fall back to global bot if it exists and is enabled
216
+ if (global?.botToken && global?.telegramEnabled !== false) {
217
+ const newConfig = deps.getConfig();
218
+ deps.setConfig({ ...newConfig, botToken: global.botToken, botUsername: global.botUsername });
219
+ await deps.persistConfig(deps.getConfig());
220
+ }
139
221
  if (deps.isTelegramEnabled()) deps.getPolling().start();
140
222
  await deps.syncTelegramCommands();
141
223
  deps.refreshStatus();
@@ -149,11 +231,14 @@ export function registerTelegramCommands(
149
231
  handler: async (_args, ctx) => {
150
232
  const store = await readTelegramConfigStore();
151
233
  const lines = [
152
- `global: ${store.global?.botUsername ? `@${store.global.botUsername}` : store.global?.botToken ? "configured" : "not configured"}`,
234
+ `global: ${store.global?.botUsername ? `@${store.global.botUsername}` : store.global?.botToken ? "configured" : "not configured"}${store.global?.telegramEnabled === false ? " (disabled)" : store.global?.telegramEnabled ? "" : ""}`,
153
235
  "",
154
236
  "workspaces:",
155
237
  ...((store.workspaces ?? []).length
156
- ? (store.workspaces ?? []).map((workspace) => `- ${escapeHtml(workspace.path)}\n ${workspace.config.botUsername ? `@${workspace.config.botUsername}` : workspace.config.botToken ? "configured" : "not configured"}`)
238
+ ? (store.workspaces ?? []).map((workspace) => {
239
+ const status = workspace.config.telegramEnabled === false ? " (disabled)" : "";
240
+ return `- ${escapeHtml(workspace.path)}\n ${workspace.config.botUsername ? `@${workspace.config.botUsername}${status}` : workspace.config.botToken ? `configured${status}` : "not configured"}`;
241
+ })
157
242
  : ["none"]),
158
243
  ];
159
244
  ctx.ui.notify(lines.join("\n"), "info");
package/lib/config.ts CHANGED
@@ -3,6 +3,9 @@ import { chmod, mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/prom
3
3
  import { homedir } from "node:os";
4
4
  import { join, relative, resolve } from "node:path";
5
5
  import type { ResolvedTelegramConfig, TelegramConfig, TelegramConfigStore, TelegramWorkspaceConfig } from "./types.ts";
6
+ import { log } from "./logger.ts";
7
+
8
+ const configLog = log.child("config");
6
9
 
7
10
  export function getAgentDir(): string {
8
11
  return process.env.PI_CODING_AGENT_DIR
@@ -33,7 +36,7 @@ async function withTelegramConfigLock<T>(run: () => Promise<T>): Promise<T> {
33
36
  if (code !== "EEXIST") throw error;
34
37
  const age = Date.now() - (await stat(lockPath).then((s) => s.mtimeMs).catch(() => Date.now()));
35
38
  if (age > 30_000 || Date.now() - started > 10_000) {
36
- await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
39
+ await rm(lockPath, { recursive: true, force: true }).catch(configLog.swallow("warn", "rm stale config lock failed", { lockPath }));
37
40
  continue;
38
41
  }
39
42
  await sleep(50);
@@ -42,13 +45,13 @@ async function withTelegramConfigLock<T>(run: () => Promise<T>): Promise<T> {
42
45
  try {
43
46
  return await run();
44
47
  } finally {
45
- await rmdir(lockPath).catch(() => undefined);
48
+ await rmdir(lockPath).catch(configLog.swallow("warn", "rmdir config lock failed on release", { lockPath }));
46
49
  }
47
50
  }
48
51
 
49
52
  function assertV2Store(value: unknown): TelegramConfigStore {
50
53
  if (!value || typeof value !== "object" || (value as { version?: unknown }).version !== 2) {
51
- throw new Error("Unsupported Telegram config format. Please recreate ~/.pi/agent/tg.json as version 2 or run /tg-setup.");
54
+ throw new Error("Unsupported Telegram config format. Please recreate ~/.pi/agent/tg.json as version 2 or run /tg-global-setup.");
52
55
  }
53
56
  const store = value as TelegramConfigStore;
54
57
  return {
@@ -73,7 +76,7 @@ export async function writeTelegramConfigStore(store: TelegramConfigStore): Prom
73
76
  workspaces: store.workspaces ?? [],
74
77
  };
75
78
  await writeFile(path, JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
76
- await chmod(path, 0o600).catch(() => undefined);
79
+ await chmod(path, 0o600).catch(configLog.swallow("warn", "chmod config file failed", { path }));
77
80
  }
78
81
 
79
82
  function normalizePath(path: string): string {
@@ -169,3 +172,15 @@ export async function unbindWorkspaceTelegramConfig(cwd: string): Promise<Resolv
169
172
  return resolveTelegramConfigStore(store, cwd);
170
173
  });
171
174
  }
175
+
176
+ /**
177
+ * Update only the global section of the Telegram config store.
178
+ * Merges with existing global config and preserves lastUpdateId monotonically.
179
+ */
180
+ export async function writeGlobalTelegramConfig(config: TelegramConfig): Promise<void> {
181
+ await withTelegramConfigLock(async () => {
182
+ const store = await readTelegramConfigStore();
183
+ store.global = mergeTelegramConfigForWrite(store.global, config);
184
+ await writeTelegramConfigStore(store);
185
+ });
186
+ }
package/lib/controller.ts CHANGED
@@ -12,6 +12,9 @@ import type {
12
12
  TelegramTurn,
13
13
  } from "./types.ts";
14
14
  import type { TelegramUiRuntime } from "./telegram-ui.ts";
15
+ import { log } from "./logger.ts";
16
+
17
+ const ctrlLog = log.child("controller");
15
18
 
16
19
  export type TelegramController = {
17
20
  handleMessage(message: TelegramMessage): Promise<void>;
@@ -221,7 +224,7 @@ export function createTelegramController(deps: {
221
224
  const mode = deps.getMessageMode();
222
225
  if (mode === "steer") {
223
226
  const task = runPrompt(text, chatId, replaceMessageId);
224
- void task.catch(() => undefined);
227
+ void task.catch(ctrlLog.swallow("error", "steer-mode prompt task failed", { chatId }));
225
228
  return;
226
229
  }
227
230
 
@@ -233,7 +236,7 @@ export function createTelegramController(deps: {
233
236
  if (generation !== getInterruptGeneration(chatId)) return;
234
237
  return runPrompt(text, chatId, replaceMessageId);
235
238
  })
236
- .catch(() => undefined);
239
+ .catch(ctrlLog.swallow("error", "queue-mode prompt task failed", { chatId }));
237
240
  setTail(chatId, task);
238
241
  };
239
242
 
@@ -247,11 +250,45 @@ export function createTelegramController(deps: {
247
250
  // prompts (e.g. /new confirmation), and awaiting them would stall the
248
251
  // polling loop and block callback processing for that update.
249
252
  const telegramUi = deps.ui.create(chatId);
253
+ const ctx = session.extensionRunner.createCommandContext();
254
+ // Capture idleness BEFORE the handler runs. Commands like /sisyphus and
255
+ // /goals enqueue the agent turn fire-and-forget via pi.sendUserMessage and
256
+ // return immediately; the actual turn (and any goal_question /
257
+ // propose_goal_draft / goal_questionnaire dialogs it raises) runs AFTER the
258
+ // handler resolves. If we let runWithTelegramUi restore the TUI UI at that
259
+ // point, those dialogs render to the local TUI and Telegram gets nothing.
260
+ // Only hold the swap when the agent was idle when the command arrived: if a
261
+ // local turn was already streaming, holding the swap would hijack the local
262
+ // user's TUI (modals would route to Telegram, editor would no-op).
263
+ const idleFn = typeof (ctx as any).isIdle === "function" ? (ctx as any).isIdle : undefined;
264
+ const waitFn = typeof (ctx as any).waitForIdle === "function" ? (ctx as any).waitForIdle : undefined;
265
+ const wasIdle = idleFn ? idleFn.call(ctx) : false;
250
266
  void runWithTelegramUi({
251
267
  session,
252
268
  ui: telegramUi,
253
- run: async () => handler(args, session.extensionRunner.createCommandContext()),
254
- }).catch(() => undefined);
269
+ run: async () => {
270
+ await handler(args, ctx);
271
+ if (!wasIdle || !idleFn || !waitFn) return;
272
+ // Hold the Telegram UI swap across the command's enqueued turn and any
273
+ // auto-continue chain it spawns. pi-goal schedules continuation turns via
274
+ // setTimeout(0 / 50ms) after a turn ends, so a single waitForIdle()
275
+ // resolves before the next turn starts. After each idle, yield one
276
+ // macrotask window (120ms > pi-goal's 50ms CONTINUATION_IDLE_RETRY_MS) to
277
+ // let a pending continuation begin; if the agent starts streaming again,
278
+ // keep waiting. Stop when the agent stays idle through the window — the
279
+ // chain has drained and the local TUI is fully usable again.
280
+ try {
281
+ for (;;) {
282
+ await waitFn.call(ctx);
283
+ await new Promise((r) => setTimeout(r, 120));
284
+ if (idleFn.call(ctx)) break;
285
+ }
286
+ } catch (err) {
287
+ // Session disposed / torn down during the wait — nothing to do but log.
288
+ ctrlLog.debug("waitForIdle during enqueued turn interrupted", { err });
289
+ }
290
+ },
291
+ }).catch(ctrlLog.swallow("warn", "beginTelegramTurn enqueued-turn wrapper failed"));
255
292
  };
256
293
 
257
294
  const tryHandleSlashCommand = async (text: string, chatId: number): Promise<boolean> => {
@@ -287,7 +324,7 @@ export function createTelegramController(deps: {
287
324
  async handleCallbackQuery(query) {
288
325
  const chatId = query.message?.chat?.id;
289
326
  if (typeof chatId !== "number") return;
290
- await deps.transport.answerCallbackQuery(query.id).catch(() => undefined);
327
+ await deps.transport.answerCallbackQuery(query.id).catch(ctrlLog.swallow("debug", "answerCallbackQuery failed", { queryId: query.id }));
291
328
  if (!(await deps.authorizeUser(query.from?.id))) return;
292
329
  await deps.setActiveChatId(chatId);
293
330
 
@@ -297,15 +334,17 @@ export function createTelegramController(deps: {
297
334
  const result = deps.ui.resolveInput(chatId, uiValue, query.message?.message_id, true);
298
335
  if (!result.handled) {
299
336
  await deps.transport.sendText(chatId, "This prompt is no longer active.");
300
- } else {
301
- // Remove inline keyboards only for terminal UI callbacks. Select
302
- // pagination callbacks continue the same flow and immediately edit the
303
- // same message with the next page of buttons; removing the keyboard here
304
- // can race after that edit and strip the new page buttons.
305
- const isPaginationCallback = /^f:[^:]+:p:\d+$/.test(uiValue);
306
- const promptMessageId = result.promptMessageId ?? query.message?.message_id;
307
- if (!isPaginationCallback && promptMessageId) void deps.transport.removeInlineKeyboard(chatId, promptMessageId);
308
337
  }
338
+ // Keyboard cleanup is OWNED by each UI flow (confirm/input/select and the
339
+ // custom-dialog bridge): each calls removeInlineKeyboard on its own prompt
340
+ // message right before resolving a terminal value (yes/no/cancel/submit/...).
341
+ // The controller must NOT speculatively strip keyboards here. Doing so races
342
+ // with continuation flows (multi-question tab navigation, single-question
343
+ // option toggles, select pagination) that edit the SAME message in place:
344
+ // removeInlineKeyboard and editButtons hit Telegram concurrently on one
345
+ // message, and when the strip lands last the message is left with no
346
+ // buttons — the "answered Q1 but Q2 never appeared" bug. Terminal flows
347
+ // clean up themselves, so no controller-side strip is needed.
309
348
  return;
310
349
  }
311
350
 
@@ -368,7 +407,7 @@ export function createTelegramController(deps: {
368
407
  formatIncomingAttachmentReceipt("saved", downloadedMediaLines),
369
408
  formatIncomingAttachmentReceipt("failed", failedMediaLines),
370
409
  ].filter(Boolean);
371
- await deps.transport.sendText(chatId, lines.join("\n\n")).catch(() => undefined);
410
+ await deps.transport.sendText(chatId, lines.join("\n\n")).catch(ctrlLog.swallow("warn", "sendText media receipt failed", { chatId }));
372
411
  }
373
412
  }
374
413
  const hasPromptInput = hasTextInput || hasMediaInput;