kiro-telegram-bot 1.5.1
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 +104 -0
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/bin/kiro-tg.mjs +21 -0
- package/docs/INSTALL.md +143 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +70 -0
- package/scripts/mq.ts +25 -0
- package/scripts/setup.mjs +78 -0
- package/src/acp/client.ts +456 -0
- package/src/acp/server-handlers.ts +85 -0
- package/src/acp/transport.ts +50 -0
- package/src/acp/types.ts +136 -0
- package/src/agents/catalog.ts +44 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +31 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +48 -0
- package/src/app/usage.ts +32 -0
- package/src/bot/auth.ts +27 -0
- package/src/bot/bot.ts +154 -0
- package/src/bot/chat-controller.ts +251 -0
- package/src/bot/commands.ts +48 -0
- package/src/bot/deps.ts +47 -0
- package/src/bot/handlers/control.ts +94 -0
- package/src/bot/handlers/history.ts +58 -0
- package/src/bot/handlers/kill.ts +69 -0
- package/src/bot/handlers/mcp.ts +205 -0
- package/src/bot/handlers/menu.ts +204 -0
- package/src/bot/handlers/message.ts +93 -0
- package/src/bot/handlers/photo.ts +108 -0
- package/src/bot/handlers/projects.ts +83 -0
- package/src/bot/handlers/running.ts +104 -0
- package/src/bot/handlers/session-card.ts +65 -0
- package/src/bot/handlers/sessions.ts +131 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +223 -0
- package/src/bot/handlers/usage.ts +33 -0
- package/src/bot/handlers/voice.ts +53 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/keyboard.ts +47 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +78 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +49 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/registry.ts +178 -0
- package/src/bot/session-runtime.ts +670 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +125 -0
- package/src/config.ts +190 -0
- package/src/index.ts +74 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +103 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +88 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/markdown.ts +126 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +102 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +83 -0
- package/src/service/macos.ts +91 -0
- package/src/service/platform.ts +59 -0
- package/src/service/types.ts +34 -0
- package/src/service/windows.ts +103 -0
- package/src/sessions/history.ts +181 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +167 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a unified diff for a file edit as RAW markdown (a ```diff fenced
|
|
3
|
+
* block). Escaping/splitting is handled downstream by the markdown converter.
|
|
4
|
+
*/
|
|
5
|
+
import { structuredPatch } from "diff";
|
|
6
|
+
|
|
7
|
+
export interface DiffInput {
|
|
8
|
+
path: string;
|
|
9
|
+
oldText: string | null | undefined;
|
|
10
|
+
newText: string | null | undefined;
|
|
11
|
+
maxLines: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DiffResult {
|
|
15
|
+
block: string; // raw ```diff fenced markdown, or ""
|
|
16
|
+
added: number;
|
|
17
|
+
removed: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderUnifiedDiff(input: DiffInput): DiffResult {
|
|
21
|
+
const oldText = input.oldText ?? "";
|
|
22
|
+
const newText = input.newText ?? "";
|
|
23
|
+
if (oldText === newText) return { block: "", added: 0, removed: 0 };
|
|
24
|
+
|
|
25
|
+
const patch = structuredPatch(input.path, input.path, oldText, newText, "", "", { context: 2 });
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
let added = 0;
|
|
28
|
+
let removed = 0;
|
|
29
|
+
|
|
30
|
+
for (const hunk of patch.hunks) {
|
|
31
|
+
lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
|
|
32
|
+
for (const l of hunk.lines) {
|
|
33
|
+
if (l.startsWith("\\")) continue; // drop ""
|
|
34
|
+
if (l.startsWith("+")) added++;
|
|
35
|
+
else if (l.startsWith("-")) removed++;
|
|
36
|
+
lines.push(l);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (lines.length === 0) return { block: "", added, removed };
|
|
40
|
+
|
|
41
|
+
let shown = lines;
|
|
42
|
+
let note = "";
|
|
43
|
+
if (lines.length > input.maxLines) {
|
|
44
|
+
shown = lines.slice(0, input.maxLines);
|
|
45
|
+
note = `\n… +${lines.length - input.maxLines} more lines`;
|
|
46
|
+
}
|
|
47
|
+
return { block: "```diff\n" + shown.join("\n") + note + "\n```", added, removed };
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram MarkdownV2 escaping helpers.
|
|
3
|
+
* @see https://core.telegram.org/bots/api#markdownv2-style
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Characters that must be escaped in normal MarkdownV2 text.
|
|
7
|
+
const SPECIAL = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
8
|
+
|
|
9
|
+
/** Escape text that appears in normal (non-entity) MarkdownV2 context. */
|
|
10
|
+
export function escapeMdV2(text: string): string {
|
|
11
|
+
return text.replace(SPECIAL, (c) => `\\${c}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Escape the body of an inline code span or code block (only ` and \). */
|
|
15
|
+
export function escapeCode(text: string): string {
|
|
16
|
+
return text.replace(/([`\\])/g, "\\$1");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Escape a URL used inside a MarkdownV2 link target. */
|
|
20
|
+
export function escapeUrl(url: string): string {
|
|
21
|
+
return url.replace(/([)\\])/g, "\\$1");
|
|
22
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert standard Markdown (as produced by the agent) into Telegram
|
|
3
|
+
* MarkdownV2, with correct escaping and graceful handling of code blocks,
|
|
4
|
+
* headings, lists, quotes, links and inline styles.
|
|
5
|
+
*/
|
|
6
|
+
import { escapeCode, escapeMdV2, escapeUrl } from "./escape.js";
|
|
7
|
+
|
|
8
|
+
const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
|
|
9
|
+
|
|
10
|
+
/** Main entry: returns a MarkdownV2-safe string. */
|
|
11
|
+
export function toTelegramMarkdown(src: string): string {
|
|
12
|
+
let out = "";
|
|
13
|
+
let last = 0;
|
|
14
|
+
let m: RegExpExecArray | null;
|
|
15
|
+
FENCE.lastIndex = 0;
|
|
16
|
+
|
|
17
|
+
while ((m = FENCE.exec(src)) !== null) {
|
|
18
|
+
out += renderTextBlock(src.slice(last, m.index));
|
|
19
|
+
const lang = (m[1] ?? "").trim();
|
|
20
|
+
const code = (m[2] ?? "").replace(/\n$/, "");
|
|
21
|
+
out += "```" + lang + "\n" + escapeCode(code) + "\n```\n";
|
|
22
|
+
last = FENCE.lastIndex;
|
|
23
|
+
}
|
|
24
|
+
out += renderTextBlock(src.slice(last));
|
|
25
|
+
|
|
26
|
+
return out.replace(/\n{3,}/g, "\n\n").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderTextBlock(text: string): string {
|
|
30
|
+
if (!text) return "";
|
|
31
|
+
return text
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map((line) => renderLine(line))
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderLine(line: string): string {
|
|
38
|
+
// Heading -> bold
|
|
39
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
40
|
+
if (heading) return "*" + renderInline((heading[2] ?? "").replace(/#+\s*$/, "").trim()) + "*";
|
|
41
|
+
|
|
42
|
+
// Horizontal rule
|
|
43
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return "\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014";
|
|
44
|
+
|
|
45
|
+
// Blockquote (keep '>' literal so Telegram renders the quote)
|
|
46
|
+
const quote = /^>\s?(.*)$/.exec(line);
|
|
47
|
+
if (quote) return ">" + renderInline(quote[1] ?? "");
|
|
48
|
+
|
|
49
|
+
// Unordered list
|
|
50
|
+
const ul = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
|
51
|
+
if (ul) return (ul[1] ?? "") + "\u2022 " + renderInline(ul[2] ?? "");
|
|
52
|
+
|
|
53
|
+
// Ordered list
|
|
54
|
+
const ol = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
|
|
55
|
+
if (ol) return (ol[1] ?? "") + (ol[2] ?? "") + "\\. " + renderInline(ol[3] ?? "");
|
|
56
|
+
|
|
57
|
+
return renderInline(line);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Render inline markdown spans into MarkdownV2. */
|
|
61
|
+
function renderInline(text: string): string {
|
|
62
|
+
let out = "";
|
|
63
|
+
let i = 0;
|
|
64
|
+
const n = text.length;
|
|
65
|
+
|
|
66
|
+
while (i < n) {
|
|
67
|
+
const c = text[i]!;
|
|
68
|
+
const next = text[i + 1];
|
|
69
|
+
|
|
70
|
+
// Inline code
|
|
71
|
+
if (c === "`") {
|
|
72
|
+
const end = text.indexOf("`", i + 1);
|
|
73
|
+
if (end !== -1) {
|
|
74
|
+
out += "`" + escapeCode(text.slice(i + 1, end)) + "`";
|
|
75
|
+
i = end + 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bold ** ** or __ __
|
|
81
|
+
if ((c === "*" && next === "*") || (c === "_" && next === "_")) {
|
|
82
|
+
const marker = c + c;
|
|
83
|
+
const end = text.indexOf(marker, i + 2);
|
|
84
|
+
if (end !== -1 && end > i + 2) {
|
|
85
|
+
out += "*" + renderInline(text.slice(i + 2, end)) + "*";
|
|
86
|
+
i = end + 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Strikethrough ~~ ~~
|
|
92
|
+
if (c === "~" && next === "~") {
|
|
93
|
+
const end = text.indexOf("~~", i + 2);
|
|
94
|
+
if (end !== -1 && end > i + 2) {
|
|
95
|
+
out += "~" + renderInline(text.slice(i + 2, end)) + "~";
|
|
96
|
+
i = end + 2;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Italic * * (single)
|
|
102
|
+
if (c === "*") {
|
|
103
|
+
const end = text.indexOf("*", i + 1);
|
|
104
|
+
if (end !== -1 && end > i + 1) {
|
|
105
|
+
out += "_" + renderInline(text.slice(i + 1, end)) + "_";
|
|
106
|
+
i = end + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Link [text](url)
|
|
112
|
+
if (c === "[") {
|
|
113
|
+
const link = /^\[([^\]]*)\]\(([^)\s]+)\)/.exec(text.slice(i));
|
|
114
|
+
if (link) {
|
|
115
|
+
out += "[" + renderInline(link[1] ?? "") + "](" + escapeUrl(link[2] ?? "") + ")";
|
|
116
|
+
i += link[0].length;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out += escapeMdV2(c);
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render subagent ("crew") activity into short, readable status lines so the
|
|
3
|
+
* user can see what's happening while the main agent waits on its subagents.
|
|
4
|
+
*/
|
|
5
|
+
import type { PendingStage, SubagentInfo } from "../acp/types.js";
|
|
6
|
+
|
|
7
|
+
const STATUS_ICON: Record<string, string> = {
|
|
8
|
+
working: "\u{1F3C3}", // 🏃 running
|
|
9
|
+
running: "\u{1F3C3}",
|
|
10
|
+
pending: "\u23F3",
|
|
11
|
+
queued: "\u23F3",
|
|
12
|
+
completed: "\u2705",
|
|
13
|
+
done: "\u2705",
|
|
14
|
+
terminated: "\u2705",
|
|
15
|
+
failed: "\u274C",
|
|
16
|
+
error: "\u274C",
|
|
17
|
+
cancelled: "\u23F9",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function trunc(s: string, n: number): string {
|
|
21
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normalize a subagent's status type to a short, stable key. */
|
|
25
|
+
export function statusKey(s: SubagentInfo): string {
|
|
26
|
+
return (s.status?.type || "working").toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A one-line label for a subagent (no leading icon). */
|
|
30
|
+
export function subagentLabel(s: SubagentInfo): string {
|
|
31
|
+
const name = s.sessionName || s.agentName || s.sessionId.slice(0, 8);
|
|
32
|
+
const role = s.role || s.agentName;
|
|
33
|
+
return role && role !== name ? `${name} (${role})` : name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A markdown block announcing a subagent's status transition, or "" to skip.
|
|
38
|
+
* `kind`: "start" the first time it appears, otherwise from its status.
|
|
39
|
+
*/
|
|
40
|
+
export function renderSubagentTransition(s: SubagentInfo, kind: "start" | "status"): string {
|
|
41
|
+
const key = statusKey(s);
|
|
42
|
+
const label = subagentLabel(s);
|
|
43
|
+
if (kind === "start") {
|
|
44
|
+
const q = s.initialQuery ? `\n \u2197 ${trunc(s.initialQuery.trim(), 140)}` : "";
|
|
45
|
+
return `\u{1F916} Subagent **${label}** started${q}`;
|
|
46
|
+
}
|
|
47
|
+
const icon = STATUS_ICON[key] ?? "\u{1F916}";
|
|
48
|
+
const verb =
|
|
49
|
+
key === "terminated" || key === "completed" || key === "done"
|
|
50
|
+
? "finished"
|
|
51
|
+
: key === "failed" || key === "error"
|
|
52
|
+
? "failed"
|
|
53
|
+
: key;
|
|
54
|
+
const msg = s.status?.message && !/^running$/i.test(s.status.message) ? ` \u2014 ${trunc(s.status.message, 80)}` : "";
|
|
55
|
+
return `${icon} Subagent **${label}** ${verb}${msg}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A compact summary for the status panel, e.g. "🤖 2 running · 1 pending". */
|
|
59
|
+
export function subagentSummary(subagents: SubagentInfo[], pending: PendingStage[]): string | undefined {
|
|
60
|
+
const running = subagents.filter((s) => {
|
|
61
|
+
const k = statusKey(s);
|
|
62
|
+
return k === "working" || k === "running" || k === "pending" || k === "queued";
|
|
63
|
+
}).length;
|
|
64
|
+
const pend = pending.length;
|
|
65
|
+
if (running === 0 && pend === 0) return undefined;
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
if (running > 0) parts.push(`${running} running`);
|
|
68
|
+
if (pend > 0) parts.push(`${pend} pending`);
|
|
69
|
+
return `\u{1F916} ${parts.join(" \u00B7 ")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when a status type means the subagent is still active. */
|
|
73
|
+
export function isActiveStatus(key: string): boolean {
|
|
74
|
+
return key === "working" || key === "running" || key === "pending" || key === "queued";
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format ACP tool-call updates into clear, RAW markdown blocks so they read
|
|
3
|
+
* distinctly from the agent's prose and thinking. Commands appear in a `bash`
|
|
4
|
+
* block, file edits as a `diff` block.
|
|
5
|
+
*/
|
|
6
|
+
import type { SessionUpdate, ToolCallContent } from "../acp/types.js";
|
|
7
|
+
import { renderUnifiedDiff } from "./diff.js";
|
|
8
|
+
|
|
9
|
+
const KIND_ICON: Record<string, string> = {
|
|
10
|
+
read: "\u{1F4D6}",
|
|
11
|
+
edit: "\u270F\uFE0F",
|
|
12
|
+
execute: "\u{1F4BB}",
|
|
13
|
+
search: "\u{1F50E}",
|
|
14
|
+
delete: "\u{1F5D1}\uFE0F",
|
|
15
|
+
move: "\u{1F4E6}",
|
|
16
|
+
fetch: "\u{1F310}",
|
|
17
|
+
think: "\u{1F4AD}",
|
|
18
|
+
other: "\u{1F527}",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const STATUS_ICON: Record<string, string> = {
|
|
22
|
+
pending: "",
|
|
23
|
+
in_progress: "\u23F3",
|
|
24
|
+
completed: "\u2705",
|
|
25
|
+
failed: "\u274C",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface ToolFormatOptions {
|
|
29
|
+
showDiffs: boolean;
|
|
30
|
+
diffMaxLines: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns a RAW markdown block describing the tool call, or "" to skip. */
|
|
34
|
+
export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): string {
|
|
35
|
+
const kind = (u.kind || "other").toLowerCase();
|
|
36
|
+
const icon = KIND_ICON[kind] ?? KIND_ICON.other;
|
|
37
|
+
const status = u.status ? (STATUS_ICON[u.status] ?? "") : "";
|
|
38
|
+
const raw = (u.rawInput || {}) as Record<string, unknown>;
|
|
39
|
+
const title = u.title || titleFromRaw(kind, raw);
|
|
40
|
+
|
|
41
|
+
let out = `${icon} **${title}**${status ? ` ${status}` : ""}`;
|
|
42
|
+
|
|
43
|
+
if (kind === "execute") {
|
|
44
|
+
const cmd = strOf(raw.command ?? raw.cmd);
|
|
45
|
+
if (cmd) out += "\n```bash\n" + cmd + "\n```";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (kind === "edit" && opts.showDiffs) {
|
|
49
|
+
const diff = buildEditDiff(u, raw, opts.diffMaxLines);
|
|
50
|
+
if (diff && diff.block) {
|
|
51
|
+
const stat = `${diff.added > 0 ? "+" + diff.added : ""}${diff.removed > 0 ? " -" + diff.removed : ""}`.trim();
|
|
52
|
+
out += `${stat ? ` (${stat})` : ""}\n${diff.block}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines: number) {
|
|
60
|
+
const blocks = collectContent(u);
|
|
61
|
+
const diffBlock = blocks.find((b) => b.type === "diff");
|
|
62
|
+
if (diffBlock) {
|
|
63
|
+
return renderUnifiedDiff({
|
|
64
|
+
path: strOf(diffBlock.path) || strOf(raw.path) || "file",
|
|
65
|
+
oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
|
|
66
|
+
newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
|
|
67
|
+
maxLines,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const oldStr = strOf(raw.old_str ?? raw.oldStr);
|
|
71
|
+
const newStr = strOf(raw.new_str ?? raw.newStr);
|
|
72
|
+
if (oldStr || newStr) {
|
|
73
|
+
return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
|
|
74
|
+
}
|
|
75
|
+
const content = strOf(raw.file_text ?? raw.content ?? raw.text);
|
|
76
|
+
if (content) {
|
|
77
|
+
return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function titleFromRaw(kind: string, raw: Record<string, unknown>): string {
|
|
83
|
+
const path = strOf(raw.path ?? raw.file_path ?? raw.filename);
|
|
84
|
+
if (path) return `${capitalize(kind)} ${path}`;
|
|
85
|
+
return capitalize(kind);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectContent(u: SessionUpdate): ToolCallContent[] {
|
|
89
|
+
const out: ToolCallContent[] = [];
|
|
90
|
+
if (Array.isArray(u.content_blocks)) out.push(...u.content_blocks);
|
|
91
|
+
const content = (u as unknown as { content?: unknown }).content;
|
|
92
|
+
if (Array.isArray(content)) out.push(...(content as ToolCallContent[]));
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function strOf(v: unknown): string {
|
|
97
|
+
return typeof v === "string" ? v : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function capitalize(s: string): string {
|
|
101
|
+
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
|
|
102
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selects the right service controller for the current platform.
|
|
3
|
+
*/
|
|
4
|
+
import { detectPlatform } from "./platform.js";
|
|
5
|
+
import { linuxController } from "./linux.js";
|
|
6
|
+
import { macosController } from "./macos.js";
|
|
7
|
+
import type { ServiceController } from "./types.js";
|
|
8
|
+
import { windowsController } from "./windows.js";
|
|
9
|
+
|
|
10
|
+
export { buildLaunchSpec } from "./platform.js";
|
|
11
|
+
export type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export function getController(): ServiceController {
|
|
14
|
+
switch (detectPlatform()) {
|
|
15
|
+
case "windows":
|
|
16
|
+
return windowsController;
|
|
17
|
+
case "linux":
|
|
18
|
+
return linuxController;
|
|
19
|
+
case "macos":
|
|
20
|
+
return macosController;
|
|
21
|
+
default:
|
|
22
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux service controller — installs a systemd *user* service so no sudo is
|
|
3
|
+
* required. `loginctl enable-linger` is attempted so the bot runs at boot even
|
|
4
|
+
* before you log in.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, userInfo } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { runSafe } from "./platform.js";
|
|
10
|
+
import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const UNIT = "kiro-telegram-bot.service";
|
|
13
|
+
|
|
14
|
+
function unitPath(): string {
|
|
15
|
+
return join(homedir(), ".config", "systemd", "user", UNIT);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const linuxController: ServiceController = {
|
|
19
|
+
platform: "linux",
|
|
20
|
+
|
|
21
|
+
async install(spec) {
|
|
22
|
+
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
23
|
+
mkdirSync(spec.logsDir, { recursive: true });
|
|
24
|
+
writeFileSync(unitPath(), unitFile(spec), "utf-8");
|
|
25
|
+
|
|
26
|
+
runSafe("systemctl", ["--user", "daemon-reload"]);
|
|
27
|
+
const en = runSafe("systemctl", ["--user", "enable", "--now", UNIT]);
|
|
28
|
+
if (!en.ok) return fail(`systemctl enable failed: ${en.out}`);
|
|
29
|
+
const linger = runSafe("loginctl", ["enable-linger", userInfo().username]);
|
|
30
|
+
const note = linger.ok ? " Boot-without-login enabled (linger)." : " (run `loginctl enable-linger` for boot-without-login)";
|
|
31
|
+
return ok(`Installed and started systemd user service "${UNIT}".${note}`);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async uninstall() {
|
|
35
|
+
runSafe("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
36
|
+
rmSync(unitPath(), { force: true });
|
|
37
|
+
runSafe("systemctl", ["--user", "daemon-reload"]);
|
|
38
|
+
return ok(`Removed systemd user service "${UNIT}".`);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async start() {
|
|
42
|
+
const r = runSafe("systemctl", ["--user", "start", UNIT]);
|
|
43
|
+
return r.ok ? ok("Started.") : fail(r.out);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async stop() {
|
|
47
|
+
const r = runSafe("systemctl", ["--user", "stop", UNIT]);
|
|
48
|
+
return r.ok ? ok("Stopped.") : fail(r.out);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async status() {
|
|
52
|
+
const r = runSafe("systemctl", ["--user", "status", UNIT, "--no-pager"]);
|
|
53
|
+
return ok(r.out.trim() || "No status.");
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function unitFile(spec: LaunchSpec): string {
|
|
58
|
+
const exec = `${spec.nodePath} ${spec.args.join(" ")}`;
|
|
59
|
+
return [
|
|
60
|
+
"[Unit]",
|
|
61
|
+
`Description=${spec.displayName}`,
|
|
62
|
+
"After=network-online.target",
|
|
63
|
+
"Wants=network-online.target",
|
|
64
|
+
"",
|
|
65
|
+
"[Service]",
|
|
66
|
+
"Type=simple",
|
|
67
|
+
`WorkingDirectory=${spec.cwd}`,
|
|
68
|
+
`ExecStart=${exec}`,
|
|
69
|
+
"Restart=always",
|
|
70
|
+
"RestartSec=5",
|
|
71
|
+
"",
|
|
72
|
+
"[Install]",
|
|
73
|
+
"WantedBy=default.target",
|
|
74
|
+
"",
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ok(message: string): ServiceResult {
|
|
79
|
+
return { ok: true, message };
|
|
80
|
+
}
|
|
81
|
+
function fail(message: string): ServiceResult {
|
|
82
|
+
return { ok: false, message };
|
|
83
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS service controller — installs a launchd LaunchAgent that runs at login
|
|
3
|
+
* and is kept alive automatically.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { runSafe } from "./platform.js";
|
|
9
|
+
import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const LABEL = "com.kiro.telegrambot";
|
|
12
|
+
|
|
13
|
+
function plistPath(): string {
|
|
14
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const macosController: ServiceController = {
|
|
18
|
+
platform: "macos",
|
|
19
|
+
|
|
20
|
+
async install(spec) {
|
|
21
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
22
|
+
mkdirSync(spec.logsDir, { recursive: true });
|
|
23
|
+
const path = plistPath();
|
|
24
|
+
runSafe("launchctl", ["unload", "-w", path]); // ignore if not loaded
|
|
25
|
+
writeFileSync(path, plist(spec), "utf-8");
|
|
26
|
+
const r = runSafe("launchctl", ["load", "-w", path]);
|
|
27
|
+
return r.ok ? ok(`Installed and loaded LaunchAgent "${LABEL}".`) : fail(r.out);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async uninstall() {
|
|
31
|
+
runSafe("launchctl", ["unload", "-w", plistPath()]);
|
|
32
|
+
rmSync(plistPath(), { force: true });
|
|
33
|
+
return ok(`Removed LaunchAgent "${LABEL}".`);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async start() {
|
|
37
|
+
const r = runSafe("launchctl", ["start", LABEL]);
|
|
38
|
+
return r.ok ? ok("Started.") : fail(r.out);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async stop() {
|
|
42
|
+
const r = runSafe("launchctl", ["stop", LABEL]);
|
|
43
|
+
return r.ok ? ok("Stopped.") : fail(r.out);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async status() {
|
|
47
|
+
const r = runSafe("launchctl", ["list"]);
|
|
48
|
+
const line = r.out.split("\n").find((l) => l.includes(LABEL));
|
|
49
|
+
return ok(line ? `Loaded: ${line.trim()}` : "Not loaded.");
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function plist(spec: LaunchSpec): string {
|
|
54
|
+
const args = [spec.nodePath, ...spec.args].map((a) => ` <string>${esc(a)}</string>`).join("\n");
|
|
55
|
+
return [
|
|
56
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
57
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
58
|
+
'<plist version="1.0">',
|
|
59
|
+
"<dict>",
|
|
60
|
+
" <key>Label</key>",
|
|
61
|
+
` <string>${LABEL}</string>`,
|
|
62
|
+
" <key>ProgramArguments</key>",
|
|
63
|
+
" <array>",
|
|
64
|
+
args,
|
|
65
|
+
" </array>",
|
|
66
|
+
" <key>WorkingDirectory</key>",
|
|
67
|
+
` <string>${esc(spec.cwd)}</string>`,
|
|
68
|
+
" <key>RunAtLoad</key>",
|
|
69
|
+
" <true/>",
|
|
70
|
+
" <key>KeepAlive</key>",
|
|
71
|
+
" <true/>",
|
|
72
|
+
" <key>StandardOutPath</key>",
|
|
73
|
+
` <string>${esc(spec.logFile)}</string>`,
|
|
74
|
+
" <key>StandardErrorPath</key>",
|
|
75
|
+
` <string>${esc(spec.logFile)}</string>`,
|
|
76
|
+
"</dict>",
|
|
77
|
+
"</plist>",
|
|
78
|
+
"",
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function esc(s: string): string {
|
|
83
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ok(message: string): ServiceResult {
|
|
87
|
+
return { ok: true, message };
|
|
88
|
+
}
|
|
89
|
+
function fail(message: string): ServiceResult {
|
|
90
|
+
return { ok: false, message };
|
|
91
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform detection, launch-spec construction, and a small command runner
|
|
3
|
+
* shared by the per-OS service controllers.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { PROJECT_ROOT, INSTANCE_DIR } from "../config.js";
|
|
8
|
+
|
|
9
|
+
export type Platform = "windows" | "linux" | "macos" | "unknown";
|
|
10
|
+
|
|
11
|
+
export function detectPlatform(): Platform {
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case "win32":
|
|
14
|
+
return "windows";
|
|
15
|
+
case "linux":
|
|
16
|
+
return "linux";
|
|
17
|
+
case "darwin":
|
|
18
|
+
return "macos";
|
|
19
|
+
default:
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import type { LaunchSpec } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** Build the launch spec that runs the bot via the current node + tsx loader. */
|
|
27
|
+
export function buildLaunchSpec(): LaunchSpec {
|
|
28
|
+
const logsDir = join(INSTANCE_DIR, "logs");
|
|
29
|
+
const args = ["--import", "tsx", join(PROJECT_ROOT, "src", "index.ts")];
|
|
30
|
+
// Run the code from the package dir (cwd below) so `tsx` resolves, but tell
|
|
31
|
+
// the bot where its .env/logs/data live. Only appended for a global install
|
|
32
|
+
// (instance dir differs) so in-place checkouts keep identical launch args.
|
|
33
|
+
if (INSTANCE_DIR !== PROJECT_ROOT) args.push("--instance", INSTANCE_DIR);
|
|
34
|
+
return {
|
|
35
|
+
id: "kiro-telegram-bot",
|
|
36
|
+
displayName: "Kiro Telegram Bot",
|
|
37
|
+
nodePath: process.execPath,
|
|
38
|
+
args,
|
|
39
|
+
cwd: PROJECT_ROOT,
|
|
40
|
+
logsDir,
|
|
41
|
+
logFile: join(logsDir, "kiro-telegram-bot.log"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run a command, returning combined output. Throws on non-zero exit. */
|
|
46
|
+
export function run(cmd: string, args: string[]): string {
|
|
47
|
+
return execFileSync(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run a command, swallowing errors and returning { ok, out }. */
|
|
51
|
+
export function runSafe(cmd: string, args: string[]): { ok: boolean; out: string } {
|
|
52
|
+
try {
|
|
53
|
+
return { ok: true, out: run(cmd, args) };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
const err = e as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string };
|
|
56
|
+
const out = String(err.stdout ?? "") + String(err.stderr ?? "") || err.message || "failed";
|
|
57
|
+
return { ok: false, out };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform service (daemon) abstraction.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface LaunchSpec {
|
|
6
|
+
/** Internal service id, e.g. "kiro-telegram-bot". */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Human-readable name. */
|
|
9
|
+
displayName: string;
|
|
10
|
+
/** Absolute path to the node binary that should run the bot. */
|
|
11
|
+
nodePath: string;
|
|
12
|
+
/** Arguments after the node binary (tsx loader + entry file). */
|
|
13
|
+
args: string[];
|
|
14
|
+
/** Working directory (the installed bot folder). */
|
|
15
|
+
cwd: string;
|
|
16
|
+
/** Absolute log file path. */
|
|
17
|
+
logFile: string;
|
|
18
|
+
/** Log directory. */
|
|
19
|
+
logsDir: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ServiceResult {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ServiceController {
|
|
28
|
+
readonly platform: string;
|
|
29
|
+
install(spec: LaunchSpec): Promise<ServiceResult>;
|
|
30
|
+
uninstall(spec: LaunchSpec): Promise<ServiceResult>;
|
|
31
|
+
start(spec: LaunchSpec): Promise<ServiceResult>;
|
|
32
|
+
stop(spec: LaunchSpec): Promise<ServiceResult>;
|
|
33
|
+
status(spec: LaunchSpec): Promise<ServiceResult>;
|
|
34
|
+
}
|