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.
- package/README.md +102 -229
- package/index.ts +30 -11
- package/lib/attachments.ts +7 -3
- package/lib/commands/info.ts +109 -3
- package/lib/commands/register.ts +15 -3
- package/lib/commands/session.ts +4 -1
- package/lib/commands/telegram-commands.ts +140 -55
- package/lib/config.ts +19 -4
- package/lib/controller.ts +53 -14
- package/lib/custom-dialogs.ts +668 -0
- package/lib/heartbeat.ts +5 -1
- package/lib/logger.ts +304 -0
- package/lib/markdown.ts +392 -23
- package/lib/menu-commands.ts +10 -6
- package/lib/polling.ts +13 -8
- package/lib/renderer.ts +146 -9
- package/lib/telegram-api.ts +129 -54
- package/lib/telegram-ui.ts +89 -10
- package/lib/text-split.ts +216 -1
- package/package.json +1 -1
package/lib/commands/info.ts
CHANGED
|
@@ -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 & 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: {
|
|
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",
|
package/lib/commands/register.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/commands/session.ts
CHANGED
|
@@ -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(()
|
|
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
|
|
24
|
-
async function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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:
|
|
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:
|
|
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
|
|
91
|
-
handler:
|
|
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((
|
|
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) =>
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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 () =>
|
|
254
|
-
|
|
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((
|
|
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((
|
|
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;
|