talon-agent 1.0.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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Terminal input — raw stdin with manual key parsing.
3
+ *
4
+ * Input is a list of parts: text segments and collapsed paste blocks.
5
+ * You can type, paste, type more, paste again. Backspace removes from the end.
6
+ * Enter submits everything. Ctrl+U clears all.
7
+ */
8
+
9
+ import pc from "picocolors";
10
+
11
+ // ── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ export type InputHandler = {
14
+ onLine(callback: (text: string) => void): void;
15
+ prompt(): void;
16
+ waitForInput(): Promise<string>;
17
+ close(): void;
18
+ pause(): void;
19
+ resume(): void;
20
+ };
21
+
22
+ type TextPart = { type: "text"; content: string };
23
+ type PastePart = { type: "paste"; content: string };
24
+ type Part = TextPart | PastePart;
25
+
26
+ // ── Constants ────────────────────────────────────────────────────────────────
27
+
28
+ const PASTE_COLLAPSE_LINES = 3;
29
+ const PASTE_COLLAPSE_CHARS = 150;
30
+ const PASTE_START = "\x1b[200~";
31
+ const PASTE_END = "\x1b[201~";
32
+
33
+ // ── Factory ──────────────────────────────────────────────────────────────────
34
+
35
+ export function createInput(promptStr: string): InputHandler {
36
+ let lineCallback: ((text: string) => void) | null = null;
37
+ let pendingResolve: ((value: string) => void) | null = null;
38
+ let paused = false;
39
+
40
+ // Input is an ordered list of parts
41
+ let parts: Part[] = [{ type: "text", content: "" }];
42
+
43
+ // Bracketed paste accumulation
44
+ let inPaste = false;
45
+ let pasteAccum = "";
46
+
47
+ // ── Helpers ──
48
+
49
+ function lastPart(): Part {
50
+ return parts[parts.length - 1]!;
51
+ }
52
+
53
+ /** Ensure the last part is a text part (for typing into). */
54
+ function ensureTrailingText(): TextPart {
55
+ const last = lastPart();
56
+ if (last.type === "text") return last;
57
+ const t: TextPart = { type: "text", content: "" };
58
+ parts.push(t);
59
+ return t;
60
+ }
61
+
62
+ function pasteTag(p: PastePart): string {
63
+ const lines = p.content.split("\n").length;
64
+ return lines > 1
65
+ ? `[Pasted ~${lines} lines]`
66
+ : `[Pasted ${p.content.length} chars]`;
67
+ }
68
+
69
+ // ── Drawing ──
70
+ // Track how many visual rows the last render occupied.
71
+ // Move up to the first row, clear to end of screen, rewrite.
72
+
73
+ let prevRows = 1;
74
+
75
+ function redraw(): void {
76
+ const cols = process.stdout.columns || 80;
77
+
78
+ // Build display string and measure visible length (strip ANSI)
79
+ let display = promptStr;
80
+ let visLen = 4; // " ❯ " = 4 visible chars
81
+ for (const p of parts) {
82
+ if (p.type === "text") {
83
+ display += p.content;
84
+ visLen += p.content.length;
85
+ } else {
86
+ const tag = pasteTag(p);
87
+ display += pc.dim(tag);
88
+ visLen += tag.length;
89
+ }
90
+ }
91
+
92
+ // Move cursor to start of the previous render, clear to end of screen
93
+ if (prevRows > 1) {
94
+ process.stdout.write(`\x1b[${prevRows - 1}A`); // move up
95
+ }
96
+ process.stdout.write(`\r\x1b[J${display}\x1b[?25h`); // col 0, clear to EOS, write, show cursor
97
+
98
+ prevRows = Math.max(1, Math.ceil(visLen / cols));
99
+ }
100
+
101
+ function getFullText(): string {
102
+ return parts
103
+ .map((p) => p.content)
104
+ .join("\n")
105
+ .trim();
106
+ }
107
+
108
+ function clear(): void {
109
+ parts = [{ type: "text", content: "" }];
110
+ }
111
+
112
+ function submit(): void {
113
+ const text = getFullText();
114
+ clear();
115
+ process.stdout.write("\n");
116
+
117
+ if (pendingResolve) {
118
+ const resolve = pendingResolve;
119
+ pendingResolve = null;
120
+ resolve(text);
121
+ return;
122
+ }
123
+ if (lineCallback) lineCallback(text);
124
+ }
125
+
126
+ function handlePasteComplete(text: string): void {
127
+ const lineCount = text.split("\n").length;
128
+ if (
129
+ lineCount >= PASTE_COLLAPSE_LINES ||
130
+ text.length > PASTE_COLLAPSE_CHARS
131
+ ) {
132
+ // Collapse into a paste part
133
+ parts.push({ type: "paste", content: text });
134
+ } else {
135
+ // Short paste — inline into current text part
136
+ ensureTrailingText().content += text.replace(/\n/g, " ");
137
+ }
138
+ redraw();
139
+ }
140
+
141
+ function handleBackspace(): void {
142
+ const last = lastPart();
143
+ if (last.type === "text" && last.content.length > 0) {
144
+ // Delete last char from text
145
+ last.content = last.content.slice(0, -1);
146
+ } else if (
147
+ last.type === "text" &&
148
+ last.content === "" &&
149
+ parts.length > 1
150
+ ) {
151
+ // Empty trailing text — remove it, then remove the paste before it
152
+ parts.pop();
153
+ parts.pop();
154
+ // Ensure we always have at least one text part
155
+ if (parts.length === 0) parts.push({ type: "text", content: "" });
156
+ ensureTrailingText();
157
+ } else if (last.type === "paste") {
158
+ // Remove the paste block
159
+ parts.pop();
160
+ if (parts.length === 0) parts.push({ type: "text", content: "" });
161
+ ensureTrailingText();
162
+ }
163
+ redraw();
164
+ }
165
+
166
+ // ── Raw mode ──
167
+
168
+ if (process.stdin.isTTY) {
169
+ process.stdin.setRawMode(true);
170
+ }
171
+ process.stdin.resume();
172
+ process.stdin.setEncoding("utf8");
173
+ process.stdout.write("\x1b[?2004h");
174
+
175
+ process.stdin.on("data", (chunk: string) => {
176
+ if (paused) return;
177
+
178
+ // ── Bracketed paste ──
179
+ if (chunk.includes(PASTE_START)) {
180
+ inPaste = true;
181
+ pasteAccum = chunk.split(PASTE_START).slice(1).join(PASTE_START);
182
+ if (pasteAccum.includes(PASTE_END)) {
183
+ inPaste = false;
184
+ handlePasteComplete(pasteAccum.split(PASTE_END)[0]!);
185
+ pasteAccum = "";
186
+ }
187
+ return;
188
+ }
189
+ if (inPaste) {
190
+ if (chunk.includes(PASTE_END)) {
191
+ pasteAccum += chunk.split(PASTE_END)[0]!;
192
+ inPaste = false;
193
+ handlePasteComplete(pasteAccum);
194
+ pasteAccum = "";
195
+ } else {
196
+ pasteAccum += chunk;
197
+ }
198
+ return;
199
+ }
200
+
201
+ // ── Normal input ──
202
+ for (let i = 0; i < chunk.length; i++) {
203
+ const ch = chunk[i]!;
204
+ const code = chunk.charCodeAt(i);
205
+
206
+ if (code === 0x03) {
207
+ // Ctrl+C
208
+ process.stdout.write("\n\x1b[?2004l");
209
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
210
+ process.exit(0);
211
+ }
212
+
213
+ if (code === 0x15) {
214
+ // Ctrl+U
215
+ clear();
216
+ redraw();
217
+ continue;
218
+ }
219
+
220
+ if (code === 0x0d || code === 0x0a) {
221
+ // Enter
222
+ if (getFullText()) {
223
+ submit();
224
+ } else {
225
+ process.stdout.write("\n");
226
+ redraw();
227
+ }
228
+ continue;
229
+ }
230
+
231
+ if (code === 0x7f || code === 0x08) {
232
+ // Backspace
233
+ handleBackspace();
234
+ continue;
235
+ }
236
+
237
+ if (code === 0x1b) {
238
+ if (i + 1 < chunk.length && chunk[i + 1] === "[") {
239
+ // ANSI escape sequence (arrows, etc.) — skip
240
+ i += 2;
241
+ while (i < chunk.length && chunk.charCodeAt(i) < 0x40) i++;
242
+ } else if (pendingResolve) {
243
+ // Bare Escape during waitForInput — cancel
244
+ const resolve = pendingResolve;
245
+ pendingResolve = null;
246
+ clear();
247
+ process.stdout.write("\n");
248
+ resolve("");
249
+ }
250
+ continue;
251
+ }
252
+
253
+ if (code === 0x09) {
254
+ // Tab
255
+ ensureTrailingText().content += " ";
256
+ redraw();
257
+ continue;
258
+ }
259
+
260
+ if (code < 0x20) continue;
261
+
262
+ // Printable char
263
+ ensureTrailingText().content += ch;
264
+ redraw();
265
+ }
266
+ });
267
+
268
+ return {
269
+ onLine(callback) {
270
+ lineCallback = callback;
271
+ },
272
+ prompt() {
273
+ paused = false;
274
+ prevRows = 1; // fresh prompt = 1 row
275
+ redraw();
276
+ },
277
+ waitForInput(): Promise<string> {
278
+ return new Promise((resolve) => {
279
+ pendingResolve = resolve;
280
+ paused = false;
281
+ prevRows = 1;
282
+ redraw();
283
+ });
284
+ },
285
+ pause() {
286
+ paused = true;
287
+ },
288
+ resume() {
289
+ paused = false;
290
+ },
291
+ close() {
292
+ process.stdout.write("\x1b[?2004l");
293
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
294
+ process.stdin.pause();
295
+ },
296
+ };
297
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Terminal renderer — minimal, clean output.
3
+ *
4
+ * The renderer NEVER touches readline. It only writes to stdout.
5
+ * The caller (index.ts) is responsible for pausing/resuming readline.
6
+ * Spinner uses atomic \r overwrite — single write call, zero flicker.
7
+ */
8
+
9
+ import pc from "picocolors";
10
+
11
+ // ── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ export type StatusBarInfo = {
14
+ model: string;
15
+ sessionName?: string;
16
+ turns: number;
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ cacheHitPct: number;
20
+ costUsd: number;
21
+ };
22
+
23
+ export type Renderer = {
24
+ readonly cols: number;
25
+ writeln(text?: string): void;
26
+ writeSystem(text: string): void;
27
+ writeError(text: string): void;
28
+ renderAssistantMessage(text: string): void;
29
+ renderToolCall(toolName: string, input: Record<string, unknown>): void;
30
+ renderStatusLine(
31
+ durationMs: number,
32
+ tools: number,
33
+ info: StatusBarInfo,
34
+ ): void;
35
+ startSpinner(label?: string): void;
36
+ updateSpinnerLabel(label: string): void;
37
+ stopSpinner(): void;
38
+ };
39
+
40
+ // ── Constants ────────────────────────────────────────────────────────────────
41
+
42
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
43
+ const HIDDEN_TOOLS = new Set(["TodoRead", "TodoWrite"]);
44
+
45
+ // ── Helpers (exported for testing) ───────────────────────────────────────────
46
+
47
+ export function wrap(text: string, indent: number, maxWidth: number): string {
48
+ const width = maxWidth - indent;
49
+ if (width <= 20) return text;
50
+ const pad = " ".repeat(indent);
51
+ return text
52
+ .split("\n")
53
+ .map((line) => {
54
+ if (line.length <= width) return pad + line;
55
+ const words = line.split(" ");
56
+ const wrapped: string[] = [];
57
+ let cur = "";
58
+ for (const w of words) {
59
+ if (cur.length + w.length + 1 > width && cur) {
60
+ wrapped.push(pad + cur);
61
+ cur = w;
62
+ } else {
63
+ cur = cur ? cur + " " + w : w;
64
+ }
65
+ }
66
+ if (cur) wrapped.push(pad + cur);
67
+ return wrapped.join("\n");
68
+ })
69
+ .join("\n");
70
+ }
71
+
72
+ export function formatTimeAgo(ts: number): string {
73
+ const sec = Math.floor((Date.now() - ts) / 1000);
74
+ if (sec < 60) return "just now";
75
+ const min = Math.floor(sec / 60);
76
+ if (min < 60) return `${min}m ago`;
77
+ const hr = Math.floor(min / 60);
78
+ if (hr < 24) return `${hr}h ago`;
79
+ return `${Math.floor(hr / 24)}d ago`;
80
+ }
81
+
82
+ function fmtTok(n: number): string {
83
+ if (n < 1000) return String(n);
84
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
85
+ return `${(n / 1_000_000).toFixed(1)}M`;
86
+ }
87
+
88
+ export function extractToolDetail(
89
+ input: Record<string, unknown>,
90
+ maxLen: number,
91
+ ): string {
92
+ if (input.command) {
93
+ const s = String(input.description || input.command);
94
+ return s.length > maxLen ? s.slice(0, maxLen - 3) + "..." : s;
95
+ }
96
+ if (input.file_path) return String(input.file_path);
97
+ if (input.pattern && input.path) return `${input.pattern} in ${input.path}`;
98
+ if (input.pattern) return String(input.pattern);
99
+ if (input.action) return String(input.action);
100
+ if (input.query) return String(input.query).slice(0, maxLen);
101
+ if (input.url) return String(input.url).slice(0, maxLen);
102
+ if (input.type) return String(input.type);
103
+ if (input.name) return String(input.name);
104
+ if (input.model) return String(input.model);
105
+ if (input.package_url) return String(input.package_url);
106
+ if (input.build_number) return `#${input.build_number}`;
107
+ if (input.packages) return (input.packages as string[]).join(", ");
108
+ const parts: string[] = [];
109
+ for (const [k, v] of Object.entries(input)) {
110
+ if (k === "_chatId") continue;
111
+ if (typeof v === "string" && v.length > 0)
112
+ parts.push(`${k}=${v.length > 30 ? v.slice(0, 30) + "..." : v}`);
113
+ else if (typeof v === "number" || typeof v === "boolean")
114
+ parts.push(`${k}=${v}`);
115
+ }
116
+ return parts.join(", ").slice(0, maxLen);
117
+ }
118
+
119
+ export function cleanToolName(name: string): string {
120
+ if (name.startsWith("mcp__")) {
121
+ const parts = name.split("__");
122
+ return parts[parts.length - 1] || name;
123
+ }
124
+ return name;
125
+ }
126
+
127
+ // ── Factory ──────────────────────────────────────────────────────────────────
128
+
129
+ export function createRenderer(cols?: number, displayName = "Talon"): Renderer {
130
+ const COLS = cols ?? Math.min(process.stdout.columns || 100, 120);
131
+ const botName = displayName;
132
+
133
+ let spinnerTimer: ReturnType<typeof setInterval> | null = null;
134
+ let spinnerFrame = 0;
135
+ let spinnerLabel = "thinking";
136
+ let spinnerLineLen = 0;
137
+ let hasToolOutput = false;
138
+
139
+ // ── Output primitives ──
140
+
141
+ function writeln(text = ""): void {
142
+ process.stdout.write(`\x1b[2K\r${text}\n`);
143
+ }
144
+
145
+ function writeSystem(text: string): void {
146
+ writeln(` ${pc.dim(text)}`);
147
+ }
148
+
149
+ function writeError(text: string): void {
150
+ writeln();
151
+ writeln(` ${pc.red("✖")} ${pc.red(text)}`);
152
+ }
153
+
154
+ // ── Messages ──
155
+
156
+ function renderAssistantMessage(text: string): void {
157
+ writeln();
158
+ writeln(` ${pc.cyan("▍")} ${pc.bold(pc.cyan(botName))}`);
159
+ for (const line of wrap(text, 2, COLS).split("\n")) {
160
+ writeln(` ${pc.cyan("▍")}${line}`);
161
+ }
162
+ }
163
+
164
+ function renderToolCall(
165
+ toolName: string,
166
+ input: Record<string, unknown>,
167
+ ): void {
168
+ const clean = cleanToolName(toolName);
169
+ if (HIDDEN_TOOLS.has(clean)) return;
170
+ if (!hasToolOutput) {
171
+ hasToolOutput = true;
172
+ writeln();
173
+ }
174
+ const display = clean.replace(/_/g, " ");
175
+ const maxD = COLS - display.length - 16;
176
+ const detail = extractToolDetail(input, maxD);
177
+ writeln(
178
+ ` ${pc.dim("→")} ${pc.yellow(display)}${detail ? ` ${pc.dim(detail)}` : ""}`,
179
+ );
180
+ }
181
+
182
+ function renderStatusLine(
183
+ ms: number,
184
+ tools: number,
185
+ info: StatusBarInfo,
186
+ ): void {
187
+ const p = [
188
+ `${(ms / 1000).toFixed(1)}s`,
189
+ info.model,
190
+ ];
191
+ if (info.sessionName) p.push(`"${info.sessionName}"`);
192
+ p.push(
193
+ `${info.turns} turn${info.turns !== 1 ? "s" : ""}`,
194
+ `${fmtTok(info.inputTokens + info.outputTokens)} tok`,
195
+ `${info.cacheHitPct}% cache`,
196
+ );
197
+ if (tools > 0) p.push(`${tools} tool${tools > 1 ? "s" : ""}`);
198
+ writeln();
199
+ writeln(` ${pc.dim(p.join(" · "))}`);
200
+ hasToolOutput = false;
201
+ }
202
+
203
+ // ── Spinner ──
204
+ // Pure stdout. Never touches readline.
205
+
206
+ function startSpinner(label = "thinking"): void {
207
+ stopSpinner();
208
+ spinnerLabel = label;
209
+ spinnerFrame = 0;
210
+ spinnerLineLen = 0;
211
+ spinnerTimer = setInterval(() => {
212
+ spinnerFrame = (spinnerFrame + 1) % FRAMES.length;
213
+ const line = ` ${pc.dim(FRAMES[spinnerFrame]!)} ${pc.dim(spinnerLabel)}`;
214
+ const pad =
215
+ spinnerLineLen > line.length
216
+ ? " ".repeat(spinnerLineLen - line.length)
217
+ : "";
218
+ spinnerLineLen = line.length;
219
+ process.stdout.write(`\r${line}${pad}`);
220
+ }, 80);
221
+ }
222
+
223
+ function updateSpinnerLabel(label: string): void {
224
+ spinnerLabel = label;
225
+ }
226
+
227
+ function stopSpinner(): void {
228
+ if (spinnerTimer) {
229
+ clearInterval(spinnerTimer);
230
+ spinnerTimer = null;
231
+ process.stdout.write("\x1b[2K\r");
232
+ spinnerLineLen = 0;
233
+ }
234
+ }
235
+
236
+ return {
237
+ cols: COLS,
238
+ writeln,
239
+ writeSystem,
240
+ writeError,
241
+ renderAssistantMessage,
242
+ renderToolCall,
243
+ renderStatusLine,
244
+ startSpinner,
245
+ updateSpinnerLabel,
246
+ stopSpinner,
247
+ };
248
+ }
package/src/index.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Talon — agentic AI harness.
3
+ * Composition root: loads config, creates frontend + backend, wires dispatcher.
4
+ *
5
+ * Frontends (Telegram, Terminal) and backends (Claude, OpenCode)
6
+ * are loaded dynamically — only the selected platform's dependencies are required.
7
+ */
8
+
9
+ import { getFrontends } from "./util/config.js";
10
+ import { startUploadCleanup, stopUploadCleanup } from "./util/workspace.js";
11
+ import { flushSessions } from "./storage/sessions.js";
12
+ import { flushChatSettings } from "./storage/chat-settings.js";
13
+ import { flushCronJobs } from "./storage/cron-store.js";
14
+ import { flushHistory } from "./storage/history.js";
15
+ import { flushMediaIndex } from "./storage/media-index.js";
16
+ import { getActiveCount } from "./core/dispatcher.js";
17
+ import { startPulseTimer, stopPulseTimer } from "./core/pulse.js";
18
+ import { startCronTimer, stopCronTimer } from "./core/cron.js";
19
+ import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
20
+ import { log, logError, logWarn } from "./util/log.js";
21
+ import { bootstrap, initBackendAndDispatcher } from "./bootstrap.js";
22
+ import { Gateway } from "./core/gateway.js";
23
+ import type { Frontend } from "./bootstrap.js";
24
+
25
+ // ── Bootstrap ────────────────────────────────────────────────────────────────
26
+
27
+ import { writeFileSync, unlinkSync } from "node:fs";
28
+ import { files as pathFiles } from "./util/paths.js";
29
+
30
+ const { config } = await bootstrap();
31
+
32
+ // Write PID file for daemon management
33
+ try { writeFileSync(pathFiles.pid, String(process.pid)); } catch { /* ok */ }
34
+
35
+ // ── Create gateway + frontend ─────────────────────────────────────────────────
36
+
37
+ const gateway = new Gateway();
38
+
39
+ const selectedFrontend = getFrontends(config)[0]; // use first configured frontend
40
+ let frontend: Frontend;
41
+
42
+ if (selectedFrontend === "terminal") {
43
+ const { createTerminalFrontend } = await import("./frontend/terminal/index.js");
44
+ frontend = createTerminalFrontend(config, gateway);
45
+ log("bot", "Frontend: Terminal");
46
+ } else if (selectedFrontend === "teams") {
47
+ const { createTeamsFrontend } = await import("./frontend/teams/index.js");
48
+ frontend = createTeamsFrontend(config, gateway);
49
+ log("bot", "Frontend: Teams");
50
+ } else {
51
+ const { createTelegramFrontend } = await import("./frontend/telegram/index.js");
52
+ frontend = createTelegramFrontend(config, gateway);
53
+ log("bot", "Frontend: Telegram");
54
+ }
55
+
56
+ // ── Create backend + wire dispatcher ─────────────────────────────────────────
57
+
58
+ await initBackendAndDispatcher(config, frontend);
59
+
60
+ // ── Graceful shutdown ────────────────────────────────────────────────────────
61
+
62
+ let shuttingDown = false;
63
+
64
+ const SHUTDOWN_TIMEOUT_MS = 15_000;
65
+
66
+ async function gracefulShutdown(signal: string): Promise<void> {
67
+ if (shuttingDown) return;
68
+ shuttingDown = true;
69
+ log("shutdown", `${signal} received, shutting down gracefully...`);
70
+
71
+ const forceTimer = setTimeout(() => {
72
+ logError("shutdown", "Timeout exceeded, forcing exit");
73
+ process.exit(1);
74
+ }, SHUTDOWN_TIMEOUT_MS);
75
+ forceTimer.unref();
76
+
77
+ const pending = getActiveCount();
78
+ if (pending > 0) {
79
+ log("shutdown", `Waiting for ${pending} in-flight queries to drain...`);
80
+ await new Promise((r) => setTimeout(r, 5000));
81
+ }
82
+
83
+ await frontend.stop();
84
+ if (config.backend === "opencode") {
85
+ const { stopOpenCodeServer } = await import("./backend/opencode/index.js");
86
+ stopOpenCodeServer();
87
+ }
88
+ // Destroy plugins (cleanup resources)
89
+ if (config.plugins.length > 0) {
90
+ const { destroyPlugins } = await import("./core/plugin.js");
91
+ await destroyPlugins();
92
+ }
93
+ stopPulseTimer();
94
+ stopCronTimer();
95
+ stopWatchdog();
96
+ stopUploadCleanup();
97
+ flushSessions();
98
+ flushChatSettings();
99
+ flushCronJobs();
100
+ flushHistory();
101
+ flushMediaIndex();
102
+ try { unlinkSync(pathFiles.pid); } catch { /* ok */ }
103
+ log("shutdown", "State saved");
104
+ process.exit(0);
105
+ }
106
+
107
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
108
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
109
+
110
+ process.on("uncaughtException", (err) => {
111
+ logError("bot", "Uncaught exception", err);
112
+ flushSessions();
113
+ flushChatSettings();
114
+ flushCronJobs();
115
+ flushHistory();
116
+ flushMediaIndex();
117
+ process.exit(1);
118
+ });
119
+
120
+ process.on("unhandledRejection", (reason) => {
121
+ logWarn(
122
+ "bot",
123
+ `Unhandled rejection: ${reason instanceof Error ? reason.message : reason}`,
124
+ );
125
+ });
126
+
127
+ // ── Start ────────────────────────────────────────────────────────────────────
128
+
129
+ async function main(): Promise<void> {
130
+ await frontend.init();
131
+ log("bot", "Starting Talon...");
132
+
133
+ if (config.pulse) startPulseTimer(config.pulseIntervalMs);
134
+ startCronTimer();
135
+ startWatchdog(config.workspace);
136
+ startUploadCleanup(config.workspace);
137
+
138
+ await frontend.start();
139
+ }
140
+
141
+ main().catch((err) => {
142
+ logError("bot", "Fatal startup error", err);
143
+ process.exit(1);
144
+ });