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
package/src/cli.ts ADDED
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Talon CLI — interactive setup, management, and monitoring.
4
+ *
5
+ * Usage:
6
+ * talon — interactive menu (runs setup on first launch)
7
+ * talon setup — guided setup wizard
8
+ * talon status — show bot health and stats
9
+ * talon config — view/edit configuration
10
+ * talon logs — tail the log file with formatting
11
+ * talon start — start the bot directly
12
+ * talon chat — terminal chat mode
13
+ */
14
+
15
+ import * as p from "@clack/prompts";
16
+ import pc from "picocolors";
17
+ import { existsSync, readFileSync, mkdirSync, watchFile, writeFileSync, unlinkSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import writeFileAtomic from "write-file-atomic";
20
+ import { dirs, files as pathFiles } from "./util/paths.js";
21
+
22
+ const PKG_ROOT = resolve(import.meta.dirname ?? process.cwd(), "..");
23
+ const CONFIG_FILE = pathFiles.config;
24
+ const LOG_FILE = pathFiles.log;
25
+ const HEALTH_URL = "http://127.0.0.1:19876/health";
26
+
27
+ function printBanner(): void {
28
+ console.log();
29
+ console.log(` ${pc.bold(pc.cyan("\uD83E\uDD85 Talon"))}`);
30
+ console.log(` ${pc.dim("Agentic AI harness")}`);
31
+ console.log();
32
+ }
33
+
34
+ type Config = {
35
+ frontend: string | string[];
36
+ botToken?: string;
37
+ claudeBinary?: string;
38
+ model: string;
39
+ concurrency: number;
40
+ pulse: boolean;
41
+ pulseIntervalMs: number;
42
+ adminUserId?: number;
43
+ apiId?: number;
44
+ apiHash?: string;
45
+ maxMessageLength: number;
46
+ plugins?: unknown[];
47
+ // Teams
48
+ teamsWebhookUrl?: string;
49
+ teamsWebhookSecret?: string;
50
+ teamsWebhookPort?: number;
51
+ teamsBotDisplayName?: string;
52
+ };
53
+
54
+ const DEFAULTS: Config = {
55
+ frontend: "telegram",
56
+ model: "claude-sonnet-4-6",
57
+ concurrency: 1,
58
+ pulse: true,
59
+ pulseIntervalMs: 300000,
60
+ maxMessageLength: 4000,
61
+ };
62
+
63
+ function loadConfig(): Config {
64
+ try {
65
+ if (existsSync(CONFIG_FILE)) {
66
+ return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) };
67
+ }
68
+ } catch { /* corrupt */ }
69
+ return { ...DEFAULTS };
70
+ }
71
+
72
+ function saveConfig(config: Config): void {
73
+ if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
74
+ const clean = Object.fromEntries(
75
+ Object.entries(config).filter(([, v]) => v !== undefined),
76
+ );
77
+ writeFileAtomic.sync(CONFIG_FILE, JSON.stringify(clean, null, 2) + "\n");
78
+ }
79
+
80
+ function maskToken(token: string | undefined): string {
81
+ if (!token || token.length < 10) return pc.red("not set");
82
+ return pc.green(token.slice(0, 8) + "..." + token.slice(-4));
83
+ }
84
+
85
+ function isConfigured(config: Config): boolean {
86
+ const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
87
+ return fes.every((fe) => {
88
+ if (fe === "telegram") return !!config.botToken;
89
+ if (fe === "terminal") return true;
90
+ if (fe === "teams") return !!config.teamsWebhookUrl;
91
+ return false;
92
+ });
93
+ }
94
+
95
+ // ── Setup wizard ────────────────────────────────────────────────────────────
96
+
97
+ async function runSetup(): Promise<void> {
98
+ printBanner();
99
+ p.intro(pc.inverse(" Setup Wizard "));
100
+
101
+ const config = loadConfig();
102
+ const existingFrontends = Array.isArray(config.frontend) ? config.frontend : [config.frontend || "telegram"];
103
+
104
+ const frontendSelection = await p.multiselect({
105
+ message: "Frontend platforms (space to toggle, enter to confirm)",
106
+ initialValues: existingFrontends,
107
+ options: [
108
+ { value: "telegram", label: `Telegram ${pc.dim("\u2014 bot via @BotFather")}` },
109
+ { value: "terminal", label: `Terminal ${pc.dim("\u2014 local CLI chat")}` },
110
+ { value: "teams", label: `Teams ${pc.dim("\u2014 Microsoft Teams via Power Automate")}` },
111
+ ],
112
+ required: true,
113
+ });
114
+ if (p.isCancel(frontendSelection)) { p.cancel("Cancelled."); process.exit(0); }
115
+ const selectedFrontends = frontendSelection as string[];
116
+
117
+ let botToken: string | undefined;
118
+ let adminId: string | undefined;
119
+ let apiId: number | undefined;
120
+ let apiHash: string | undefined;
121
+
122
+ if (selectedFrontends.includes("telegram")) {
123
+ const token = await p.text({
124
+ message: "Bot token",
125
+ placeholder: "Paste your token from @BotFather",
126
+ initialValue: config.botToken || undefined,
127
+ validate: (v) => {
128
+ if (!v) return "Token is required";
129
+ if (!v.includes(":")) return "Invalid format";
130
+ },
131
+ });
132
+ if (p.isCancel(token)) { p.cancel("Cancelled."); process.exit(0); }
133
+ botToken = token;
134
+
135
+ adminId = await p.text({
136
+ message: "Your Telegram user ID",
137
+ placeholder: "optional \u2014 message @userinfobot to find yours",
138
+ initialValue: config.adminUserId ? String(config.adminUserId) : "",
139
+ }) as string;
140
+ if (p.isCancel(adminId)) { p.cancel("Cancelled."); process.exit(0); }
141
+
142
+ const wantUserbot = await p.confirm({
143
+ message: "Set up userbot for full history access?",
144
+ initialValue: !!(config.apiId && config.apiHash),
145
+ });
146
+ if (p.isCancel(wantUserbot)) { p.cancel("Cancelled."); process.exit(0); }
147
+
148
+ if (wantUserbot) {
149
+ p.note("Get these from https://my.telegram.org \u2192 API development tools", "Telegram API credentials");
150
+ const id = await p.text({
151
+ message: "API ID", placeholder: "12345678",
152
+ initialValue: config.apiId ? String(config.apiId) : "",
153
+ validate: (v) => { if (v && isNaN(parseInt(v, 10))) return "Must be a number"; },
154
+ });
155
+ if (p.isCancel(id)) { p.cancel("Cancelled."); process.exit(0); }
156
+ const hash = await p.text({ message: "API Hash", initialValue: config.apiHash || "" });
157
+ if (p.isCancel(hash)) { p.cancel("Cancelled."); process.exit(0); }
158
+ if (id) apiId = parseInt(id, 10);
159
+ if (hash) apiHash = hash as string;
160
+ }
161
+ }
162
+
163
+ let teamsWebhookUrl: string | undefined;
164
+ let teamsWebhookSecret: string | undefined;
165
+ let teamsWebhookPort: number | undefined;
166
+ let teamsBotDisplayName: string | undefined;
167
+
168
+ if (selectedFrontends.includes("teams")) {
169
+ p.note(
170
+ "Set up two Power Automate workflows in Teams:\n" +
171
+ "1. Send: 'Post to a channel when a webhook request is received' — copy the URL below\n" +
172
+ "2. Receive: 'When a new channel message is added' → HTTP POST to your Talon endpoint",
173
+ "Teams Setup",
174
+ );
175
+
176
+ const url = await p.text({
177
+ message: "Power Automate webhook URL (for sending to Teams)",
178
+ placeholder: "https://prod-XX.westus.logic.azure.com/workflows/...",
179
+ initialValue: config.teamsWebhookUrl || undefined,
180
+ validate: (v) => {
181
+ if (!v) return "Webhook URL is required";
182
+ try { new URL(v); } catch { return "Must be a valid URL"; }
183
+ },
184
+ });
185
+ if (p.isCancel(url)) { p.cancel("Cancelled."); process.exit(0); }
186
+ teamsWebhookUrl = url;
187
+
188
+ const secret = await p.text({
189
+ message: "Webhook secret for inbound verification",
190
+ placeholder: "optional — shared secret to verify incoming webhooks",
191
+ initialValue: config.teamsWebhookSecret || "",
192
+ }) as string;
193
+ if (p.isCancel(secret)) { p.cancel("Cancelled."); process.exit(0); }
194
+ if (secret) teamsWebhookSecret = secret;
195
+
196
+ const port = await p.text({
197
+ message: "Webhook receiver port",
198
+ placeholder: "19878",
199
+ initialValue: config.teamsWebhookPort ? String(config.teamsWebhookPort) : "19878",
200
+ validate: (v) => {
201
+ if (!v) return "Port is required";
202
+ const n = parseInt(v, 10);
203
+ if (isNaN(n) || n < 1024 || n > 65535) return "Port must be 1024-65535";
204
+ },
205
+ });
206
+ if (p.isCancel(port)) { p.cancel("Cancelled."); process.exit(0); }
207
+ teamsWebhookPort = parseInt(port as string, 10);
208
+
209
+ const botName = await p.text({
210
+ message: "Bot display name in Teams (for echo loop prevention)",
211
+ placeholder: "optional — e.g. 'Talon Bot'",
212
+ initialValue: config.teamsBotDisplayName || "",
213
+ }) as string;
214
+ if (p.isCancel(botName)) { p.cancel("Cancelled."); process.exit(0); }
215
+ if (botName) teamsBotDisplayName = botName;
216
+ }
217
+
218
+ const model = await p.select({
219
+ message: "Default model",
220
+ initialValue: config.model,
221
+ options: [
222
+ { value: "claude-sonnet-4-6", label: `Sonnet 4.6 ${pc.dim("\u2014 fast, balanced")}` },
223
+ { value: "claude-opus-4-6", label: `Opus 4.6 ${pc.dim("\u2014 smartest")}` },
224
+ { value: "claude-haiku-4-5", label: `Haiku 4.5 ${pc.dim("\u2014 fastest, cheapest")}` },
225
+ ],
226
+ });
227
+ if (p.isCancel(model)) { p.cancel("Cancelled."); process.exit(0); }
228
+
229
+ const pulse = !selectedFrontends.every((f) => f === "terminal") ? await p.confirm({
230
+ message: "Enable pulse? (periodic group engagement)",
231
+ initialValue: config.pulse,
232
+ }) : false;
233
+ if (p.isCancel(pulse)) { p.cancel("Cancelled."); process.exit(0); }
234
+
235
+ // ── Claude binary path ──
236
+ const claudeBinaryInput = await p.text({
237
+ message: "Claude Code binary path",
238
+ placeholder: "leave empty for default (claude)",
239
+ initialValue: config.claudeBinary || "",
240
+ });
241
+ if (p.isCancel(claudeBinaryInput)) { p.cancel("Cancelled."); process.exit(0); }
242
+ const claudeBinary = (claudeBinaryInput as string).trim() || undefined;
243
+
244
+ const newConfig: Config = {
245
+ frontend: selectedFrontends.length === 1 ? selectedFrontends[0] : selectedFrontends,
246
+ botToken: selectedFrontends.includes("telegram") ? botToken : undefined,
247
+ claudeBinary,
248
+ model: model as string,
249
+ concurrency: config.concurrency,
250
+ pulse: pulse as boolean,
251
+ pulseIntervalMs: config.pulseIntervalMs,
252
+ adminUserId: adminId ? parseInt(adminId, 10) || undefined : undefined,
253
+ apiId, apiHash,
254
+ maxMessageLength: config.maxMessageLength,
255
+ plugins: config.plugins,
256
+ // Teams
257
+ teamsWebhookUrl: selectedFrontends.includes("teams") ? teamsWebhookUrl : undefined,
258
+ teamsWebhookSecret: selectedFrontends.includes("teams") ? teamsWebhookSecret : undefined,
259
+ teamsWebhookPort: selectedFrontends.includes("teams") ? teamsWebhookPort : undefined,
260
+ teamsBotDisplayName: selectedFrontends.includes("teams") ? teamsBotDisplayName : undefined,
261
+ };
262
+
263
+ const s = p.spinner();
264
+ s.start("Saving configuration");
265
+ saveConfig(newConfig);
266
+ s.stop("Configuration saved");
267
+
268
+ p.outro(`Run ${pc.cyan(pc.bold("talon start"))} to launch Talon`);
269
+
270
+ if (selectedFrontends.includes("telegram") && apiId && apiHash) {
271
+ console.log(` ${pc.yellow("!")} Run ${pc.cyan("npx tsx src/login.ts")} to authenticate the userbot first.\n`);
272
+ }
273
+ }
274
+
275
+ // ── Status ──────────────────────────────────────────────────────────────────
276
+
277
+ async function showStatus(): Promise<void> {
278
+ printBanner();
279
+ try {
280
+ const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(2000) });
281
+ if (resp.ok) {
282
+ const h = await resp.json() as Record<string, unknown>;
283
+ const ok = h.ok as boolean;
284
+ console.log(` ${ok ? pc.green("\u25CF") : pc.yellow("\u25CF")} ${pc.bold("Running")} ${ok ? pc.green("healthy") : pc.yellow("degraded")}`);
285
+ console.log();
286
+ console.log(` ${pc.dim("Uptime")} ${formatUptime(h.uptime as number)}`);
287
+ console.log(` ${pc.dim("Memory")} ${h.memory} MB`);
288
+ console.log(` ${pc.dim("Sessions")} ${h.sessions}`);
289
+ console.log(` ${pc.dim("Messages")} ${h.messages}`);
290
+ console.log(` ${pc.dim("Queue")} ${h.queue} pending`);
291
+ console.log(` ${pc.dim("Errors")} ${h.errors}`);
292
+ console.log(` ${pc.dim("Last active")} ${h.lastActivity}\n`);
293
+ return;
294
+ }
295
+ } catch { /* not running */ }
296
+
297
+ console.log(` ${pc.red("\u25CF")} ${pc.bold("Stopped")}\n`);
298
+ if (existsSync(CONFIG_FILE)) {
299
+ const config = loadConfig();
300
+ const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
301
+ console.log(` ${pc.dim("Frontend")} ${fes.join(", ")}`);
302
+ if (fes.includes("telegram")) console.log(` ${pc.dim("Token")} ${config.botToken ? pc.green("configured") : pc.red("not set")}`);
303
+ if (fes.includes("teams")) console.log(` ${pc.dim("Teams")} ${config.teamsWebhookUrl ? pc.green("configured") : pc.red("not set")}`);
304
+ console.log(` ${pc.dim("Model")} ${config.model}`);
305
+ console.log(` ${pc.dim("Config")} ${pc.dim(CONFIG_FILE)}\n`);
306
+ console.log(` Start with ${pc.cyan("talon start")} or ${pc.cyan("talon chat")}\n`);
307
+ } else {
308
+ console.log(` Run ${pc.cyan("talon setup")} to get started.\n`);
309
+ }
310
+ }
311
+
312
+ function formatUptime(seconds: number): string {
313
+ if (seconds < 60) return `${seconds}s`;
314
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
315
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
316
+ }
317
+
318
+ // ── Config viewer ───────────────────────────────────────────────────────────
319
+
320
+ async function viewConfig(): Promise<void> {
321
+ printBanner();
322
+ if (!existsSync(CONFIG_FILE)) { console.log(` No config found. Running setup...\n`); await runSetup(); return; }
323
+ const config = loadConfig();
324
+ p.intro(pc.inverse(" Configuration "));
325
+ console.log();
326
+ console.log(` ${pc.dim("File")} ${pc.dim(CONFIG_FILE)}`);
327
+ const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
328
+ console.log(` ${pc.dim("Frontend")} ${fes.join(", ")}`);
329
+ if (fes.includes("telegram")) {
330
+ console.log(` ${pc.dim("Bot token")} ${maskToken(config.botToken)}`);
331
+ console.log(` ${pc.dim("Admin")} ${config.adminUserId || pc.dim("not set")}`);
332
+ console.log(` ${pc.dim("Userbot")} ${config.apiId ? pc.green("configured") : pc.dim("not set")}`);
333
+ }
334
+ if (fes.includes("teams")) {
335
+ console.log(` ${pc.dim("Teams webhook")} ${config.teamsWebhookUrl ? pc.green("configured") : pc.red("not set")}`);
336
+ console.log(` ${pc.dim("Teams secret")} ${config.teamsWebhookSecret ? pc.green("set") : pc.dim("not set")}`);
337
+ console.log(` ${pc.dim("Teams port")} ${config.teamsWebhookPort || 19878}`);
338
+ console.log(` ${pc.dim("Teams bot name")} ${config.teamsBotDisplayName || pc.dim("not set")}`);
339
+ }
340
+ if (config.claudeBinary) console.log(` ${pc.dim("Claude binary")} ${pc.green(config.claudeBinary)}`);
341
+ console.log(` ${pc.dim("Model")} ${config.model}`);
342
+ console.log(` ${pc.dim("Concurrency")} ${config.concurrency}`);
343
+ console.log(` ${pc.dim("Pulse")} ${config.pulse ? pc.green("on") : pc.dim("off")} ${pc.dim(`(${Math.round(config.pulseIntervalMs / 60000)}m)`)}`);
344
+ if (config.plugins && config.plugins.length > 0) console.log(` ${pc.dim("Plugins")} ${config.plugins.length} loaded`);
345
+ console.log();
346
+ const action = await p.select({ message: "Action", options: [{ value: "edit", label: "Edit", hint: "re-run setup wizard" }, { value: "done", label: "Done" }] });
347
+ if (action === "edit") await runSetup();
348
+ }
349
+
350
+ // ── Log viewer ──────────────────────────────────────────────────────────────
351
+
352
+ const LEVEL_LABELS: Record<number, string> = { 10: pc.dim("TRC"), 20: pc.dim("DBG"), 30: pc.blue("INF"), 40: pc.yellow("WRN"), 50: pc.red("ERR"), 60: pc.bgRed(pc.white("FTL")) };
353
+
354
+ function formatLogLine(line: string): string {
355
+ try {
356
+ const obj = JSON.parse(line);
357
+ const level = LEVEL_LABELS[obj.level as number] ?? pc.dim("???");
358
+ const time = pc.dim(new Date(obj.time as number).toTimeString().slice(0, 8));
359
+ const comp = pc.cyan((obj.component as string ?? "?").padEnd(10));
360
+ return ` ${time} ${level} ${comp} ${obj.msg}${obj.err ? pc.red(` (${obj.err})`) : ""}`;
361
+ } catch { return ` ${line}`; }
362
+ }
363
+
364
+ async function tailLogs(): Promise<void> {
365
+ printBanner();
366
+ if (!existsSync(LOG_FILE)) { console.log(` No log file. Start the bot first: ${pc.cyan("talon start")}\n`); return; }
367
+ console.log(` ${pc.dim("Tailing")} ${pc.dim(LOG_FILE)}\n ${pc.dim("Press Ctrl+C to stop")}\n`);
368
+ const content = readFileSync(LOG_FILE, "utf-8");
369
+ const lines = content.trim().split("\n");
370
+ for (const line of lines.slice(-30)) console.log(formatLogLine(line));
371
+ let lastSize = lines.length;
372
+ watchFile(LOG_FILE, { interval: 500 }, () => {
373
+ try { const nl = readFileSync(LOG_FILE, "utf-8").trim().split("\n"); for (let i = lastSize; i < nl.length; i++) console.log(formatLogLine(nl[i])); lastSize = nl.length; } catch { /* ignore */ }
374
+ });
375
+ await new Promise(() => {});
376
+ }
377
+
378
+ // ── Doctor ──────────────────────────────────────────────────────────────────
379
+
380
+ async function runDoctor(): Promise<void> {
381
+ printBanner();
382
+ console.log(` ${pc.bold("Environment check")}\n`);
383
+ let issues = 0;
384
+ const major = parseInt(process.versions.node.split(".")[0], 10);
385
+ console.log(major >= 22 ? ` ${pc.green("\u2713")} Node.js ${process.versions.node}` : ` ${pc.red("\u2717")} Node.js ${process.versions.node} ${pc.dim("(need >=22)")}`);
386
+ if (major < 22) issues++;
387
+ if (existsSync(CONFIG_FILE)) {
388
+ const config = loadConfig();
389
+ const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
390
+ console.log(isConfigured(config) ? ` ${pc.green("\u2713")} Frontend: ${fes.join(", ")} (configured)` : ` ${pc.red("\u2717")} Frontend not fully configured`);
391
+ if (!isConfigured(config)) issues++;
392
+ } else { console.log(` ${pc.red("\u2717")} No config file`); issues++; }
393
+ console.log(existsSync(dirs.root) ? ` ${pc.green("\u2713")} Workspace: ${pc.dim(dirs.root)}` : ` ${pc.yellow("!")} Workspace missing`);
394
+ try {
395
+ const { execSync } = await import("node:child_process");
396
+ const doctorConfig = existsSync(CONFIG_FILE) ? loadConfig() : undefined;
397
+ if (doctorConfig?.claudeBinary) {
398
+ // Check if it's a PATH command or an absolute/relative file path
399
+ const cmd = process.platform === "win32" ? "where" : "which";
400
+ try {
401
+ execSync(`${cmd} ${doctorConfig.claudeBinary}`, { stdio: "pipe" });
402
+ console.log(` ${pc.green("\u2713")} Claude Code binary: ${pc.dim(doctorConfig.claudeBinary)}`);
403
+ } catch {
404
+ console.log(` ${pc.red("\u2717")} Claude Code binary not found: ${pc.dim(doctorConfig.claudeBinary)}`);
405
+ issues++;
406
+ }
407
+ } else {
408
+ execSync(process.platform === "win32" ? "where claude" : "which claude", { stdio: "pipe" });
409
+ console.log(` ${pc.green("\u2713")} Claude Code installed`);
410
+ }
411
+ } catch { console.log(` ${pc.red("\u2717")} Claude Code not found`); issues++; }
412
+ try { const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(2000) }); if (resp.ok) console.log(` ${pc.green("\u2713")} Bot is running`); } catch { console.log(` ${pc.dim("-")} Bot is not running`); }
413
+ console.log(issues === 0 ? `\n ${pc.green("All checks passed.")}\n` : `\n ${pc.yellow(`${issues} issue(s) found.`)}\n`);
414
+ }
415
+
416
+ // ── Terminal chat ───────────────────────────────────────────────────────────
417
+
418
+ async function startChat(): Promise<void> {
419
+ process.env.TALON_QUIET = "1";
420
+
421
+ const { bootstrap, initBackendAndDispatcher } = await import("./bootstrap.js");
422
+ const { flushSessions } = await import("./storage/sessions.js");
423
+ const { flushChatSettings } = await import("./storage/chat-settings.js");
424
+ const { flushCronJobs } = await import("./storage/cron-store.js");
425
+ const { flushHistory } = await import("./storage/history.js");
426
+ const { flushMediaIndex } = await import("./storage/media-index.js");
427
+ const { createTerminalFrontend } = await import("./frontend/terminal/index.js");
428
+ const { Gateway } = await import("./core/gateway.js");
429
+
430
+ const { config } = await bootstrap({ frontendNames: ["terminal"] });
431
+
432
+ // Override frontend for the backend — talon chat always uses terminal,
433
+ // regardless of what the config file says. This prevents the backend from
434
+ // spawning telegram-tools or teams-tools MCP servers and ensures the
435
+ // system prompt loads terminal.md instead of teams.md/telegram.md.
436
+ (config as Record<string, unknown>).frontend = "terminal";
437
+ const { rebuildSystemPrompt } = await import("./util/config.js");
438
+ const { getPluginPromptAdditions } = await import("./core/plugin.js");
439
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
440
+
441
+ const gateway = new Gateway();
442
+ const frontend = createTerminalFrontend(config, gateway);
443
+ await frontend.init();
444
+ await initBackendAndDispatcher(config, frontend);
445
+
446
+ process.on("SIGINT", () => {
447
+ flushSessions();
448
+ flushChatSettings();
449
+ flushCronJobs();
450
+ flushHistory();
451
+ flushMediaIndex();
452
+ frontend.stop();
453
+ process.exit(0);
454
+ });
455
+ await frontend.start();
456
+ }
457
+
458
+ // ── Main menu ───────────────────────────────────────────────────────────────
459
+
460
+ async function mainMenu(): Promise<void> {
461
+ printBanner();
462
+ if (!existsSync(CONFIG_FILE) || !isConfigured(loadConfig())) {
463
+ p.intro(pc.inverse(" Welcome to Talon "));
464
+ p.note("Talon is an agentic AI harness.\nSupports Telegram and Terminal.\nLet's get you set up.", "First time?");
465
+ await runSetup();
466
+ return;
467
+ }
468
+
469
+ let running = false;
470
+ try { const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(1000) }); running = resp.ok; } catch { /* not running */ }
471
+ const config = loadConfig();
472
+ const statusDot = running ? `${pc.green("\u25CF")} running` : `${pc.red("\u25CF")} stopped`;
473
+ const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
474
+ const frontendLabel = fes.map((f) => f === "telegram" ? "Telegram" : f === "teams" ? "Teams" : "Terminal").join(" + ");
475
+
476
+ const action = await p.select({
477
+ message: `Talon ${statusDot} ${pc.dim(`(${frontendLabel})`)}`,
478
+ options: [
479
+ ...(!running ? [{ value: "start" as const, label: `Start ${frontendLabel}`, hint: "background daemon" }] : []),
480
+ ...(running ? [{ value: "restart" as const, label: "Restart" }, { value: "stop" as const, label: "Stop" }] : []),
481
+ { value: "chat", label: "Chat in terminal", hint: "talk to Talon here" },
482
+ { value: "status", label: "Status", hint: "health and stats" },
483
+ { value: "config", label: "Config", hint: "view or edit" },
484
+ { value: "logs", label: "Logs", hint: "tail live" },
485
+ { value: "setup", label: "Setup", hint: "re-run wizard" },
486
+ ],
487
+ });
488
+ if (p.isCancel(action)) process.exit(0);
489
+ switch (action) {
490
+ case "start": await daemonStart(); break;
491
+ case "stop": daemonStop(); break;
492
+ case "restart": await daemonRestart(); break;
493
+ case "chat": process.chdir(PKG_ROOT); await startChat(); break;
494
+ case "status": await showStatus(); break;
495
+ case "config": await viewConfig(); break;
496
+ case "logs": await tailLogs(); break;
497
+ case "setup": await runSetup(); break;
498
+ }
499
+ }
500
+
501
+ // ── Daemon management ───────────────────────────────────────────────────────
502
+
503
+ const PID_FILE = pathFiles.pid;
504
+
505
+ function readPid(): number | null {
506
+ try {
507
+ if (existsSync(PID_FILE)) {
508
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
509
+ if (!isNaN(pid) && pid > 0) return pid;
510
+ }
511
+ } catch { /* corrupt */ }
512
+ return null;
513
+ }
514
+
515
+ function isProcessRunning(pid: number): boolean {
516
+ try { process.kill(pid, 0); return true; } catch { return false; }
517
+ }
518
+
519
+ async function daemonStart(): Promise<void> {
520
+ const existingPid = readPid();
521
+ if (existingPid && isProcessRunning(existingPid)) {
522
+ console.log(` ${pc.yellow("!")} Talon is already running (PID ${existingPid})`);
523
+ console.log(` Use ${pc.cyan("talon restart")} to restart, or ${pc.cyan("talon stop")} to stop.\n`);
524
+ return;
525
+ }
526
+
527
+ const { spawn } = await import("node:child_process");
528
+ const entryScript = resolve(PKG_ROOT, "src", "index.ts");
529
+
530
+ // Spawn detached process with stdio piped to /dev/null
531
+ // Use node with tsx's ESM loader to avoid .cmd wrapper issues on Windows
532
+ const tsxImport = resolve(PKG_ROOT, "node_modules", "tsx", "dist", "esm", "index.mjs");
533
+ const child = spawn(process.execPath, ["--import", tsxImport, entryScript], {
534
+ cwd: PKG_ROOT,
535
+ detached: true,
536
+ stdio: "ignore",
537
+ env: { ...process.env },
538
+ });
539
+
540
+ child.unref();
541
+
542
+ if (child.pid) {
543
+ if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
544
+ writeFileSync(PID_FILE, String(child.pid));
545
+ console.log(` ${pc.green("●")} Talon started (PID ${child.pid})`);
546
+ console.log(` ${pc.dim("Logs:")} talon logs`);
547
+ console.log(` ${pc.dim("Stop:")} talon stop\n`);
548
+ } else {
549
+ console.log(` ${pc.red("✖")} Failed to start Talon\n`);
550
+ }
551
+ }
552
+
553
+ function daemonStop(): boolean {
554
+ const pid = readPid();
555
+ if (!pid || !isProcessRunning(pid)) {
556
+ console.log(` ${pc.dim("●")} Talon is not running\n`);
557
+ try { unlinkSync(PID_FILE); } catch { /* ok */ }
558
+ return false;
559
+ }
560
+
561
+ process.kill(pid, "SIGTERM");
562
+ console.log(` ${pc.red("●")} Talon stopped (PID ${pid})`);
563
+ try { unlinkSync(PID_FILE); } catch { /* ok */ }
564
+ return true;
565
+ }
566
+
567
+ async function daemonRestart(): Promise<void> {
568
+ const was = daemonStop();
569
+ if (was) {
570
+ // Wait for graceful shutdown
571
+ await new Promise((r) => setTimeout(r, 2000));
572
+ }
573
+ await daemonStart();
574
+ }
575
+
576
+ // ── Entry point ─────────────────────────────────────────────────────────────
577
+
578
+ const command = process.argv[2];
579
+ switch (command) {
580
+ case "setup": runSetup(); break;
581
+ case "status": showStatus(); break;
582
+ case "config": viewConfig(); break;
583
+ case "logs": tailLogs(); break;
584
+ case "start": printBanner(); await daemonStart(); break;
585
+ case "stop": printBanner(); daemonStop(); break;
586
+ case "restart": printBanner(); await daemonRestart(); break;
587
+ case "run": process.chdir(PKG_ROOT); import("./index.js"); break;
588
+ case "chat": process.chdir(PKG_ROOT); startChat(); break;
589
+ case "doctor": runDoctor(); break;
590
+ case "--help": case "-h":
591
+ printBanner();
592
+ console.log(" Usage: talon [command]\n");
593
+ console.log(" Commands:");
594
+ console.log(` ${pc.cyan("setup")} Guided setup wizard`);
595
+ console.log(` ${pc.cyan("start")} Start as background daemon`);
596
+ console.log(` ${pc.cyan("stop")} Stop the daemon`);
597
+ console.log(` ${pc.cyan("restart")} Restart the daemon`);
598
+ console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
599
+ console.log(` ${pc.cyan("chat")} Terminal chat mode`);
600
+ console.log(` ${pc.cyan("status")} Show bot health`);
601
+ console.log(` ${pc.cyan("config")} View/edit configuration`);
602
+ console.log(` ${pc.cyan("logs")} Tail log file`);
603
+ console.log(` ${pc.cyan("doctor")} Validate environment`);
604
+ console.log();
605
+ console.log(` Run ${pc.cyan("talon")} with no args for interactive menu.\n`);
606
+ break;
607
+ case undefined: mainMenu(); break;
608
+ default:
609
+ console.error(` Unknown command: ${command}\n Run ${pc.cyan("talon --help")} for usage.\n`);
610
+ process.exit(1);
611
+ }