kiro-telegram-bot 1.6.0 → 1.7.2
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 +11 -0
- package/CHANGELOG.md +186 -0
- package/README.md +73 -16
- package/package.json +4 -1
- package/scripts/setup.mjs +51 -11
- package/src/acp/client.ts +110 -15
- package/src/app/auth-service.ts +325 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/bot/auth.ts +14 -3
- package/src/bot/bot.ts +9 -0
- package/src/bot/chat-controller.ts +10 -0
- package/src/bot/commands.ts +1 -0
- package/src/bot/handlers/auth.ts +89 -0
- package/src/bot/handlers/control.ts +2 -2
- package/src/bot/handlers/kill.ts +1 -18
- package/src/bot/handlers/running.ts +2 -0
- package/src/bot/handlers/session-card.ts +16 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +2 -1
- package/src/bot/menu/status-panel.ts +53 -16
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/reauth-controller.ts +462 -0
- package/src/bot/session-runtime.ts +55 -9
- package/src/cli.ts +5 -4
- package/src/config.ts +36 -14
- package/src/index.ts +15 -1
- package/src/render/device-flow.ts +76 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/service/windows.ts +116 -21
- package/src/sessions/history.ts +12 -1
- package/src/sessions/process.ts +30 -0
- package/src/stream/streamer.ts +73 -5
package/src/config.ts
CHANGED
|
@@ -7,34 +7,44 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
|
-
loadDotenv();
|
|
11
|
-
|
|
12
10
|
/** Absolute path to the installed bot code (one level above src/). For a global
|
|
13
11
|
* npm install this lives inside node_modules — code lives here, never user data. */
|
|
14
12
|
export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
13
|
|
|
14
|
+
/** Canonical, path-independent home for this bot's `.env`, `logs/`, `data/` and
|
|
15
|
+
* the single-instance locks: `~/.kiro/tg`. Used whenever the bot is started
|
|
16
|
+
* without an explicit instance dir and there's no `.env` in the current folder,
|
|
17
|
+
* so the SAME configuration is found no matter which directory you launch from. */
|
|
18
|
+
export const CANONICAL_DIR = join(homedir(), ".kiro", "tg");
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
|
-
* Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution
|
|
21
|
+
* Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution
|
|
22
|
+
* (first match wins):
|
|
18
23
|
* 1. `--instance <dir>` argv — set by the installed background service,
|
|
19
|
-
* 2. `
|
|
20
|
-
* 3. the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* 2. `KIRO_TG_DIR` env — an explicit override,
|
|
25
|
+
* 3. `KIRO_TG_CWD` env — the legacy launcher variable,
|
|
26
|
+
* 4. the current folder, IF it already contains a `.env` (an explicit
|
|
27
|
+
* per-folder bot — keeps cloned/zip checkouts working in place),
|
|
28
|
+
* 5. the canonical `~/.kiro/tg` home — the path-independent default, so a
|
|
29
|
+
* `.env` created once is loaded no matter where the bot is started from.
|
|
24
30
|
*/
|
|
25
31
|
export const INSTANCE_DIR = resolveInstanceDir();
|
|
26
32
|
|
|
33
|
+
/** Absolute path to the `.env` this instance loads (and that `setup` writes). */
|
|
34
|
+
export const ENV_PATH = join(INSTANCE_DIR, ".env");
|
|
35
|
+
|
|
27
36
|
function resolveInstanceDir(): string {
|
|
28
37
|
const flag = process.argv.indexOf("--instance");
|
|
29
38
|
if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]!);
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
return process.cwd();
|
|
39
|
+
const envDir = process.env.KIRO_TG_DIR?.trim() || process.env.KIRO_TG_CWD?.trim();
|
|
40
|
+
if (envDir) return resolve(expandHome(envDir));
|
|
41
|
+
if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
|
|
42
|
+
return CANONICAL_DIR;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
loadDotenv({ path:
|
|
45
|
+
// Load .env from the resolved instance directory. dotenv does NOT override
|
|
46
|
+
// variables already present in the environment (the launcher/service env wins).
|
|
47
|
+
loadDotenv({ path: ENV_PATH });
|
|
38
48
|
|
|
39
49
|
function expandHome(p: string): string {
|
|
40
50
|
if (p === "~") return homedir();
|
|
@@ -112,6 +122,11 @@ export interface AppConfig {
|
|
|
112
122
|
mcpProbeConcurrency: number;
|
|
113
123
|
/** Show subagent (crew) activity while the main agent waits on them. */
|
|
114
124
|
showSubagents: boolean;
|
|
125
|
+
/** Ask the agent to emit a `{progress: N%}` marker and render it as a bar. */
|
|
126
|
+
showProgress: boolean;
|
|
127
|
+
/** When the agent emits no `{progress}` marker, show a bot-computed fallback
|
|
128
|
+
* bar derived from real activity (tool calls, streamed output, elapsed). */
|
|
129
|
+
progressFallback: boolean;
|
|
115
130
|
/** Deliver a turn's "Done" summary to the chat even when that session is in
|
|
116
131
|
* the background (you've switched to another session). */
|
|
117
132
|
notifyOtherSessions: boolean;
|
|
@@ -119,6 +134,10 @@ export interface AppConfig {
|
|
|
119
134
|
autoUpdate: boolean;
|
|
120
135
|
/** How often to check npm for a newer version (ms). */
|
|
121
136
|
updateCheckMs: number;
|
|
137
|
+
/** Enforce a single running instance per bot token: on startup, a still-alive
|
|
138
|
+
* ghost/duplicate holding the lock is terminated so the fresh process (with
|
|
139
|
+
* the current `.env`) is the only Telegram getUpdates consumer. */
|
|
140
|
+
singleInstance: boolean;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
export function loadConfig(): AppConfig {
|
|
@@ -182,9 +201,12 @@ export function loadConfig(): AppConfig {
|
|
|
182
201
|
mcpProbeTimeoutMs: num(process.env.MCP_PROBE_TIMEOUT_MS, 8000),
|
|
183
202
|
mcpProbeConcurrency: num(process.env.MCP_PROBE_CONCURRENCY, 6),
|
|
184
203
|
showSubagents: bool(process.env.SHOW_SUBAGENTS, true),
|
|
204
|
+
showProgress: bool(process.env.SHOW_PROGRESS, true),
|
|
205
|
+
progressFallback: bool(process.env.PROGRESS_FALLBACK, true),
|
|
185
206
|
notifyOtherSessions: bool(process.env.NOTIFY_OTHER_SESSIONS, true),
|
|
186
207
|
autoUpdate: bool(process.env.AUTO_UPDATE, true),
|
|
187
208
|
updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000),
|
|
209
|
+
singleInstance: bool(process.env.KIRO_TG_SINGLE_INSTANCE, true),
|
|
188
210
|
};
|
|
189
211
|
|
|
190
212
|
return cfg;
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { AcpClient } from "./acp/client.js";
|
|
7
7
|
import { createBot } from "./bot/bot.js";
|
|
8
|
-
import { loadConfig } from "./config.js";
|
|
8
|
+
import { CANONICAL_DIR, loadConfig } from "./config.js";
|
|
9
|
+
import { InstanceLock } from "./app/instance-lock.js";
|
|
10
|
+
import { join } from "node:path";
|
|
9
11
|
import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
|
|
10
12
|
|
|
11
13
|
async function main(): Promise<void> {
|
|
@@ -17,6 +19,17 @@ async function main(): Promise<void> {
|
|
|
17
19
|
enableFileLogging(cfg.logFile);
|
|
18
20
|
const log = createLogger("main");
|
|
19
21
|
|
|
22
|
+
// Single-instance guard: kill any ghost/duplicate already polling this token
|
|
23
|
+
// (the usual cause of a stale "Not authorized" — an old process with an
|
|
24
|
+
// outdated .env). A plain manual start yields to a running background service.
|
|
25
|
+
const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.KIRO_TG_SUPERVISED === "1");
|
|
26
|
+
if (cfg.singleInstance && !(await lock.acquire())) {
|
|
27
|
+
process.stdout.write(
|
|
28
|
+
"\u26D4 Another Kiro Telegram Bot is already running for this token (a background service). Use `kiro-tg restart`, or `kiro-tg stop` first.\n",
|
|
29
|
+
);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
log.info("starting Kiro Telegram Bot");
|
|
21
34
|
log.info(`workspace: ${cfg.workspace}`);
|
|
22
35
|
log.info(`kiro-cli: ${cfg.kiroCliPath}`);
|
|
@@ -46,6 +59,7 @@ async function main(): Promise<void> {
|
|
|
46
59
|
registry.disposeAll();
|
|
47
60
|
void bot.stop().catch(() => {});
|
|
48
61
|
acp.stop();
|
|
62
|
+
lock.release();
|
|
49
63
|
setTimeout(() => process.exit(code), 500);
|
|
50
64
|
};
|
|
51
65
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the (noisy) stdout of `kiro-cli login --use-device-flow` into the few
|
|
3
|
+
* stable fields worth showing: the verification URL, the device code, and
|
|
4
|
+
* whether login completed.
|
|
5
|
+
*
|
|
6
|
+
* The CLI animates a spinner ("▰▱▱… Logging in…") by rewriting one terminal
|
|
7
|
+
* line with carriage returns. With stdio piped the `\r`s are stripped, so those
|
|
8
|
+
* frames pile up into one long string ("▰▱▱… Logging in…▰▰▱… Logging in…"). We
|
|
9
|
+
* discard that noise and surface only meaningful values, which the bot renders
|
|
10
|
+
* on a single, self-animated status line instead of echoing every frame.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Block/braille glyphs the CLI uses to draw progress bars / spinners. */
|
|
14
|
+
const BAR_CHARS = "▰▱▮▯■□▪▫●○◐◓◑◒⣾⣽⣻⢿⡿⣟⣯⣷⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
15
|
+
const BAR_CLASS = `[${BAR_CHARS}]`;
|
|
16
|
+
|
|
17
|
+
export interface DeviceFlow {
|
|
18
|
+
/** Verification URL, if the CLI printed one. */
|
|
19
|
+
url?: string;
|
|
20
|
+
/** Device/user code (e.g. `VKKH-PPMX`), if present. */
|
|
21
|
+
code?: string;
|
|
22
|
+
/** True once the CLI reports a completed login. */
|
|
23
|
+
loggedIn: boolean;
|
|
24
|
+
/** A short error line, if the output looks like a failure. */
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Remove spinner frames, repeated "Logging in…" and stray bar glyphs. */
|
|
29
|
+
export function stripSpinner(raw: string): string {
|
|
30
|
+
return raw
|
|
31
|
+
.replace(/\r/g, "")
|
|
32
|
+
.replace(new RegExp(`${BAR_CLASS}+\\s*Logging in[.\\u2026]*`, "gi"), "")
|
|
33
|
+
.replace(/Logging in[.\u2026]*/gi, "")
|
|
34
|
+
.replace(new RegExp(`${BAR_CLASS}+`, "g"), "")
|
|
35
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
36
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ERROR_RE = /error|failed|denied|expired|invalid|unable|timed out/i;
|
|
41
|
+
|
|
42
|
+
export function parseDeviceFlow(raw: string): DeviceFlow {
|
|
43
|
+
const text = stripSpinner(raw);
|
|
44
|
+
|
|
45
|
+
// Collect every URL, then prefer the device-verification one (it embeds the
|
|
46
|
+
// user code, so it's directly clickable). `firstUrl` guards against two URLs
|
|
47
|
+
// run together with no separator (terminal echo), which would otherwise be
|
|
48
|
+
// matched as one giant token.
|
|
49
|
+
const urls = (text.match(/https?:\/\/[^\s'"<>)\]]+/gi) ?? []).map(firstUrl);
|
|
50
|
+
const url = urls.find((u) => /user_code=|\/device/i.test(u)) ?? urls[0];
|
|
51
|
+
|
|
52
|
+
// Prefer the unambiguous XXXX-XXXX shape; fall back to a "Code:" label.
|
|
53
|
+
const code =
|
|
54
|
+
text.match(/\b[A-Z0-9]{4}-[A-Z0-9]{4}\b/)?.[0] ??
|
|
55
|
+
text.match(/Code[:\s]+([A-Z0-9][A-Z0-9-]{3,})/)?.[1];
|
|
56
|
+
|
|
57
|
+
const loggedIn = /logged in|login successful|successfully logged in/i.test(text);
|
|
58
|
+
|
|
59
|
+
const error =
|
|
60
|
+
!loggedIn && ERROR_RE.test(text)
|
|
61
|
+
? text
|
|
62
|
+
.split("\n")
|
|
63
|
+
.map((l) => l.trim())
|
|
64
|
+
.reverse()
|
|
65
|
+
.find((l) => ERROR_RE.test(l))
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
return { url, code, loggedIn, error };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Trim a token that accidentally contains two concatenated URLs down to the
|
|
72
|
+
* first one (e.g. "https://x/starthttps://x/start" → "https://x/start"). */
|
|
73
|
+
function firstUrl(u: string): string {
|
|
74
|
+
const second = u.indexOf("http", 4);
|
|
75
|
+
return second > 0 ? u.slice(0, second) : u;
|
|
76
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot-side FALLBACK task-progress estimate.
|
|
3
|
+
*
|
|
4
|
+
* The primary progress signal is the `{progress: N%}` marker the agent is asked
|
|
5
|
+
* to emit (see PROGRESS_DIRECTIVE). But that marker is only an *instruction* the
|
|
6
|
+
* model can ignore — weaker/free models and long, tool-heavy turns frequently
|
|
7
|
+
* never emit one, leaving the bar empty for the whole turn. This module gives
|
|
8
|
+
* the bot a way to show a live, advancing bar anyway, derived ONLY from real,
|
|
9
|
+
* observable work signals (never random):
|
|
10
|
+
*
|
|
11
|
+
* • completed tool calls — each is concrete progress, weighted most
|
|
12
|
+
* • streamed prose chars — the agent explaining / answering
|
|
13
|
+
* • streamed thinking chars — reasoning volume (weighted least)
|
|
14
|
+
* • elapsed time — a small, slow contribution so a quiet turn still creeps
|
|
15
|
+
*
|
|
16
|
+
* The estimate is monotonic by construction (every input only grows during a
|
|
17
|
+
* turn) and asymptotically capped well below 100 while running, so the bar never
|
|
18
|
+
* claims "done" on its own — the caller pushes 100 only when the turn actually
|
|
19
|
+
* completes. The agent's own marker, when present, always takes precedence.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Observable, monotonically-increasing signals collected during one turn. */
|
|
23
|
+
export interface ActivitySignals {
|
|
24
|
+
/** Number of tool calls / subagent transitions shown this turn. */
|
|
25
|
+
toolCalls: number;
|
|
26
|
+
/** Characters of agent prose streamed this turn. */
|
|
27
|
+
outputChars: number;
|
|
28
|
+
/** Characters of agent thinking streamed this turn. */
|
|
29
|
+
thoughtChars: number;
|
|
30
|
+
/** Milliseconds since the turn started. */
|
|
31
|
+
elapsedMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Hard ceiling for the fallback while a turn is still running. The agent (or
|
|
35
|
+
* turn completion) is the only thing allowed to take the bar to 100. */
|
|
36
|
+
export const FALLBACK_RUNNING_CAP = 90;
|
|
37
|
+
|
|
38
|
+
/** Minimum shown once *any* work signal is present (so the bar never sits at 0
|
|
39
|
+
* while the agent is clearly busy). */
|
|
40
|
+
const FALLBACK_FLOOR = 5;
|
|
41
|
+
|
|
42
|
+
/** Controls how quickly the asymptotic curve approaches the cap. Larger = slower. */
|
|
43
|
+
const CURVE_K = 6;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Map real work signals to a 0–FALLBACK_RUNNING_CAP estimate via a saturating
|
|
47
|
+
* curve `cap * (1 - e^(-units/K))`. Tool calls dominate because each is a
|
|
48
|
+
* discrete, completed step; text volume and elapsed time add gentle, diminishing
|
|
49
|
+
* contributions so a turn that's only thinking still advances slowly.
|
|
50
|
+
*/
|
|
51
|
+
export function estimateProgress(s: ActivitySignals): number {
|
|
52
|
+
const units =
|
|
53
|
+
Math.max(0, s.toolCalls) * 1.0 +
|
|
54
|
+
Math.max(0, s.outputChars) / 400 +
|
|
55
|
+
Math.max(0, s.thoughtChars) / 1500 +
|
|
56
|
+
Math.max(0, s.elapsedMs) / 30_000;
|
|
57
|
+
|
|
58
|
+
if (units <= 0) return 0;
|
|
59
|
+
|
|
60
|
+
const raw = FALLBACK_RUNNING_CAP * (1 - Math.exp(-units / CURVE_K));
|
|
61
|
+
const clamped = Math.min(FALLBACK_RUNNING_CAP, Math.max(FALLBACK_FLOOR, raw));
|
|
62
|
+
return Math.round(clamped);
|
|
63
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-progress support: parse the `{progress: N%}` marker the agent appends to
|
|
3
|
+
* its messages, strip it from the visible text, and render a green loading bar.
|
|
4
|
+
*
|
|
5
|
+
* The agent is asked (see PROGRESS_DIRECTIVE) to end each message with a marker
|
|
6
|
+
* like `{progress: 65%}`. The bot extracts the latest value, removes the marker
|
|
7
|
+
* so it never shows raw, and renders a 0–100% bar (filled = 🟩, empty = ⬜) on
|
|
8
|
+
* the live message, in session cards, and in the pinned status panel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Matches a complete marker: `{progress: 65%}`, `{ progress:65 }`, etc. */
|
|
12
|
+
const PROGRESS_RE = /\{\s*progress\s*:\s*(\d{1,3})\s*%?\s*\}/gi;
|
|
13
|
+
/** Matches a trailing, not-yet-closed marker mid-stream (e.g. `…{progress: 6`). */
|
|
14
|
+
const PARTIAL_TAIL_RE = /\{\s*progress\b[^}]*$/i;
|
|
15
|
+
|
|
16
|
+
const FILLED = "\u{1F7E9}"; // 🟩
|
|
17
|
+
const EMPTY = "\u2B1C"; // ⬜
|
|
18
|
+
const SEGMENTS = 10;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The instruction appended to prompts so the agent emits a progress marker.
|
|
22
|
+
*
|
|
23
|
+
* IMPORTANT for maintainers:
|
|
24
|
+
* - The ONLY brace token may be the literal `{progress: N%}` with the letter
|
|
25
|
+
* `N` (never a digit). A digit inside braces would be parsed by PROGRESS_RE
|
|
26
|
+
* as a real value AND would break the exact-string strip in
|
|
27
|
+
* `sessions/history.ts` (`cleanStoredText`).
|
|
28
|
+
* - Keep this string "tidy-idempotent": no trailing spaces on any line, no run
|
|
29
|
+
* of 3+ newlines, and no trailing whitespace at the end. `cleanStoredText`
|
|
30
|
+
* runs `extractProgress` (which calls `tidy()`) before stripping the
|
|
31
|
+
* directive by exact match, so any whitespace `tidy()` would rewrite must
|
|
32
|
+
* not appear here, or the directive leaks into history/previews.
|
|
33
|
+
*/
|
|
34
|
+
export const PROGRESS_DIRECTIVE = [
|
|
35
|
+
"PROGRESS REPORTING IS MANDATORY ON EVERY SINGLE MESSAGE YOU SEND \u2014 NO EXCEPTIONS.",
|
|
36
|
+
"Rule 1 (format): finish EVERY message with a task-completion marker on its own final line, in EXACTLY this format, with nothing at all after it: {progress: N%}",
|
|
37
|
+
"N is a plain integer from 0 to 100 (no decimals, no ranges, no math). The marker is the very last thing in the message: no text, punctuation, spaces, emoji, backticks, or code fences may follow it, and it must NEVER be placed inside a code block or quote.",
|
|
38
|
+
"Rule 2 (frequency): emit the marker on your FIRST message, on EVERY intermediate message, after EVERY tool call or group of tool calls, around any subagent delegation, and on your FINAL message. Short replies, plans, questions, acknowledgements, clarifications, errors, and tool-only or status updates are NOT exempt \u2014 if you output any text at all, it ends with the marker. Do not batch it only into the last message.",
|
|
39
|
+
"Rule 3 (compute it for real, never random): before each message, decompose the overall task into the concrete steps it actually needs (understand the request, read the relevant files, each separate edit, run the build/tests, fix failures, verify) and set N = round(100 * completed_steps / total_steps), re-estimated fresh from the real current state each time.",
|
|
40
|
+
"Rule 4 (be granular and honest): start low (about 5 to 15 on your first message; use 0 only when literally nothing is started yet), then climb in realistic increments that mirror real progress. Do NOT jump from a low number straight to a high one, and do NOT keep repeating the same number across messages while work is clearly advancing.",
|
|
41
|
+
"Rule 5 (monotonic): within one task the number must NEVER decrease \u2014 each marker is greater than or equal to the previous one you emitted.",
|
|
42
|
+
"Rule 6 (terminal): report 100 ONLY when the entire task is fully complete and verified with nothing left to do. While ANY work, fix, verification, question, or follow-up remains, cap the number at 99.",
|
|
43
|
+
"The client parses this marker, removes it from the visible text, and renders it as a live progress bar, so its presence on every message and the accuracy of the number both matter.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
export interface ProgressExtract {
|
|
47
|
+
/** Latest progress value found (0–100), or undefined if none. */
|
|
48
|
+
value?: number;
|
|
49
|
+
/** The input text with all progress markers removed. */
|
|
50
|
+
cleaned: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pull the latest `{progress: N%}` value out of `text` and strip every marker
|
|
54
|
+
* (plus any trailing half-streamed marker so it never flashes raw). */
|
|
55
|
+
export function extractProgress(text: string): ProgressExtract {
|
|
56
|
+
let value: number | undefined;
|
|
57
|
+
let cleaned = text.replace(PROGRESS_RE, (_m, digits: string) => {
|
|
58
|
+
const v = Math.max(0, Math.min(100, Number.parseInt(digits, 10)));
|
|
59
|
+
if (Number.isFinite(v)) value = v; // keep the LAST occurrence (most recent)
|
|
60
|
+
return "";
|
|
61
|
+
});
|
|
62
|
+
cleaned = cleaned.replace(PARTIAL_TAIL_RE, "");
|
|
63
|
+
return { value, cleaned: tidy(cleaned) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Tidy whitespace left behind by a removed marker. */
|
|
67
|
+
function tidy(s: string): string {
|
|
68
|
+
return s
|
|
69
|
+
.replace(/[ \t]+\n/g, "\n") // trailing spaces on lines
|
|
70
|
+
.replace(/\n{3,}/g, "\n\n") // collapse blank-line runs
|
|
71
|
+
.replace(/\s+$/g, ""); // trailing whitespace/newlines
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A 10-segment green progress bar, e.g. `🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜ 50%` (✅ at 100%). */
|
|
75
|
+
export function progressBar(pct: number): string {
|
|
76
|
+
const v = Math.max(0, Math.min(100, Math.round(pct)));
|
|
77
|
+
const filled = Math.round((v / 100) * SEGMENTS);
|
|
78
|
+
const bar = FILLED.repeat(filled) + EMPTY.repeat(SEGMENTS - filled);
|
|
79
|
+
return `${bar} ${v}%${v >= 100 ? " \u2705" : ""}`;
|
|
80
|
+
}
|
package/src/service/windows.ts
CHANGED
|
@@ -1,23 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Windows service controller — runs the bot at logon
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Windows service controller — runs the bot at logon. Preferred mechanism is a
|
|
3
|
+
* hidden ONLOGON Scheduled Task, but registering a logon-triggered task needs
|
|
4
|
+
* admin, so from a normal (non-elevated) terminal we fall back to a launcher in
|
|
5
|
+
* the per-user Startup folder — both run a small .vbs that starts node with no
|
|
6
|
+
* console window; the app logs to a file. Stop precisely targets our node
|
|
7
|
+
* process by command line, so it works regardless of how it was launched.
|
|
5
8
|
*/
|
|
6
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
10
|
import { join } from "node:path";
|
|
8
11
|
import { runSafe } from "./platform.js";
|
|
9
12
|
import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
10
13
|
|
|
11
14
|
const TASK = "KiroTelegramBot";
|
|
15
|
+
/** Launcher dropped in the per-user Startup folder when no admin is available. */
|
|
16
|
+
const STARTUP_VBS = "KiroTelegramBot.vbs";
|
|
17
|
+
|
|
18
|
+
/** The per-user Startup folder (runs at logon for the current user, no admin).
|
|
19
|
+
* Undefined only if APPDATA is unset (e.g. running with no roaming profile). */
|
|
20
|
+
function startupDir(): string | undefined {
|
|
21
|
+
const appData = process.env.APPDATA;
|
|
22
|
+
return appData ? join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup") : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function startupVbsPath(): string | undefined {
|
|
26
|
+
const dir = startupDir();
|
|
27
|
+
return dir ? join(dir, STARTUP_VBS) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove a leftover Startup-folder launcher (e.g. from an earlier non-elevated
|
|
31
|
+
* install) so a task-based install never double-launches the bot at logon. */
|
|
32
|
+
function removeStartupLauncher(): void {
|
|
33
|
+
const p = startupVbsPath();
|
|
34
|
+
if (p) rmSync(p, { force: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Canonical launcher in the bot folder (the Scheduled Task points at it). */
|
|
38
|
+
function vbsPath(spec: LaunchSpec): string {
|
|
39
|
+
return join(spec.cwd, "run-service.vbs");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when our hidden Scheduled Task is registered. */
|
|
43
|
+
function taskInstalled(): boolean {
|
|
44
|
+
return runSafe("schtasks", ["/Query", "/TN", TASK]).ok;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when a bot process matching this spec is currently running. Launch
|
|
48
|
+
* paths use this to avoid starting a second instance — two pollers on one
|
|
49
|
+
* bot token make Telegram return 409 Conflict. */
|
|
50
|
+
function isRunning(spec: LaunchSpec): boolean {
|
|
51
|
+
const proc = runSafe("powershell", ["-NoProfile", "-Command", countScript(entryOf(spec))]);
|
|
52
|
+
return proc.ok && /[1-9]\d*/.test(proc.out.trim());
|
|
53
|
+
}
|
|
12
54
|
|
|
13
55
|
export const windowsController: ServiceController = {
|
|
14
56
|
platform: "windows",
|
|
15
57
|
|
|
16
58
|
async install(spec) {
|
|
17
59
|
mkdirSync(spec.logsDir, { recursive: true });
|
|
18
|
-
const vbs =
|
|
60
|
+
const vbs = vbsPath(spec);
|
|
19
61
|
writeFileSync(vbs, vbsLauncher(spec), "utf-8");
|
|
20
62
|
|
|
63
|
+
// Preferred: a hidden ONLOGON Scheduled Task. Registering a *logon-triggered*
|
|
64
|
+
// task is a privileged operation, so /Create succeeds only from an elevated
|
|
65
|
+
// (admin) terminal. From a normal terminal it returns "Access is denied".
|
|
21
66
|
runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // replace if present
|
|
22
67
|
const res = runSafe("schtasks", [
|
|
23
68
|
"/Create",
|
|
@@ -29,37 +74,87 @@ export const windowsController: ServiceController = {
|
|
|
29
74
|
"/TR",
|
|
30
75
|
`wscript.exe "${vbs}"`,
|
|
31
76
|
]);
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
removeStartupLauncher(); // avoid a leftover launcher double-starting the bot
|
|
79
|
+
if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
80
|
+
return ok(`Installed scheduled task "${TASK}" (starts at logon) and launched it.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A task may still exist that we just couldn't overwrite (e.g. created by an
|
|
84
|
+
// earlier elevated install). Reuse it rather than ALSO adding a Startup
|
|
85
|
+
// launcher, which would double-launch the bot at logon (409 Conflict).
|
|
86
|
+
if (taskInstalled()) {
|
|
87
|
+
removeStartupLauncher();
|
|
88
|
+
if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
89
|
+
return ok(`Scheduled task "${TASK}" already exists; launched it. (Re-run elevated to recreate it.)`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fallback (no admin — the common case): drop the launcher in the per-user
|
|
93
|
+
// Startup folder. It runs hidden at every logon with no elevation.
|
|
94
|
+
const startupVbs = startupVbsPath();
|
|
95
|
+
const dir = startupDir();
|
|
96
|
+
if (!startupVbs || !dir) {
|
|
97
|
+
return fail(
|
|
98
|
+
`Could not create the logon task (${res.out.trim()}) and no per-user Startup folder is available. ` +
|
|
99
|
+
`Re-run "kiro-tg install" from an elevated terminal (Run as administrator).`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
writeFileSync(startupVbs, vbsLauncher(spec), "utf-8");
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return fail(`Startup-folder install failed: ${(e as Error).message}`);
|
|
107
|
+
}
|
|
108
|
+
if (!isRunning(spec)) runSafe("wscript.exe", [startupVbs]); // launch now
|
|
109
|
+
return ok(
|
|
110
|
+
`Installed via the Startup folder — starts hidden at logon, no admin needed — and launched it.\n` +
|
|
111
|
+
`(Tip: run "kiro-tg install" from an elevated terminal to use a hidden Scheduled Task instead.)`,
|
|
112
|
+
);
|
|
35
113
|
},
|
|
36
114
|
|
|
37
115
|
async uninstall(spec) {
|
|
38
116
|
await this.stop(spec);
|
|
39
|
-
|
|
40
|
-
rmSync(
|
|
41
|
-
|
|
117
|
+
runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // best-effort (may not exist)
|
|
118
|
+
rmSync(vbsPath(spec), { force: true });
|
|
119
|
+
const startupVbs = startupVbsPath();
|
|
120
|
+
if (startupVbs) rmSync(startupVbs, { force: true });
|
|
121
|
+
return ok(`Removed "${TASK}" (scheduled task and/or Startup launcher).`);
|
|
42
122
|
},
|
|
43
123
|
|
|
44
|
-
async start() {
|
|
45
|
-
|
|
46
|
-
|
|
124
|
+
async start(spec) {
|
|
125
|
+
if (isRunning(spec)) return ok("Already running.");
|
|
126
|
+
if (taskInstalled()) {
|
|
127
|
+
const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
128
|
+
return res.ok ? ok("Started.") : fail(res.out);
|
|
129
|
+
}
|
|
130
|
+
const startupVbs = startupVbsPath();
|
|
131
|
+
if (startupVbs && existsSync(startupVbs)) {
|
|
132
|
+
runSafe("wscript.exe", [startupVbs]);
|
|
133
|
+
return ok("Started.");
|
|
134
|
+
}
|
|
135
|
+
return fail(`Not installed. Run "kiro-tg install" first.`);
|
|
47
136
|
},
|
|
48
137
|
|
|
49
138
|
async stop(spec) {
|
|
50
|
-
runSafe("schtasks", ["/End", "/TN", TASK]);
|
|
139
|
+
runSafe("schtasks", ["/End", "/TN", TASK]); // best-effort if task-based
|
|
51
140
|
const res = runSafe("powershell", ["-NoProfile", "-Command", killScript(entryOf(spec))]);
|
|
52
141
|
return ok(`Stopped. ${res.out.trim()}`);
|
|
53
142
|
},
|
|
54
143
|
|
|
55
144
|
async status(spec) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const installed =
|
|
145
|
+
const installedTask = taskInstalled();
|
|
146
|
+
const startupVbs = startupVbsPath();
|
|
147
|
+
const installedStartup = !!startupVbs && existsSync(startupVbs);
|
|
148
|
+
const installed = installedTask || installedStartup;
|
|
149
|
+
const running = isRunning(spec);
|
|
150
|
+
const how = installedTask ? "scheduled task" : installedStartup ? "Startup folder" : "—";
|
|
151
|
+
const detail = installedTask
|
|
152
|
+
? `\n${runSafe("schtasks", ["/Query", "/TN", TASK, "/FO", "LIST"]).out.trim()}`
|
|
153
|
+
: installedStartup
|
|
154
|
+
? `\nLauncher: ${startupVbs}`
|
|
155
|
+
: "";
|
|
60
156
|
return ok(
|
|
61
|
-
`Installed: ${installed ?
|
|
62
|
-
(installed ? task.out.trim() : "Task not found."),
|
|
157
|
+
`Installed: ${installed ? `yes (${how})` : "no"} | Running: ${running ? "yes" : "no"}${detail}`,
|
|
63
158
|
);
|
|
64
159
|
},
|
|
65
160
|
};
|
package/src/sessions/history.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Reads only the tail of large logs to stay fast.
|
|
4
4
|
*/
|
|
5
5
|
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
6
|
+
import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
6
7
|
import type { HistoryEntry, HistoryRole } from "./types.js";
|
|
7
8
|
|
|
8
9
|
const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
|
|
@@ -141,7 +142,7 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
|
|
|
141
142
|
const role = roleOf(ev.kind);
|
|
142
143
|
if (!role) return undefined;
|
|
143
144
|
|
|
144
|
-
const text = extractText(ev.data?.content);
|
|
145
|
+
const text = cleanStoredText(extractText(ev.data?.content));
|
|
145
146
|
const tool = ev.data?.tool_name || ev.data?.name;
|
|
146
147
|
if (!text && !tool) return undefined;
|
|
147
148
|
|
|
@@ -153,6 +154,16 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
|
|
|
153
154
|
};
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/** Strip the `{progress: N%}` markers (any role) and the appended progress
|
|
158
|
+
* directive (user prompts) from persisted text so history / unread / previews
|
|
159
|
+
* / fork-priming never surface the raw plumbing. */
|
|
160
|
+
function cleanStoredText(text: string): string {
|
|
161
|
+
if (!text) return text;
|
|
162
|
+
let t = extractProgress(text).cleaned;
|
|
163
|
+
if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
|
|
164
|
+
return t;
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
function roleOf(kind?: string): HistoryRole | undefined {
|
|
157
168
|
switch (kind) {
|
|
158
169
|
case "Prompt":
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process control helpers for Kiro sessions: force-killing the process that
|
|
3
|
+
* holds a session's `.lock` (its `lockPid`). Shared by /killall and the
|
|
4
|
+
* per-session kill button so the behaviour stays identical.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createLogger } from "../logger.js";
|
|
8
|
+
|
|
9
|
+
const log = createLogger("sessions:process");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Force-kill a process by PID — and its child tree on Windows (`taskkill /T`),
|
|
13
|
+
* which a `kiro-cli` session may have spawned (shells, tools). Returns whether
|
|
14
|
+
* the kill command was issued without throwing. A non-existent PID or one we
|
|
15
|
+
* may not signal counts as failure (callers report it).
|
|
16
|
+
*/
|
|
17
|
+
export function killPid(pid: number): boolean {
|
|
18
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
|
|
22
|
+
} else {
|
|
23
|
+
process.kill(pid, "SIGKILL");
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
log.debug(`kill ${pid} failed:`, (e as Error).message);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|