talon-agent 1.0.0 → 1.2.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
package/src/cli.ts CHANGED
@@ -14,7 +14,14 @@
14
14
 
15
15
  import * as p from "@clack/prompts";
16
16
  import pc from "picocolors";
17
- import { existsSync, readFileSync, mkdirSync, watchFile, writeFileSync, unlinkSync } from "node:fs";
17
+ import {
18
+ existsSync,
19
+ readFileSync,
20
+ mkdirSync,
21
+ watchFile,
22
+ writeFileSync,
23
+ unlinkSync,
24
+ } from "node:fs";
18
25
  import { resolve } from "node:path";
19
26
  import writeFileAtomic from "write-file-atomic";
20
27
  import { dirs, files as pathFiles } from "./util/paths.js";
@@ -65,7 +72,9 @@ function loadConfig(): Config {
65
72
  if (existsSync(CONFIG_FILE)) {
66
73
  return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) };
67
74
  }
68
- } catch { /* corrupt */ }
75
+ } catch {
76
+ /* corrupt */
77
+ }
69
78
  return { ...DEFAULTS };
70
79
  }
71
80
 
@@ -83,7 +92,9 @@ function maskToken(token: string | undefined): string {
83
92
  }
84
93
 
85
94
  function isConfigured(config: Config): boolean {
86
- const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
95
+ const fes = Array.isArray(config.frontend)
96
+ ? config.frontend
97
+ : [config.frontend];
87
98
  return fes.every((fe) => {
88
99
  if (fe === "telegram") return !!config.botToken;
89
100
  if (fe === "terminal") return true;
@@ -99,19 +110,33 @@ async function runSetup(): Promise<void> {
99
110
  p.intro(pc.inverse(" Setup Wizard "));
100
111
 
101
112
  const config = loadConfig();
102
- const existingFrontends = Array.isArray(config.frontend) ? config.frontend : [config.frontend || "telegram"];
113
+ const existingFrontends = Array.isArray(config.frontend)
114
+ ? config.frontend
115
+ : [config.frontend || "telegram"];
103
116
 
104
117
  const frontendSelection = await p.multiselect({
105
118
  message: "Frontend platforms (space to toggle, enter to confirm)",
106
119
  initialValues: existingFrontends,
107
120
  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")}` },
121
+ {
122
+ value: "telegram",
123
+ label: `Telegram ${pc.dim("\u2014 bot via @BotFather")}`,
124
+ },
125
+ {
126
+ value: "terminal",
127
+ label: `Terminal ${pc.dim("\u2014 local CLI chat")}`,
128
+ },
129
+ {
130
+ value: "teams",
131
+ label: `Teams ${pc.dim("\u2014 Microsoft Teams via Power Automate")}`,
132
+ },
111
133
  ],
112
134
  required: true,
113
135
  });
114
- if (p.isCancel(frontendSelection)) { p.cancel("Cancelled."); process.exit(0); }
136
+ if (p.isCancel(frontendSelection)) {
137
+ p.cancel("Cancelled.");
138
+ process.exit(0);
139
+ }
115
140
  const selectedFrontends = frontendSelection as string[];
116
141
 
117
142
  let botToken: string | undefined;
@@ -129,32 +154,56 @@ async function runSetup(): Promise<void> {
129
154
  if (!v.includes(":")) return "Invalid format";
130
155
  },
131
156
  });
132
- if (p.isCancel(token)) { p.cancel("Cancelled."); process.exit(0); }
157
+ if (p.isCancel(token)) {
158
+ p.cancel("Cancelled.");
159
+ process.exit(0);
160
+ }
133
161
  botToken = token;
134
162
 
135
- adminId = await p.text({
163
+ adminId = (await p.text({
136
164
  message: "Your Telegram user ID",
137
165
  placeholder: "optional \u2014 message @userinfobot to find yours",
138
166
  initialValue: config.adminUserId ? String(config.adminUserId) : "",
139
- }) as string;
140
- if (p.isCancel(adminId)) { p.cancel("Cancelled."); process.exit(0); }
167
+ })) as string;
168
+ if (p.isCancel(adminId)) {
169
+ p.cancel("Cancelled.");
170
+ process.exit(0);
171
+ }
141
172
 
142
173
  const wantUserbot = await p.confirm({
143
174
  message: "Set up userbot for full history access?",
144
175
  initialValue: !!(config.apiId && config.apiHash),
145
176
  });
146
- if (p.isCancel(wantUserbot)) { p.cancel("Cancelled."); process.exit(0); }
177
+ if (p.isCancel(wantUserbot)) {
178
+ p.cancel("Cancelled.");
179
+ process.exit(0);
180
+ }
147
181
 
148
182
  if (wantUserbot) {
149
- p.note("Get these from https://my.telegram.org \u2192 API development tools", "Telegram API credentials");
183
+ p.note(
184
+ "Get these from https://my.telegram.org \u2192 API development tools",
185
+ "Telegram API credentials",
186
+ );
150
187
  const id = await p.text({
151
- message: "API ID", placeholder: "12345678",
188
+ message: "API ID",
189
+ placeholder: "12345678",
152
190
  initialValue: config.apiId ? String(config.apiId) : "",
153
- validate: (v) => { if (v && isNaN(parseInt(v, 10))) return "Must be a number"; },
191
+ validate: (v) => {
192
+ if (v && isNaN(parseInt(v, 10))) return "Must be a number";
193
+ },
154
194
  });
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); }
195
+ if (p.isCancel(id)) {
196
+ p.cancel("Cancelled.");
197
+ process.exit(0);
198
+ }
199
+ const hash = await p.text({
200
+ message: "API Hash",
201
+ initialValue: config.apiHash || "",
202
+ });
203
+ if (p.isCancel(hash)) {
204
+ p.cancel("Cancelled.");
205
+ process.exit(0);
206
+ }
158
207
  if (id) apiId = parseInt(id, 10);
159
208
  if (hash) apiHash = hash as string;
160
209
  }
@@ -168,8 +217,8 @@ async function runSetup(): Promise<void> {
168
217
  if (selectedFrontends.includes("teams")) {
169
218
  p.note(
170
219
  "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",
220
+ "1. Send: 'Post to a channel when a webhook request is received' — copy the URL below\n" +
221
+ "2. Receive: 'When a new channel message is added' → HTTP POST to your Talon endpoint",
173
222
  "Teams Setup",
174
223
  );
175
224
 
@@ -179,39 +228,57 @@ async function runSetup(): Promise<void> {
179
228
  initialValue: config.teamsWebhookUrl || undefined,
180
229
  validate: (v) => {
181
230
  if (!v) return "Webhook URL is required";
182
- try { new URL(v); } catch { return "Must be a valid URL"; }
231
+ try {
232
+ new URL(v);
233
+ } catch {
234
+ return "Must be a valid URL";
235
+ }
183
236
  },
184
237
  });
185
- if (p.isCancel(url)) { p.cancel("Cancelled."); process.exit(0); }
238
+ if (p.isCancel(url)) {
239
+ p.cancel("Cancelled.");
240
+ process.exit(0);
241
+ }
186
242
  teamsWebhookUrl = url;
187
243
 
188
- const secret = await p.text({
244
+ const secret = (await p.text({
189
245
  message: "Webhook secret for inbound verification",
190
246
  placeholder: "optional — shared secret to verify incoming webhooks",
191
247
  initialValue: config.teamsWebhookSecret || "",
192
- }) as string;
193
- if (p.isCancel(secret)) { p.cancel("Cancelled."); process.exit(0); }
248
+ })) as string;
249
+ if (p.isCancel(secret)) {
250
+ p.cancel("Cancelled.");
251
+ process.exit(0);
252
+ }
194
253
  if (secret) teamsWebhookSecret = secret;
195
254
 
196
255
  const port = await p.text({
197
256
  message: "Webhook receiver port",
198
257
  placeholder: "19878",
199
- initialValue: config.teamsWebhookPort ? String(config.teamsWebhookPort) : "19878",
258
+ initialValue: config.teamsWebhookPort
259
+ ? String(config.teamsWebhookPort)
260
+ : "19878",
200
261
  validate: (v) => {
201
262
  if (!v) return "Port is required";
202
263
  const n = parseInt(v, 10);
203
264
  if (isNaN(n) || n < 1024 || n > 65535) return "Port must be 1024-65535";
204
265
  },
205
266
  });
206
- if (p.isCancel(port)) { p.cancel("Cancelled."); process.exit(0); }
267
+ if (p.isCancel(port)) {
268
+ p.cancel("Cancelled.");
269
+ process.exit(0);
270
+ }
207
271
  teamsWebhookPort = parseInt(port as string, 10);
208
272
 
209
- const botName = await p.text({
273
+ const botName = (await p.text({
210
274
  message: "Bot display name in Teams (for echo loop prevention)",
211
275
  placeholder: "optional — e.g. 'Talon Bot'",
212
276
  initialValue: config.teamsBotDisplayName || "",
213
- }) as string;
214
- if (p.isCancel(botName)) { p.cancel("Cancelled."); process.exit(0); }
277
+ })) as string;
278
+ if (p.isCancel(botName)) {
279
+ p.cancel("Cancelled.");
280
+ process.exit(0);
281
+ }
215
282
  if (botName) teamsBotDisplayName = botName;
216
283
  }
217
284
 
@@ -219,18 +286,35 @@ async function runSetup(): Promise<void> {
219
286
  message: "Default model",
220
287
  initialValue: config.model,
221
288
  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")}` },
289
+ {
290
+ value: "claude-sonnet-4-6",
291
+ label: `Sonnet 4.6 ${pc.dim("\u2014 fast, balanced")}`,
292
+ },
293
+ {
294
+ value: "claude-opus-4-6",
295
+ label: `Opus 4.6 ${pc.dim("\u2014 smartest")}`,
296
+ },
297
+ {
298
+ value: "claude-haiku-4-5",
299
+ label: `Haiku 4.5 ${pc.dim("\u2014 fastest, cheapest")}`,
300
+ },
225
301
  ],
226
302
  });
227
- if (p.isCancel(model)) { p.cancel("Cancelled."); process.exit(0); }
303
+ if (p.isCancel(model)) {
304
+ p.cancel("Cancelled.");
305
+ process.exit(0);
306
+ }
228
307
 
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); }
308
+ const pulse = !selectedFrontends.every((f) => f === "terminal")
309
+ ? await p.confirm({
310
+ message: "Enable pulse? (periodic group engagement)",
311
+ initialValue: config.pulse,
312
+ })
313
+ : false;
314
+ if (p.isCancel(pulse)) {
315
+ p.cancel("Cancelled.");
316
+ process.exit(0);
317
+ }
234
318
 
235
319
  // ── Claude binary path ──
236
320
  const claudeBinaryInput = await p.text({
@@ -238,11 +322,15 @@ async function runSetup(): Promise<void> {
238
322
  placeholder: "leave empty for default (claude)",
239
323
  initialValue: config.claudeBinary || "",
240
324
  });
241
- if (p.isCancel(claudeBinaryInput)) { p.cancel("Cancelled."); process.exit(0); }
325
+ if (p.isCancel(claudeBinaryInput)) {
326
+ p.cancel("Cancelled.");
327
+ process.exit(0);
328
+ }
242
329
  const claudeBinary = (claudeBinaryInput as string).trim() || undefined;
243
330
 
244
331
  const newConfig: Config = {
245
- frontend: selectedFrontends.length === 1 ? selectedFrontends[0] : selectedFrontends,
332
+ frontend:
333
+ selectedFrontends.length === 1 ? selectedFrontends[0] : selectedFrontends,
246
334
  botToken: selectedFrontends.includes("telegram") ? botToken : undefined,
247
335
  claudeBinary,
248
336
  model: model as string,
@@ -250,14 +338,23 @@ async function runSetup(): Promise<void> {
250
338
  pulse: pulse as boolean,
251
339
  pulseIntervalMs: config.pulseIntervalMs,
252
340
  adminUserId: adminId ? parseInt(adminId, 10) || undefined : undefined,
253
- apiId, apiHash,
341
+ apiId,
342
+ apiHash,
254
343
  maxMessageLength: config.maxMessageLength,
255
344
  plugins: config.plugins,
256
345
  // 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,
346
+ teamsWebhookUrl: selectedFrontends.includes("teams")
347
+ ? teamsWebhookUrl
348
+ : undefined,
349
+ teamsWebhookSecret: selectedFrontends.includes("teams")
350
+ ? teamsWebhookSecret
351
+ : undefined,
352
+ teamsWebhookPort: selectedFrontends.includes("teams")
353
+ ? teamsWebhookPort
354
+ : undefined,
355
+ teamsBotDisplayName: selectedFrontends.includes("teams")
356
+ ? teamsBotDisplayName
357
+ : undefined,
261
358
  };
262
359
 
263
360
  const s = p.spinner();
@@ -268,7 +365,9 @@ async function runSetup(): Promise<void> {
268
365
  p.outro(`Run ${pc.cyan(pc.bold("talon start"))} to launch Talon`);
269
366
 
270
367
  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`);
368
+ console.log(
369
+ ` ${pc.yellow("!")} Run ${pc.cyan("npx tsx src/login.ts")} to authenticate the userbot first.\n`,
370
+ );
272
371
  }
273
372
  }
274
373
 
@@ -279,11 +378,15 @@ async function showStatus(): Promise<void> {
279
378
  try {
280
379
  const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(2000) });
281
380
  if (resp.ok) {
282
- const h = await resp.json() as Record<string, unknown>;
381
+ const h = (await resp.json()) as Record<string, unknown>;
283
382
  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")}`);
383
+ console.log(
384
+ ` ${ok ? pc.green("\u25CF") : pc.yellow("\u25CF")} ${pc.bold("Running")} ${ok ? pc.green("healthy") : pc.yellow("degraded")}`,
385
+ );
285
386
  console.log();
286
- console.log(` ${pc.dim("Uptime")} ${formatUptime(h.uptime as number)}`);
387
+ console.log(
388
+ ` ${pc.dim("Uptime")} ${formatUptime(h.uptime as number)}`,
389
+ );
287
390
  console.log(` ${pc.dim("Memory")} ${h.memory} MB`);
288
391
  console.log(` ${pc.dim("Sessions")} ${h.sessions}`);
289
392
  console.log(` ${pc.dim("Messages")} ${h.messages}`);
@@ -292,18 +395,30 @@ async function showStatus(): Promise<void> {
292
395
  console.log(` ${pc.dim("Last active")} ${h.lastActivity}\n`);
293
396
  return;
294
397
  }
295
- } catch { /* not running */ }
398
+ } catch {
399
+ /* not running */
400
+ }
296
401
 
297
402
  console.log(` ${pc.red("\u25CF")} ${pc.bold("Stopped")}\n`);
298
403
  if (existsSync(CONFIG_FILE)) {
299
404
  const config = loadConfig();
300
- const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
405
+ const fes = Array.isArray(config.frontend)
406
+ ? config.frontend
407
+ : [config.frontend];
301
408
  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")}`);
409
+ if (fes.includes("telegram"))
410
+ console.log(
411
+ ` ${pc.dim("Token")} ${config.botToken ? pc.green("configured") : pc.red("not set")}`,
412
+ );
413
+ if (fes.includes("teams"))
414
+ console.log(
415
+ ` ${pc.dim("Teams")} ${config.teamsWebhookUrl ? pc.green("configured") : pc.red("not set")}`,
416
+ );
304
417
  console.log(` ${pc.dim("Model")} ${config.model}`);
305
418
  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`);
419
+ console.log(
420
+ ` Start with ${pc.cyan("talon start")} or ${pc.cyan("talon chat")}\n`,
421
+ );
307
422
  } else {
308
423
  console.log(` Run ${pc.cyan("talon setup")} to get started.\n`);
309
424
  }
@@ -319,58 +434,117 @@ function formatUptime(seconds: number): string {
319
434
 
320
435
  async function viewConfig(): Promise<void> {
321
436
  printBanner();
322
- if (!existsSync(CONFIG_FILE)) { console.log(` No config found. Running setup...\n`); await runSetup(); return; }
437
+ if (!existsSync(CONFIG_FILE)) {
438
+ console.log(` No config found. Running setup...\n`);
439
+ await runSetup();
440
+ return;
441
+ }
323
442
  const config = loadConfig();
324
443
  p.intro(pc.inverse(" Configuration "));
325
444
  console.log();
326
445
  console.log(` ${pc.dim("File")} ${pc.dim(CONFIG_FILE)}`);
327
- const fes = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
446
+ const fes = Array.isArray(config.frontend)
447
+ ? config.frontend
448
+ : [config.frontend];
328
449
  console.log(` ${pc.dim("Frontend")} ${fes.join(", ")}`);
329
450
  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")}`);
451
+ console.log(
452
+ ` ${pc.dim("Bot token")} ${maskToken(config.botToken)}`,
453
+ );
454
+ console.log(
455
+ ` ${pc.dim("Admin")} ${config.adminUserId || pc.dim("not set")}`,
456
+ );
457
+ console.log(
458
+ ` ${pc.dim("Userbot")} ${config.apiId ? pc.green("configured") : pc.dim("not set")}`,
459
+ );
333
460
  }
334
461
  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")}`);
462
+ console.log(
463
+ ` ${pc.dim("Teams webhook")} ${config.teamsWebhookUrl ? pc.green("configured") : pc.red("not set")}`,
464
+ );
465
+ console.log(
466
+ ` ${pc.dim("Teams secret")} ${config.teamsWebhookSecret ? pc.green("set") : pc.dim("not set")}`,
467
+ );
468
+ console.log(
469
+ ` ${pc.dim("Teams port")} ${config.teamsWebhookPort || 19878}`,
470
+ );
471
+ console.log(
472
+ ` ${pc.dim("Teams bot name")} ${config.teamsBotDisplayName || pc.dim("not set")}`,
473
+ );
339
474
  }
340
- if (config.claudeBinary) console.log(` ${pc.dim("Claude binary")} ${pc.green(config.claudeBinary)}`);
475
+ if (config.claudeBinary)
476
+ console.log(
477
+ ` ${pc.dim("Claude binary")} ${pc.green(config.claudeBinary)}`,
478
+ );
341
479
  console.log(` ${pc.dim("Model")} ${config.model}`);
342
480
  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`);
481
+ console.log(
482
+ ` ${pc.dim("Pulse")} ${config.pulse ? pc.green("on") : pc.dim("off")} ${pc.dim(`(${Math.round(config.pulseIntervalMs / 60000)}m)`)}`,
483
+ );
484
+ if (config.plugins && config.plugins.length > 0)
485
+ console.log(
486
+ ` ${pc.dim("Plugins")} ${config.plugins.length} loaded`,
487
+ );
345
488
  console.log();
346
- const action = await p.select({ message: "Action", options: [{ value: "edit", label: "Edit", hint: "re-run setup wizard" }, { value: "done", label: "Done" }] });
489
+ const action = await p.select({
490
+ message: "Action",
491
+ options: [
492
+ { value: "edit", label: "Edit", hint: "re-run setup wizard" },
493
+ { value: "done", label: "Done" },
494
+ ],
495
+ });
347
496
  if (action === "edit") await runSetup();
348
497
  }
349
498
 
350
499
  // ── Log viewer ──────────────────────────────────────────────────────────────
351
500
 
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")) };
501
+ const LEVEL_LABELS: Record<number, string> = {
502
+ 10: pc.dim("TRC"),
503
+ 20: pc.dim("DBG"),
504
+ 30: pc.blue("INF"),
505
+ 40: pc.yellow("WRN"),
506
+ 50: pc.red("ERR"),
507
+ 60: pc.bgRed(pc.white("FTL")),
508
+ };
353
509
 
354
510
  function formatLogLine(line: string): string {
355
511
  try {
356
512
  const obj = JSON.parse(line);
357
513
  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));
514
+ const time = pc.dim(
515
+ new Date(obj.time as number).toTimeString().slice(0, 8),
516
+ );
517
+ const comp = pc.cyan(((obj.component as string) ?? "?").padEnd(10));
360
518
  return ` ${time} ${level} ${comp} ${obj.msg}${obj.err ? pc.red(` (${obj.err})`) : ""}`;
361
- } catch { return ` ${line}`; }
519
+ } catch {
520
+ return ` ${line}`;
521
+ }
362
522
  }
363
523
 
364
524
  async function tailLogs(): Promise<void> {
365
525
  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`);
526
+ if (!existsSync(LOG_FILE)) {
527
+ console.log(
528
+ ` No log file. Start the bot first: ${pc.cyan("talon start")}\n`,
529
+ );
530
+ return;
531
+ }
532
+ console.log(
533
+ ` ${pc.dim("Tailing")} ${pc.dim(LOG_FILE)}\n ${pc.dim("Press Ctrl+C to stop")}\n`,
534
+ );
368
535
  const content = readFileSync(LOG_FILE, "utf-8");
369
536
  const lines = content.trim().split("\n");
370
537
  for (const line of lines.slice(-30)) console.log(formatLogLine(line));
371
538
  let lastSize = lines.length;
372
539
  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 */ }
540
+ try {
541
+ const nl = readFileSync(LOG_FILE, "utf-8").trim().split("\n");
542
+ for (let i = lastSize; i < nl.length; i++)
543
+ console.log(formatLogLine(nl[i]));
544
+ lastSize = nl.length;
545
+ } catch {
546
+ /* ignore */
547
+ }
374
548
  });
375
549
  await new Promise(() => {});
376
550
  }
@@ -382,15 +556,32 @@ async function runDoctor(): Promise<void> {
382
556
  console.log(` ${pc.bold("Environment check")}\n`);
383
557
  let issues = 0;
384
558
  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)")}`);
559
+ console.log(
560
+ major >= 22
561
+ ? ` ${pc.green("\u2713")} Node.js ${process.versions.node}`
562
+ : ` ${pc.red("\u2717")} Node.js ${process.versions.node} ${pc.dim("(need >=22)")}`,
563
+ );
386
564
  if (major < 22) issues++;
387
565
  if (existsSync(CONFIG_FILE)) {
388
566
  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`);
567
+ const fes = Array.isArray(config.frontend)
568
+ ? config.frontend
569
+ : [config.frontend];
570
+ console.log(
571
+ isConfigured(config)
572
+ ? ` ${pc.green("\u2713")} Frontend: ${fes.join(", ")} (configured)`
573
+ : ` ${pc.red("\u2717")} Frontend not fully configured`,
574
+ );
391
575
  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`);
576
+ } else {
577
+ console.log(` ${pc.red("\u2717")} No config file`);
578
+ issues++;
579
+ }
580
+ console.log(
581
+ existsSync(dirs.root)
582
+ ? ` ${pc.green("\u2713")} Workspace: ${pc.dim(dirs.root)}`
583
+ : ` ${pc.yellow("!")} Workspace missing`,
584
+ );
394
585
  try {
395
586
  const { execSync } = await import("node:child_process");
396
587
  const doctorConfig = existsSync(CONFIG_FILE) ? loadConfig() : undefined;
@@ -399,18 +590,36 @@ async function runDoctor(): Promise<void> {
399
590
  const cmd = process.platform === "win32" ? "where" : "which";
400
591
  try {
401
592
  execSync(`${cmd} ${doctorConfig.claudeBinary}`, { stdio: "pipe" });
402
- console.log(` ${pc.green("\u2713")} Claude Code binary: ${pc.dim(doctorConfig.claudeBinary)}`);
593
+ console.log(
594
+ ` ${pc.green("\u2713")} Claude Code binary: ${pc.dim(doctorConfig.claudeBinary)}`,
595
+ );
403
596
  } catch {
404
- console.log(` ${pc.red("\u2717")} Claude Code binary not found: ${pc.dim(doctorConfig.claudeBinary)}`);
597
+ console.log(
598
+ ` ${pc.red("\u2717")} Claude Code binary not found: ${pc.dim(doctorConfig.claudeBinary)}`,
599
+ );
405
600
  issues++;
406
601
  }
407
602
  } else {
408
- execSync(process.platform === "win32" ? "where claude" : "which claude", { stdio: "pipe" });
603
+ execSync(process.platform === "win32" ? "where claude" : "which claude", {
604
+ stdio: "pipe",
605
+ });
409
606
  console.log(` ${pc.green("\u2713")} Claude Code installed`);
410
607
  }
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`);
608
+ } catch {
609
+ console.log(` ${pc.red("\u2717")} Claude Code not found`);
610
+ issues++;
611
+ }
612
+ try {
613
+ const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(2000) });
614
+ if (resp.ok) console.log(` ${pc.green("\u2713")} Bot is running`);
615
+ } catch {
616
+ console.log(` ${pc.dim("-")} Bot is not running`);
617
+ }
618
+ console.log(
619
+ issues === 0
620
+ ? `\n ${pc.green("All checks passed.")}\n`
621
+ : `\n ${pc.yellow(`${issues} issue(s) found.`)}\n`,
622
+ );
414
623
  }
415
624
 
416
625
  // ── Terminal chat ───────────────────────────────────────────────────────────
@@ -418,13 +627,15 @@ async function runDoctor(): Promise<void> {
418
627
  async function startChat(): Promise<void> {
419
628
  process.env.TALON_QUIET = "1";
420
629
 
421
- const { bootstrap, initBackendAndDispatcher } = await import("./bootstrap.js");
630
+ const { bootstrap, initBackendAndDispatcher } =
631
+ await import("./bootstrap.js");
422
632
  const { flushSessions } = await import("./storage/sessions.js");
423
633
  const { flushChatSettings } = await import("./storage/chat-settings.js");
424
634
  const { flushCronJobs } = await import("./storage/cron-store.js");
425
635
  const { flushHistory } = await import("./storage/history.js");
426
636
  const { flushMediaIndex } = await import("./storage/media-index.js");
427
- const { createTerminalFrontend } = await import("./frontend/terminal/index.js");
637
+ const { createTerminalFrontend } =
638
+ await import("./frontend/terminal/index.js");
428
639
  const { Gateway } = await import("./core/gateway.js");
429
640
 
430
641
  const { config } = await bootstrap({ frontendNames: ["terminal"] });
@@ -461,23 +672,52 @@ async function mainMenu(): Promise<void> {
461
672
  printBanner();
462
673
  if (!existsSync(CONFIG_FILE) || !isConfigured(loadConfig())) {
463
674
  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?");
675
+ p.note(
676
+ "Talon is an agentic AI harness.\nSupports Telegram and Terminal.\nLet's get you set up.",
677
+ "First time?",
678
+ );
465
679
  await runSetup();
466
680
  return;
467
681
  }
468
682
 
469
683
  let running = false;
470
- try { const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(1000) }); running = resp.ok; } catch { /* not running */ }
684
+ try {
685
+ const resp = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(1000) });
686
+ running = resp.ok;
687
+ } catch {
688
+ /* not running */
689
+ }
471
690
  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(" + ");
691
+ const statusDot = running
692
+ ? `${pc.green("\u25CF")} running`
693
+ : `${pc.red("\u25CF")} stopped`;
694
+ const fes = Array.isArray(config.frontend)
695
+ ? config.frontend
696
+ : [config.frontend];
697
+ const frontendLabel = fes
698
+ .map((f) =>
699
+ f === "telegram" ? "Telegram" : f === "teams" ? "Teams" : "Terminal",
700
+ )
701
+ .join(" + ");
475
702
 
476
703
  const action = await p.select({
477
704
  message: `Talon ${statusDot} ${pc.dim(`(${frontendLabel})`)}`,
478
705
  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" }] : []),
706
+ ...(!running
707
+ ? [
708
+ {
709
+ value: "start" as const,
710
+ label: `Start ${frontendLabel}`,
711
+ hint: "background daemon",
712
+ },
713
+ ]
714
+ : []),
715
+ ...(running
716
+ ? [
717
+ { value: "restart" as const, label: "Restart" },
718
+ { value: "stop" as const, label: "Stop" },
719
+ ]
720
+ : []),
481
721
  { value: "chat", label: "Chat in terminal", hint: "talk to Talon here" },
482
722
  { value: "status", label: "Status", hint: "health and stats" },
483
723
  { value: "config", label: "Config", hint: "view or edit" },
@@ -487,14 +727,31 @@ async function mainMenu(): Promise<void> {
487
727
  });
488
728
  if (p.isCancel(action)) process.exit(0);
489
729
  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;
730
+ case "start":
731
+ await daemonStart();
732
+ break;
733
+ case "stop":
734
+ daemonStop();
735
+ break;
736
+ case "restart":
737
+ await daemonRestart();
738
+ break;
739
+ case "chat":
740
+ process.chdir(PKG_ROOT);
741
+ await startChat();
742
+ break;
743
+ case "status":
744
+ await showStatus();
745
+ break;
746
+ case "config":
747
+ await viewConfig();
748
+ break;
749
+ case "logs":
750
+ await tailLogs();
751
+ break;
752
+ case "setup":
753
+ await runSetup();
754
+ break;
498
755
  }
499
756
  }
500
757
 
@@ -508,19 +765,30 @@ function readPid(): number | null {
508
765
  const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
509
766
  if (!isNaN(pid) && pid > 0) return pid;
510
767
  }
511
- } catch { /* corrupt */ }
768
+ } catch {
769
+ /* corrupt */
770
+ }
512
771
  return null;
513
772
  }
514
773
 
515
774
  function isProcessRunning(pid: number): boolean {
516
- try { process.kill(pid, 0); return true; } catch { return false; }
775
+ try {
776
+ process.kill(pid, 0);
777
+ return true;
778
+ } catch {
779
+ return false;
780
+ }
517
781
  }
518
782
 
519
783
  async function daemonStart(): Promise<void> {
520
784
  const existingPid = readPid();
521
785
  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`);
786
+ console.log(
787
+ ` ${pc.yellow("!")} Talon is already running (PID ${existingPid})`,
788
+ );
789
+ console.log(
790
+ ` Use ${pc.cyan("talon restart")} to restart, or ${pc.cyan("talon stop")} to stop.\n`,
791
+ );
524
792
  return;
525
793
  }
526
794
 
@@ -529,7 +797,14 @@ async function daemonStart(): Promise<void> {
529
797
 
530
798
  // Spawn detached process with stdio piped to /dev/null
531
799
  // 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");
800
+ const tsxImport = resolve(
801
+ PKG_ROOT,
802
+ "node_modules",
803
+ "tsx",
804
+ "dist",
805
+ "esm",
806
+ "index.mjs",
807
+ );
533
808
  const child = spawn(process.execPath, ["--import", tsxImport, entryScript], {
534
809
  cwd: PKG_ROOT,
535
810
  detached: true,
@@ -554,13 +829,21 @@ function daemonStop(): boolean {
554
829
  const pid = readPid();
555
830
  if (!pid || !isProcessRunning(pid)) {
556
831
  console.log(` ${pc.dim("●")} Talon is not running\n`);
557
- try { unlinkSync(PID_FILE); } catch { /* ok */ }
832
+ try {
833
+ unlinkSync(PID_FILE);
834
+ } catch {
835
+ /* ok */
836
+ }
558
837
  return false;
559
838
  }
560
839
 
561
840
  process.kill(pid, "SIGTERM");
562
841
  console.log(` ${pc.red("●")} Talon stopped (PID ${pid})`);
563
- try { unlinkSync(PID_FILE); } catch { /* ok */ }
842
+ try {
843
+ unlinkSync(PID_FILE);
844
+ } catch {
845
+ /* ok */
846
+ }
564
847
  return true;
565
848
  }
566
849
 
@@ -577,17 +860,43 @@ async function daemonRestart(): Promise<void> {
577
860
 
578
861
  const command = process.argv[2];
579
862
  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":
863
+ case "setup":
864
+ runSetup();
865
+ break;
866
+ case "status":
867
+ showStatus();
868
+ break;
869
+ case "config":
870
+ viewConfig();
871
+ break;
872
+ case "logs":
873
+ tailLogs();
874
+ break;
875
+ case "start":
876
+ printBanner();
877
+ await daemonStart();
878
+ break;
879
+ case "stop":
880
+ printBanner();
881
+ daemonStop();
882
+ break;
883
+ case "restart":
884
+ printBanner();
885
+ await daemonRestart();
886
+ break;
887
+ case "run":
888
+ process.chdir(PKG_ROOT);
889
+ import("./index.js");
890
+ break;
891
+ case "chat":
892
+ process.chdir(PKG_ROOT);
893
+ startChat();
894
+ break;
895
+ case "doctor":
896
+ runDoctor();
897
+ break;
898
+ case "--help":
899
+ case "-h":
591
900
  printBanner();
592
901
  console.log(" Usage: talon [command]\n");
593
902
  console.log(" Commands:");
@@ -602,10 +911,16 @@ switch (command) {
602
911
  console.log(` ${pc.cyan("logs")} Tail log file`);
603
912
  console.log(` ${pc.cyan("doctor")} Validate environment`);
604
913
  console.log();
605
- console.log(` Run ${pc.cyan("talon")} with no args for interactive menu.\n`);
914
+ console.log(
915
+ ` Run ${pc.cyan("talon")} with no args for interactive menu.\n`,
916
+ );
917
+ break;
918
+ case undefined:
919
+ mainMenu();
606
920
  break;
607
- case undefined: mainMenu(); break;
608
921
  default:
609
- console.error(` Unknown command: ${command}\n Run ${pc.cyan("talon --help")} for usage.\n`);
922
+ console.error(
923
+ ` Unknown command: ${command}\n Run ${pc.cyan("talon --help")} for usage.\n`,
924
+ );
610
925
  process.exit(1);
611
926
  }