kiro-telegram-bot 1.5.1 → 1.6.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.
- package/.env.example +30 -0
- package/CHANGELOG.md +310 -0
- package/README.md +8 -3
- package/package.json +2 -1
- package/src/acp/client.ts +24 -0
- package/src/app/settings-store.ts +7 -0
- package/src/app/types.ts +4 -2
- package/src/app/updater.ts +234 -0
- package/src/app/version.ts +41 -0
- package/src/bot/bot.ts +53 -1
- package/src/bot/chat-controller.ts +60 -7
- package/src/bot/commands.ts +3 -3
- package/src/bot/deps.ts +18 -0
- package/src/bot/handlers/control.ts +11 -3
- package/src/bot/handlers/history.ts +7 -2
- package/src/bot/handlers/kill.ts +4 -2
- package/src/bot/handlers/mcp.ts +2 -1
- package/src/bot/handlers/menu.ts +12 -7
- package/src/bot/handlers/message.ts +7 -2
- package/src/bot/handlers/photo.ts +13 -4
- package/src/bot/handlers/projects.ts +119 -19
- package/src/bot/handlers/running.ts +96 -21
- package/src/bot/handlers/sessions.ts +41 -25
- package/src/bot/handlers/tasks.ts +2 -1
- package/src/bot/handlers/usage.ts +2 -1
- package/src/bot/handlers/voice.ts +1 -1
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/prompt-content.ts +1 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +228 -66
- package/src/config.ts +21 -0
- package/src/index.ts +3 -1
- package/src/projects/manager.ts +16 -5
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +4 -0
- package/src/render/tool-call.ts +97 -3
- package/src/service/linux.ts +2 -0
- package/src/service/macos.ts +10 -0
- package/src/service/platform.ts +5 -0
- package/src/service/types.ts +2 -0
- package/src/sessions/history.ts +33 -0
- package/src/stream/streamer.ts +34 -8
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-updater — once an hour, asks npm for the latest published version with a
|
|
3
|
+
* single lightweight request. When a newer version exists AND the bot is fully
|
|
4
|
+
* idle (no in-flight prompt, no other active Kiro session on the PC), it
|
|
5
|
+
* announces in chat, runs `npm install -g kiro-telegram-bot@<latest>`, and
|
|
6
|
+
* restarts to apply. After the restart it posts the new version's CHANGELOG
|
|
7
|
+
* (tagged #update) so every release is easy to find in the conversation.
|
|
8
|
+
*
|
|
9
|
+
* Safety:
|
|
10
|
+
* • only ever updates when idle — never interrupts a running turn/task;
|
|
11
|
+
* • only for a global npm install (a cloned/source checkout is left alone);
|
|
12
|
+
* • restart is supervisor-aware: under systemd/launchd it exits cleanly and
|
|
13
|
+
* lets the supervisor relaunch; on Windows / foreground it re-execs itself.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { get } from "node:https";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { JsonStore } from "./json-store.js";
|
|
20
|
+
import { createLogger } from "../logger.js";
|
|
21
|
+
import { extractChangelog, isNewer, isSafeVersion } from "./version.js";
|
|
22
|
+
|
|
23
|
+
const log = createLogger("updater");
|
|
24
|
+
const PKG = "kiro-telegram-bot";
|
|
25
|
+
|
|
26
|
+
interface PendingUpdate {
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
chats: number[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UpdaterOptions {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
intervalMs: number;
|
|
35
|
+
projectRoot: string;
|
|
36
|
+
instanceDir: string;
|
|
37
|
+
dataDir: string;
|
|
38
|
+
/** True while the agent is busy (a chat turn or scheduled task). */
|
|
39
|
+
isPromptInFlight: () => boolean;
|
|
40
|
+
/** Active Kiro sessions on this PC NOT owned by the bot's own agent. */
|
|
41
|
+
otherActiveSessions: () => number;
|
|
42
|
+
/** Send a plain or markdown message to every chat. */
|
|
43
|
+
announce: (text: string, markdown: boolean) => Promise<void>;
|
|
44
|
+
/** Stop polling + the agent before the process exits/re-execs. */
|
|
45
|
+
shutdown: () => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class Updater {
|
|
49
|
+
private timer: NodeJS.Timeout | undefined;
|
|
50
|
+
private readonly state: JsonStore<PendingUpdate | null>;
|
|
51
|
+
private readonly current: string;
|
|
52
|
+
private attempting = false;
|
|
53
|
+
private readonly tried = new Set<string>();
|
|
54
|
+
|
|
55
|
+
constructor(private readonly opts: UpdaterOptions) {
|
|
56
|
+
this.state = new JsonStore<PendingUpdate | null>(join(opts.dataDir, "update-state.json"), null);
|
|
57
|
+
this.current = readVersion(opts.projectRoot);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Announce a just-applied update (if any), then begin hourly checks. */
|
|
61
|
+
async start(): Promise<void> {
|
|
62
|
+
await this.announcePending();
|
|
63
|
+
if (!this.opts.enabled) {
|
|
64
|
+
log.info("auto-update disabled (AUTO_UPDATE=false)");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!this.isNpmInstall()) {
|
|
68
|
+
log.info("running from source — auto-update is a no-op (use git to update)");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// First check shortly after boot, then on the configured interval.
|
|
72
|
+
this.timer = setTimeout(() => void this.tick(), 60_000);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stop(): void {
|
|
76
|
+
if (this.timer) clearTimeout(this.timer);
|
|
77
|
+
this.timer = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private schedule(): void {
|
|
81
|
+
this.timer = setTimeout(() => void this.tick(), this.opts.intervalMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async tick(): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await this.checkAndUpdate();
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log.debug("update check failed:", (e as Error).message);
|
|
89
|
+
} finally {
|
|
90
|
+
this.schedule();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async checkAndUpdate(): Promise<void> {
|
|
95
|
+
if (this.attempting) return;
|
|
96
|
+
const latest = await fetchLatestVersion();
|
|
97
|
+
if (!latest || !isSafeVersion(latest)) return;
|
|
98
|
+
if (!isNewer(latest, this.current)) return;
|
|
99
|
+
if (this.tried.has(latest)) return; // don't loop on a version we already tried
|
|
100
|
+
|
|
101
|
+
if (this.opts.isPromptInFlight() || this.opts.otherActiveSessions() > 0) {
|
|
102
|
+
log.info(`update ${this.current} -> ${latest} available; waiting for idle`);
|
|
103
|
+
return; // re-evaluated next interval
|
|
104
|
+
}
|
|
105
|
+
await this.applyUpdate(latest);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async applyUpdate(latest: string): Promise<void> {
|
|
109
|
+
this.attempting = true;
|
|
110
|
+
this.tried.add(latest);
|
|
111
|
+
log.info(`updating ${this.current} -> ${latest}`);
|
|
112
|
+
await this.opts.announce(
|
|
113
|
+
`\u{1F504} #update Updating ${PKG} v${this.current} \u2192 v${latest}\u2026\nThe bot is idle, so it's safe \u2014 it will restart and report what changed.`,
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const ok = await npmInstall(latest);
|
|
118
|
+
if (!ok) {
|
|
119
|
+
this.attempting = false;
|
|
120
|
+
await this.opts.announce(
|
|
121
|
+
`\u26A0\uFE0F #update Update to v${latest} failed (\`npm install -g\`). I'll try again after the next restart.`,
|
|
122
|
+
false,
|
|
123
|
+
);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.state.set({ from: this.current, to: latest, chats: this.announceChats() });
|
|
128
|
+
await this.restart();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** After a restart, post the new version's changelog (once), tagged #update. */
|
|
132
|
+
private async announcePending(): Promise<void> {
|
|
133
|
+
const pending = this.state.get();
|
|
134
|
+
if (!pending) return;
|
|
135
|
+
this.state.set(null); // consume regardless, so we never re-announce
|
|
136
|
+
if (pending.to !== this.current) {
|
|
137
|
+
log.warn(`pending update to ${pending.to} but running ${this.current}; skipping announce`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const notes = this.changelogFor(pending.to);
|
|
141
|
+
const body = notes
|
|
142
|
+
? `\u{1F680} #update Updated v${pending.from} \u2192 **v${pending.to}**\n\n${notes}`
|
|
143
|
+
: `\u{1F680} #update Updated to **v${pending.to}**.`;
|
|
144
|
+
await this.opts.announce(body, true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async restart(): Promise<void> {
|
|
148
|
+
await this.opts.shutdown().catch(() => {});
|
|
149
|
+
// Under systemd/launchd, a clean exit triggers a managed relaunch (no double
|
|
150
|
+
// instance). On Windows / foreground there is no supervisor, so re-exec.
|
|
151
|
+
if (process.env.KIRO_TG_SUPERVISED === "1") {
|
|
152
|
+
log.info("exiting for supervisor to relaunch the updated bot");
|
|
153
|
+
setTimeout(() => process.exit(0), 250);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
log.info("re-executing the updated bot");
|
|
157
|
+
const child = spawn(
|
|
158
|
+
process.execPath,
|
|
159
|
+
["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
|
|
160
|
+
{ detached: true, stdio: "ignore", cwd: this.opts.projectRoot, env: process.env },
|
|
161
|
+
);
|
|
162
|
+
child.unref();
|
|
163
|
+
setTimeout(() => process.exit(0), 500);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private isNpmInstall(): boolean {
|
|
167
|
+
return this.opts.projectRoot.replace(/\\/g, "/").includes("/node_modules/");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private announceChats(): number[] {
|
|
171
|
+
const pending = this.state.get();
|
|
172
|
+
return pending?.chats ?? [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private changelogFor(version: string): string {
|
|
176
|
+
try {
|
|
177
|
+
const md = readFileSync(join(this.opts.projectRoot, "CHANGELOG.md"), "utf-8");
|
|
178
|
+
return extractChangelog(md, version);
|
|
179
|
+
} catch {
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Read the installed version from package.json (falls back to "0.0.0"). */
|
|
186
|
+
function readVersion(projectRoot: string): string {
|
|
187
|
+
try {
|
|
188
|
+
return (JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8")) as { version?: string }).version ?? "0.0.0";
|
|
189
|
+
} catch {
|
|
190
|
+
return "0.0.0";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One small HTTPS GET to the npm registry's dist-tag manifest for `latest`. */
|
|
195
|
+
function fetchLatestVersion(): Promise<string | undefined> {
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const req = get(
|
|
198
|
+
`https://registry.npmjs.org/${PKG}/latest`,
|
|
199
|
+
{ timeout: 10_000, headers: { Accept: "application/json" } },
|
|
200
|
+
(res) => {
|
|
201
|
+
if (res.statusCode !== 200) {
|
|
202
|
+
res.resume();
|
|
203
|
+
resolve(undefined);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let body = "";
|
|
207
|
+
res.setEncoding("utf-8");
|
|
208
|
+
res.on("data", (c) => (body += c));
|
|
209
|
+
res.on("end", () => {
|
|
210
|
+
try {
|
|
211
|
+
resolve((JSON.parse(body) as { version?: string }).version);
|
|
212
|
+
} catch {
|
|
213
|
+
resolve(undefined);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
req.on("error", () => resolve(undefined));
|
|
219
|
+
req.on("timeout", () => {
|
|
220
|
+
req.destroy();
|
|
221
|
+
resolve(undefined);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Run `npm install -g kiro-telegram-bot@<version>`; resolves true on success. */
|
|
227
|
+
function npmInstall(version: string): Promise<boolean> {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
230
|
+
const child = spawn(npm, ["install", "-g", `${PKG}@${version}`], { stdio: "ignore" });
|
|
231
|
+
child.on("error", () => resolve(false));
|
|
232
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny semver helpers + CHANGELOG section extraction for the auto-updater.
|
|
3
|
+
* Pure and dependency-free so they're easy to test.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Parse the leading "X.Y.Z" of a version string (ignores pre-release tags). */
|
|
7
|
+
export function parseSemver(v: string): [number, number, number] {
|
|
8
|
+
const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
9
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** True when `latest` is strictly greater than `current` (by major/minor/patch). */
|
|
13
|
+
export function isNewer(latest: string, current: string): boolean {
|
|
14
|
+
const a = parseSemver(latest);
|
|
15
|
+
const b = parseSemver(current);
|
|
16
|
+
for (let i = 0; i < 3; i++) {
|
|
17
|
+
if (a[i] !== b[i]) return a[i]! > b[i]!;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A version string is a plain semver we'd trust to pass to `npm install`. */
|
|
23
|
+
export function isSafeVersion(v: string): boolean {
|
|
24
|
+
return /^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(v);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Extract the body of a CHANGELOG `## [version] …` section (markdown). */
|
|
28
|
+
export function extractChangelog(md: string, version: string): string {
|
|
29
|
+
const head = new RegExp(`^##\\s*\\[${version.replace(/\./g, "\\.")}\\]`);
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
let capturing = false;
|
|
32
|
+
for (const line of md.split("\n")) {
|
|
33
|
+
if (capturing && /^##\s*\[/.test(line)) break;
|
|
34
|
+
if (capturing) {
|
|
35
|
+
out.push(line);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (head.test(line)) capturing = true;
|
|
39
|
+
}
|
|
40
|
+
return out.join("\n").trim();
|
|
41
|
+
}
|
package/src/bot/bot.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { Bot } from "grammy";
|
|
|
7
7
|
import type { AcpClient } from "../acp/client.js";
|
|
8
8
|
import { SettingsStore } from "../app/settings-store.js";
|
|
9
9
|
import { SttService } from "../app/stt.js";
|
|
10
|
+
import { Updater } from "../app/updater.js";
|
|
10
11
|
import { UsageService } from "../app/usage.js";
|
|
11
12
|
import type { AppConfig } from "../config.js";
|
|
13
|
+
import { INSTANCE_DIR } from "../config.js";
|
|
12
14
|
import { createLogger } from "../logger.js";
|
|
13
15
|
import { ProjectManager } from "../projects/manager.js";
|
|
14
16
|
import { SessionStore } from "../sessions/store.js";
|
|
@@ -33,6 +35,9 @@ import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
|
|
|
33
35
|
import { registerUsage } from "./handlers/usage.js";
|
|
34
36
|
import { registerVoice } from "./handlers/voice.js";
|
|
35
37
|
import { StatusPanel } from "./menu/status-panel.js";
|
|
38
|
+
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
39
|
+
import { Ephemeral } from "./menu/ephemeral.js";
|
|
40
|
+
import { BAR_LABELS } from "./menu/keyboard.js";
|
|
36
41
|
import { PermissionService } from "./permission-service.js";
|
|
37
42
|
import { RuntimeRegistry } from "./registry.js";
|
|
38
43
|
import { TaskWizard } from "./wizard/task-wizard.js";
|
|
@@ -57,6 +62,7 @@ export interface BotBundle {
|
|
|
57
62
|
bot: Bot;
|
|
58
63
|
registry: RuntimeRegistry;
|
|
59
64
|
scheduler: Scheduler;
|
|
65
|
+
updater: Updater;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBundle> {
|
|
@@ -94,6 +100,7 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
|
|
|
94
100
|
menuCache: new MenuCache(),
|
|
95
101
|
settings,
|
|
96
102
|
statusPanel,
|
|
103
|
+
ephemeral: new Ephemeral(bot.api, cfg.dataDir),
|
|
97
104
|
tasks,
|
|
98
105
|
taskRunner,
|
|
99
106
|
wizard,
|
|
@@ -112,6 +119,16 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
|
|
|
112
119
|
|
|
113
120
|
bot.use(createAuthMiddleware(cfg));
|
|
114
121
|
|
|
122
|
+
// Keep history clean: after handling, delete the user's command (/…) and
|
|
123
|
+
// persistent-bar button taps. Plain prompts and wizard input are kept.
|
|
124
|
+
bot.on("message:text", async (ctx, next) => {
|
|
125
|
+
await next();
|
|
126
|
+
const text = ctx.message?.text ?? "";
|
|
127
|
+
if (text.startsWith("/") || BAR_LABELS.includes(text)) {
|
|
128
|
+
await ctx.deleteMessage().catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
115
132
|
bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
|
|
116
133
|
const label = permissions.resolveChoice(ctx.match![1]!, Number(ctx.match![2]));
|
|
117
134
|
await ctx.answerCallbackQuery({ text: label ?? "Expired" });
|
|
@@ -150,5 +167,40 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
|
|
|
150
167
|
log.warn("setMyCommands failed:", (e as Error).message);
|
|
151
168
|
}
|
|
152
169
|
|
|
153
|
-
|
|
170
|
+
const updater = new Updater({
|
|
171
|
+
enabled: cfg.autoUpdate,
|
|
172
|
+
intervalMs: cfg.updateCheckMs,
|
|
173
|
+
projectRoot: cfg.projectRoot,
|
|
174
|
+
instanceDir: INSTANCE_DIR,
|
|
175
|
+
dataDir: cfg.dataDir,
|
|
176
|
+
isPromptInFlight: () => acp.hasInflightPrompt(),
|
|
177
|
+
otherActiveSessions: () => store.listActive().filter((s) => s.lockPid !== acp.pid).length,
|
|
178
|
+
announce: async (text, markdown) => {
|
|
179
|
+
for (const id of settings.chatIds()) {
|
|
180
|
+
try {
|
|
181
|
+
if (markdown) await sendMarkdownDoc(bot.api, id, text, { loud: true });
|
|
182
|
+
else await bot.api.sendMessage(id, text, { disable_notification: false });
|
|
183
|
+
} catch {
|
|
184
|
+
/* per-chat best-effort */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
shutdown: async () => {
|
|
189
|
+
try {
|
|
190
|
+
await bot.stop();
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
acp.stop();
|
|
196
|
+
} catch {
|
|
197
|
+
/* ignore */
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Remove any navigation surface left over from before a restart.
|
|
203
|
+
void deps.ephemeral.cleanupAll().catch(() => {});
|
|
204
|
+
|
|
205
|
+
return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
|
|
154
206
|
}
|
|
@@ -64,6 +64,7 @@ export class ChatController {
|
|
|
64
64
|
/** List the controlled sessions (for /running). */
|
|
65
65
|
list(): RunningSession[] {
|
|
66
66
|
this.ensureRestored();
|
|
67
|
+
this.pruneDuplicates();
|
|
67
68
|
return this.runtimes.map((rt) => ({
|
|
68
69
|
sessionId: rt.sessionId,
|
|
69
70
|
projectName: rt.projectName ?? basename(rt.cwd),
|
|
@@ -76,10 +77,11 @@ export class ChatController {
|
|
|
76
77
|
/** Start a brand-new session and bring it to the foreground. */
|
|
77
78
|
async addNew(cwd: string, projectName?: string): Promise<SessionRuntime> {
|
|
78
79
|
this.ensureRestored();
|
|
79
|
-
|
|
80
|
+
const prevFg = this.fg;
|
|
80
81
|
const rt = this.create({ cwd, projectName });
|
|
81
82
|
this.runtimes.push(rt);
|
|
82
83
|
this.fg = rt;
|
|
84
|
+
await this.background(prevFg);
|
|
83
85
|
await rt.startNewSession(cwd, projectName);
|
|
84
86
|
this.markSeen(rt);
|
|
85
87
|
this.persist();
|
|
@@ -101,10 +103,13 @@ export class ChatController {
|
|
|
101
103
|
const sw = await this.switchTo(sessionId);
|
|
102
104
|
return { rt: sw!.rt, result: "resumed", alreadyControlled: true };
|
|
103
105
|
}
|
|
104
|
-
|
|
106
|
+
// Reserve the runtime synchronously (before any await) so a concurrent tap
|
|
107
|
+
// on the same session finds it and switches instead of creating a duplicate.
|
|
108
|
+
const prevFg = this.fg;
|
|
105
109
|
const rt = this.create({ cwd, projectName, sessionId });
|
|
106
110
|
this.runtimes.push(rt);
|
|
107
111
|
this.fg = rt;
|
|
112
|
+
await this.background(prevFg);
|
|
108
113
|
const result = await rt.attach(sessionId, cwd, projectName, priorEntries);
|
|
109
114
|
this.markSeen(rt);
|
|
110
115
|
this.persist();
|
|
@@ -117,10 +122,11 @@ export class ChatController {
|
|
|
117
122
|
if (this.runtimes.some((r) => r.sessionId === sessionId)) {
|
|
118
123
|
return (await this.switchTo(sessionId))!;
|
|
119
124
|
}
|
|
120
|
-
|
|
125
|
+
const prevFg = this.fg;
|
|
121
126
|
const rt = this.create({ cwd, projectName, sessionId });
|
|
122
127
|
this.runtimes.push(rt);
|
|
123
128
|
this.fg = rt;
|
|
129
|
+
await this.background(prevFg);
|
|
124
130
|
await rt.prepare().catch(() => {});
|
|
125
131
|
const path = this.store.jsonlPath(sessionId);
|
|
126
132
|
const unread = readHistory(path, 12);
|
|
@@ -198,8 +204,10 @@ export class ChatController {
|
|
|
198
204
|
if (this.restored) return;
|
|
199
205
|
this.restored = true;
|
|
200
206
|
const s = this.settings.get(this.chatId);
|
|
207
|
+
const seen = new Set<string>();
|
|
201
208
|
for (const cs of s.controlledSessions ?? []) {
|
|
202
|
-
if (!cs.sessionId) continue;
|
|
209
|
+
if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
|
|
210
|
+
seen.add(cs.sessionId);
|
|
203
211
|
this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
|
|
204
212
|
}
|
|
205
213
|
if (this.runtimes.length > 0) {
|
|
@@ -209,10 +217,51 @@ export class ChatController {
|
|
|
209
217
|
}
|
|
210
218
|
}
|
|
211
219
|
|
|
220
|
+
/** Drop any runtime that duplicates another's sessionId (keeping the
|
|
221
|
+
* foreground one), healing a state where two runtimes wrap one session. */
|
|
222
|
+
private pruneDuplicates(): void {
|
|
223
|
+
const byId = new Map<string, SessionRuntime>();
|
|
224
|
+
const kept: SessionRuntime[] = [];
|
|
225
|
+
for (const rt of this.runtimes) {
|
|
226
|
+
const id = rt.sessionId;
|
|
227
|
+
if (!id) {
|
|
228
|
+
kept.push(rt);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const prev = byId.get(id);
|
|
232
|
+
if (!prev) {
|
|
233
|
+
byId.set(id, rt);
|
|
234
|
+
kept.push(rt);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const loser = prev.isForeground || !rt.isForeground ? rt : prev;
|
|
238
|
+
const winner = loser === rt ? prev : rt;
|
|
239
|
+
if (winner !== prev) {
|
|
240
|
+
byId.set(id, winner);
|
|
241
|
+
const i = kept.indexOf(prev);
|
|
242
|
+
if (i !== -1) kept[i] = winner;
|
|
243
|
+
}
|
|
244
|
+
if (this.fg === loser) this.fg = winner;
|
|
245
|
+
loser.dispose();
|
|
246
|
+
}
|
|
247
|
+
if (kept.length !== this.runtimes.length) {
|
|
248
|
+
this.runtimes.length = 0;
|
|
249
|
+
this.runtimes.push(...kept);
|
|
250
|
+
this.persist();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
212
254
|
private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
|
|
213
255
|
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
|
|
214
256
|
rt.onStateChange = () => this.refresh(this.chatId);
|
|
215
257
|
rt.onActivity = (busy) => this.notifyActivity(busy);
|
|
258
|
+
// A logical fork (auto-fork-on-error / lost-session recovery) swaps the
|
|
259
|
+
// runtime's session id in place — re-persist the controlled list with the
|
|
260
|
+
// new id and treat the fresh session as already-seen.
|
|
261
|
+
rt.onSessionChange = () => {
|
|
262
|
+
this.markSeen(rt);
|
|
263
|
+
this.persist();
|
|
264
|
+
};
|
|
216
265
|
return rt;
|
|
217
266
|
}
|
|
218
267
|
|
|
@@ -234,9 +283,13 @@ export class ChatController {
|
|
|
234
283
|
}
|
|
235
284
|
|
|
236
285
|
private persist(): void {
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
286
|
+
const seen = new Set<string>();
|
|
287
|
+
const controlled: { sessionId?: string; projectPath: string; projectName?: string }[] = [];
|
|
288
|
+
for (const r of this.runtimes) {
|
|
289
|
+
if (!r.sessionId || seen.has(r.sessionId)) continue;
|
|
290
|
+
seen.add(r.sessionId);
|
|
291
|
+
controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
|
|
292
|
+
}
|
|
240
293
|
this.settings.update(this.chatId, {
|
|
241
294
|
controlledSessions: controlled,
|
|
242
295
|
foregroundSessionId: this.fg?.sessionId,
|
package/src/bot/commands.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export const COMMANDS: { command: string; description: string }[] = [
|
|
5
5
|
{ command: "start", description: "Welcome, menu & status panel" },
|
|
6
6
|
{ command: "menu", description: "Show the menu keyboard" },
|
|
7
|
-
{ command: "projects", description: "Projects: list / search <q> / new <name>" },
|
|
7
|
+
{ command: "projects", description: "Projects: list / search <q> / open <path> / new <name>" },
|
|
8
8
|
{ command: "sessions", description: "List/resume sessions (active first) \u00b7 /sessions <q>" },
|
|
9
9
|
{ command: "active", description: "Sessions running now on the PC" },
|
|
10
10
|
{ command: "running", description: "Sessions this chat controls \u2014 switch between them" },
|
|
@@ -16,7 +16,7 @@ export const COMMANDS: { command: string; description: string }[] = [
|
|
|
16
16
|
{ command: "new", description: "Start a fresh session here" },
|
|
17
17
|
{ command: "status", description: "Current session, project & queue" },
|
|
18
18
|
{ command: "usage", description: "Account & context usage" },
|
|
19
|
-
{ command: "btw", description: "
|
|
19
|
+
{ command: "btw", description: "Run ASAP (now if idle, else next): /btw <text>" },
|
|
20
20
|
{ command: "flush", description: "Send queued follow-ups now" },
|
|
21
21
|
{ command: "queue", description: "Show queued follow-ups" },
|
|
22
22
|
{ command: "cancel", description: "Stop the current turn" },
|
|
@@ -41,7 +41,7 @@ export const HELP_TEXT = [
|
|
|
41
41
|
"/active \u2014 attach to a session currently running on the PC",
|
|
42
42
|
"/history \u2014 show the latest messages of the current session",
|
|
43
43
|
"/new \u2014 start a brand-new session in the current project",
|
|
44
|
-
"/btw <text> \u2014
|
|
44
|
+
"/btw <text> \u2014 run it now if idle, otherwise right after the current task",
|
|
45
45
|
"/flush \u2014 run queued follow-ups immediately",
|
|
46
46
|
"/cancel \u2014 stop the current turn",
|
|
47
47
|
"/status \u2014 show session, project and queue size",
|
package/src/bot/deps.ts
CHANGED
|
@@ -9,10 +9,12 @@ import type { AppConfig } from "../config.js";
|
|
|
9
9
|
import type { SttService } from "../app/stt.js";
|
|
10
10
|
import type { UsageService } from "../app/usage.js";
|
|
11
11
|
import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
|
|
12
|
+
import type { SessionMeta } from "../sessions/types.js";
|
|
12
13
|
import type { SessionStore } from "../sessions/store.js";
|
|
13
14
|
import type { TaskRunner } from "../tasks/runner.js";
|
|
14
15
|
import type { TaskStore } from "../tasks/store.js";
|
|
15
16
|
import type { StatusPanel } from "./menu/status-panel.js";
|
|
17
|
+
import type { Ephemeral } from "./menu/ephemeral.js";
|
|
16
18
|
import type { RuntimeRegistry } from "./registry.js";
|
|
17
19
|
import type { TaskWizard } from "./wizard/task-wizard.js";
|
|
18
20
|
|
|
@@ -26,6 +28,7 @@ export interface BotDeps {
|
|
|
26
28
|
menuCache: MenuCache;
|
|
27
29
|
settings: SettingsStore;
|
|
28
30
|
statusPanel: StatusPanel;
|
|
31
|
+
ephemeral: Ephemeral;
|
|
29
32
|
tasks: TaskStore;
|
|
30
33
|
taskRunner: TaskRunner;
|
|
31
34
|
wizard: TaskWizard;
|
|
@@ -36,6 +39,7 @@ export interface BotDeps {
|
|
|
36
39
|
/** Caches the last project list shown per chat for callback resolution. */
|
|
37
40
|
export class MenuCache {
|
|
38
41
|
private readonly projectLists = new Map<number, ProjectEntry[]>();
|
|
42
|
+
private readonly sessionLists = new Map<number, { metas: SessionMeta[]; heading: string }>();
|
|
39
43
|
|
|
40
44
|
setProjects(chatId: number, list: ProjectEntry[]): void {
|
|
41
45
|
this.projectLists.set(chatId, list);
|
|
@@ -44,4 +48,18 @@ export class MenuCache {
|
|
|
44
48
|
getProject(chatId: number, index: number): ProjectEntry | undefined {
|
|
45
49
|
return this.projectLists.get(chatId)?.[index];
|
|
46
50
|
}
|
|
51
|
+
|
|
52
|
+
/** The full (sorted) project list, for paging the picker. */
|
|
53
|
+
getProjects(chatId: number): ProjectEntry[] | undefined {
|
|
54
|
+
return this.projectLists.get(chatId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Remember the session set + heading currently being paged for a chat. */
|
|
58
|
+
setSessions(chatId: number, metas: SessionMeta[], heading: string): void {
|
|
59
|
+
this.sessionLists.set(chatId, { metas, heading });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getSessions(chatId: number): { metas: SessionMeta[]; heading: string } | undefined {
|
|
63
|
+
return this.sessionLists.get(chatId);
|
|
64
|
+
}
|
|
47
65
|
}
|
|
@@ -68,12 +68,20 @@ export function registerControl(bot: Bot, deps: BotDeps): void {
|
|
|
68
68
|
bot.command("btw", async (ctx) => {
|
|
69
69
|
const text = (ctx.match || "").toString().trim();
|
|
70
70
|
if (!text) {
|
|
71
|
-
await ctx.reply("Usage: /btw <something to do
|
|
71
|
+
await ctx.reply("Usage: /btw <something for the agent to do — now if idle, otherwise next>");
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
74
|
const rt = deps.registry.get(ctx.chat.id);
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Run it right away when idle; otherwise queue it to run automatically the
|
|
76
|
+
// moment the current turn finishes (can't interrupt an in-flight agent turn).
|
|
77
|
+
const outcome = await rt.submit(textPrompt(text));
|
|
78
|
+
if (outcome === "queued") {
|
|
79
|
+
await ctx.reply(
|
|
80
|
+
`\u{1F4E5} Queued (position ${rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
await ctx.reply("\u25B6\uFE0F On it\u2026");
|
|
84
|
+
}
|
|
77
85
|
});
|
|
78
86
|
|
|
79
87
|
bot.command("flush", async (ctx) => {
|
|
@@ -5,6 +5,7 @@ import type { Bot } from "grammy";
|
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
import type { BotDeps } from "../deps.js";
|
|
7
7
|
import { readHistory } from "../../sessions/history.js";
|
|
8
|
+
import { sessionHashtags } from "../../render/hashtags.js";
|
|
8
9
|
import type { SessionMeta } from "../../sessions/types.js";
|
|
9
10
|
import { sendMarkdownDoc } from "../telegram-io.js";
|
|
10
11
|
|
|
@@ -24,7 +25,7 @@ export function registerHistory(bot: Bot, deps: BotDeps): void {
|
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
26
27
|
const meta = deps.store.get(rt.sessionId);
|
|
27
|
-
await showHistory(deps, ctx.chat.id, rt.sessionId, meta);
|
|
28
|
+
await showHistory(deps, ctx.chat.id, rt.sessionId, meta, 16, rt.tags);
|
|
28
29
|
});
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -35,6 +36,7 @@ export async function showHistory(
|
|
|
35
36
|
sessionId: string,
|
|
36
37
|
meta?: SessionMeta,
|
|
37
38
|
count = 16,
|
|
39
|
+
tags?: string,
|
|
38
40
|
): Promise<void> {
|
|
39
41
|
const entries = readHistory(deps.store.jsonlPath(sessionId), count);
|
|
40
42
|
if (entries.length === 0) {
|
|
@@ -54,5 +56,8 @@ export async function showHistory(
|
|
|
54
56
|
})
|
|
55
57
|
.join("\n\n");
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
// Every AI-output surface carries the session's searchable hashtags. A static
|
|
60
|
+
// view (no live runtime) tags at least project + session id.
|
|
61
|
+
const footer = tags ?? sessionHashtags({ cwd: meta?.cwd, sessionId });
|
|
62
|
+
await sendMarkdownDoc(deps.api, chatId, `${header}\n\n${body}\n\n${footer}`);
|
|
58
63
|
}
|
package/src/bot/handlers/kill.ts
CHANGED
|
@@ -17,9 +17,10 @@ function targets(deps: BotDeps): SessionMeta[] {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function showKillConfirm(ctx: Context, deps: BotDeps): Promise<void> {
|
|
20
|
+
await deps.ephemeral.open(ctx);
|
|
20
21
|
const active = targets(deps);
|
|
21
22
|
if (active.length === 0) {
|
|
22
|
-
await
|
|
23
|
+
await deps.ephemeral.reply(ctx, "\u2705 No other active Kiro sessions to kill.");
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
25
26
|
const list = active
|
|
@@ -29,7 +30,8 @@ export async function showKillConfirm(ctx: Context, deps: BotDeps): Promise<void
|
|
|
29
30
|
const kb = new InlineKeyboard()
|
|
30
31
|
.text(`\u{1F6D1} Kill ${active.length}`, "killall:confirm")
|
|
31
32
|
.text("Cancel", "killall:cancel");
|
|
32
|
-
await
|
|
33
|
+
await deps.ephemeral.reply(
|
|
34
|
+
ctx,
|
|
33
35
|
`\u{1F6D1} Kill ${active.length} active session(s)?\n${list}\n\n(The bot's own session is excluded.)`,
|
|
34
36
|
{ reply_markup: kb },
|
|
35
37
|
);
|
package/src/bot/handlers/mcp.ts
CHANGED
|
@@ -87,7 +87,8 @@ function togglePanel(list: McpServer[], page: number): { text: string; kb: Inlin
|
|
|
87
87
|
export async function showMcp(ctx: Context, deps: BotDeps): Promise<void> {
|
|
88
88
|
const list = snapshot(ctx.chat!.id, deps);
|
|
89
89
|
const { text, kb } = mainPanel(list);
|
|
90
|
-
await
|
|
90
|
+
await deps.ephemeral.open(ctx);
|
|
91
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function fmtProbe(r: McpProbeResult): string {
|