talon-agent 3.25.1 → 3.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.25.1",
3
+ "version": "3.25.2",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/cli/config.ts CHANGED
@@ -18,6 +18,13 @@ export function printBanner(): void {
18
18
  console.log();
19
19
  }
20
20
 
21
+ /**
22
+ * The subset of ~/.talon/config.json the CLI reads or prompts for — a
23
+ * partial view, not the whole schema (that lives in util/config.ts).
24
+ * Anything absent here is still present at runtime on a loaded config and
25
+ * MUST be carried through on save: `talon setup` used to rebuild the file
26
+ * from its own named fields alone and silently dropped every other key.
27
+ */
21
28
  export type Config = {
22
29
  frontend: string | string[];
23
30
  /** Active backend (`claude` / `kilo` / `opencode` / `codex` / `openai-agents`). */
@@ -56,6 +63,17 @@ export type Config = {
56
63
  adminUserIds?: string[];
57
64
  [key: string]: unknown;
58
65
  };
66
+ // WhatsApp — pairing + allowlists. Session credentials are not here;
67
+ // they live in ~/.talon/whatsapp-auth/.
68
+ whatsapp?: {
69
+ allowedJids?: string[];
70
+ allowedGroups?: string[];
71
+ groupPolicy?: "listed" | "with-allowed-user" | "all";
72
+ respondMode?: "mention" | "all";
73
+ pairingNumber?: string;
74
+ sendReadReceipts?: boolean;
75
+ [key: string]: unknown;
76
+ };
59
77
  };
60
78
 
61
79
  export const DEFAULTS: Config = {
@@ -99,6 +117,12 @@ export function isConfigured(config: Config): boolean {
99
117
  if (fe === "telegram") return !!config.botToken;
100
118
  if (fe === "teams") return !!config.teamsWebhookUrl;
101
119
  if (fe === "discord") return !!config.discord?.botToken;
120
+ // WhatsApp's credentials live in ~/.talon/whatsapp-auth/, not the
121
+ // config — but the loader hard-requires the block, so its presence is
122
+ // what "configured" means here. Omitting this case sent every
123
+ // WhatsApp user through the fail-closed branch below and straight
124
+ // into the setup wizard on a plain `talon`.
125
+ if (fe === "whatsapp") return !!config.whatsapp;
102
126
  // terminal (stdio) and native (the client bridge) carry no
103
127
  // credentials; unknown names fail closed so a typo'd frontend sends
104
128
  // the user to setup instead of a daemon that can't start.
package/src/cli/menu.ts CHANGED
@@ -8,6 +8,7 @@ import pc from "picocolors";
8
8
  import { existsSync } from "node:fs";
9
9
  import { findRunningInstance } from "../core/daemon/discovery.js";
10
10
  import { printBanner, loadConfig, isConfigured } from "./config.js";
11
+ import { getFrontendDescriptor } from "../core/frontend-runtime/routing.js";
11
12
  import { CONFIG_FILE, PKG_ROOT } from "./context.js";
12
13
  import { runSetup } from "./setup.js";
13
14
  import { showStatus } from "./status.js";
@@ -36,18 +37,12 @@ export async function mainMenu(): Promise<void> {
36
37
  const fes = Array.isArray(config.frontend)
37
38
  ? config.frontend
38
39
  : [config.frontend];
40
+ // Labels come from the frontend registry, not a chain here. The old
41
+ // if/else fell through to "Terminal" for anything it didn't name, so a
42
+ // WhatsApp install read as "Terminal" in this header. An unknown id now
43
+ // shows itself rather than impersonating another frontend.
39
44
  const frontendLabel = fes
40
- .map((f) =>
41
- f === "telegram"
42
- ? "Telegram"
43
- : f === "teams"
44
- ? "Teams"
45
- : f === "discord"
46
- ? "Discord"
47
- : f === "native"
48
- ? "Native"
49
- : "Terminal",
50
- )
45
+ .map((f) => getFrontendDescriptor(f)?.label ?? f)
51
46
  .join(" + ");
52
47
 
53
48
  const action = await p.select({
package/src/cli/setup.ts CHANGED
@@ -7,6 +7,14 @@ import * as p from "@clack/prompts";
7
7
  import pc from "picocolors";
8
8
  import { printBanner, loadConfig, saveConfig, type Config } from "./config.js";
9
9
 
10
+ /** "353871234567, +44 7700 900000" → ["353871234567", "447700900000"]. */
11
+ function parseNumberList(raw: string): string[] {
12
+ return raw
13
+ .split(",")
14
+ .map((entry) => entry.replace(/[\s+()-]/g, ""))
15
+ .filter((entry) => entry.length > 0);
16
+ }
17
+
10
18
  export async function runSetup(): Promise<void> {
11
19
  printBanner();
12
20
  p.intro(pc.inverse(" Setup Wizard "));
@@ -24,6 +32,10 @@ export async function runSetup(): Promise<void> {
24
32
  value: "telegram",
25
33
  label: `Telegram ${pc.dim("— bot via @BotFather")}`,
26
34
  },
35
+ {
36
+ value: "whatsapp",
37
+ label: `WhatsApp ${pc.dim("— a real account via Baileys multi-device")}`,
38
+ },
27
39
  {
28
40
  value: "discord",
29
41
  label: `Discord ${pc.dim("— bot via Developer Portal (discord.js v14)")}`,
@@ -36,6 +48,10 @@ export async function runSetup(): Promise<void> {
36
48
  value: "terminal",
37
49
  label: `Terminal ${pc.dim("— local CLI chat")}`,
38
50
  },
51
+ {
52
+ value: "native",
53
+ label: `Native ${pc.dim("— HTTP+SSE bridge for the companion app")}`,
54
+ },
39
55
  ],
40
56
  required: true,
41
57
  });
@@ -227,6 +243,75 @@ export async function runSetup(): Promise<void> {
227
243
  discordApplicationId = appId as string;
228
244
  }
229
245
 
246
+ let whatsapp: Config["whatsapp"] | undefined;
247
+
248
+ if (selectedFrontends.includes("whatsapp")) {
249
+ p.note(
250
+ "Talon drives a real WhatsApp account over Baileys multi-device —\n" +
251
+ "the same mechanism as WhatsApp Web. On first start you'll pair it\n" +
252
+ "under WhatsApp → Linked devices.",
253
+ "WhatsApp Setup",
254
+ );
255
+
256
+ const pairing = (await p.text({
257
+ message: "The bot account's own number (E.164 digits, no +)",
258
+ placeholder: "leave blank to pair by QR code instead",
259
+ initialValue: config.whatsapp?.pairingNumber || "",
260
+ validate: (v) => {
261
+ if (v && !/^\d{7,15}$/.test(v.replace(/[\s+()-]/g, "")))
262
+ return "Digits only, country code included — e.g. 353871234567";
263
+ },
264
+ })) as string;
265
+ if (p.isCancel(pairing)) {
266
+ p.cancel("Cancelled.");
267
+ process.exit(0);
268
+ }
269
+
270
+ const allowed = (await p.text({
271
+ message: "Numbers allowed to DM the bot (comma-separated)",
272
+ placeholder: "353871234567, 447700900000 — blank disables DMs",
273
+ initialValue: (config.whatsapp?.allowedJids ?? []).join(", "),
274
+ })) as string;
275
+ if (p.isCancel(allowed)) {
276
+ p.cancel("Cancelled.");
277
+ process.exit(0);
278
+ }
279
+
280
+ const groupPolicy = await p.select({
281
+ message: "Which groups should the bot serve?",
282
+ initialValue: config.whatsapp?.groupPolicy ?? "listed",
283
+ options: [
284
+ {
285
+ value: "listed",
286
+ label: `Listed only ${pc.dim("— groups you name in allowedGroups")}`,
287
+ },
288
+ {
289
+ value: "with-allowed-user",
290
+ label: `Groups I'm in ${pc.dim("— any group containing an allowed number")}`,
291
+ },
292
+ {
293
+ value: "all",
294
+ label: `All ${pc.dim("— every group the account belongs to")}`,
295
+ },
296
+ ],
297
+ });
298
+ if (p.isCancel(groupPolicy)) {
299
+ p.cancel("Cancelled.");
300
+ process.exit(0);
301
+ }
302
+
303
+ whatsapp = {
304
+ // Preserve allowedGroups / respondMode / sendReadReceipts — the
305
+ // wizard doesn't prompt for them but must not drop them.
306
+ ...config.whatsapp,
307
+ allowedJids: parseNumberList(allowed),
308
+ groupPolicy: groupPolicy as "listed" | "with-allowed-user" | "all",
309
+ ...(pairing.replace(/[\s+()-]/g, "")
310
+ ? { pairingNumber: pairing.replace(/[\s+()-]/g, "") }
311
+ : {}),
312
+ };
313
+ }
314
+
230
315
  // Discover models from SDK; fall back to static list if SDK isn't available
231
316
  const {
232
317
  registerClaudeModels,
@@ -393,52 +478,28 @@ export async function runSetup(): Promise<void> {
393
478
  // creds configured separately (kilo via `kilo login`, opencode via
394
479
  // its own auth flow).
395
480
 
396
- const newConfig: Config = {
397
- frontend:
398
- selectedFrontends.length === 1 ? selectedFrontends[0] : selectedFrontends,
481
+ const newConfig = buildSetupConfig(config, {
482
+ selectedFrontends,
399
483
  backend,
400
- botToken: selectedFrontends.includes("telegram") ? botToken : undefined,
484
+ botToken,
401
485
  claudeBinary,
402
486
  codexApiKey,
403
487
  openaiApiKey,
404
488
  openaiBaseUrl,
405
489
  openaiApiMode,
406
490
  model: model as string,
407
- concurrency: config.concurrency,
408
491
  pulse: pulse as boolean,
409
- pulseIntervalMs: config.pulseIntervalMs,
410
- adminUserId: adminId ? parseInt(adminId, 10) || undefined : undefined,
492
+ adminId,
411
493
  apiId,
412
494
  apiHash,
413
- maxMessageLength: config.maxMessageLength,
414
- plugins: config.plugins,
415
- // Teams
416
- teamsWebhookUrl: selectedFrontends.includes("teams")
417
- ? teamsWebhookUrl
418
- : undefined,
419
- teamsWebhookSecret: selectedFrontends.includes("teams")
420
- ? teamsWebhookSecret
421
- : undefined,
422
- teamsWebhookPort: selectedFrontends.includes("teams")
423
- ? teamsWebhookPort
424
- : undefined,
425
- teamsBotDisplayName: selectedFrontends.includes("teams")
426
- ? teamsBotDisplayName
427
- : undefined,
428
- // Discord — bot token + applicationId. Allowlists / admin IDs /
429
- // mention vs channel-wide reply behaviour are left as defaults in
430
- // the wizard; advanced users hand-edit talon.json.
431
- discord:
432
- selectedFrontends.includes("discord") &&
433
- discordBotToken &&
434
- discordApplicationId
435
- ? {
436
- ...config.discord,
437
- botToken: discordBotToken,
438
- applicationId: discordApplicationId,
439
- }
440
- : config.discord,
441
- };
495
+ teamsWebhookUrl,
496
+ teamsWebhookSecret,
497
+ teamsWebhookPort,
498
+ teamsBotDisplayName,
499
+ discordBotToken,
500
+ discordApplicationId,
501
+ whatsapp,
502
+ });
442
503
 
443
504
  const s = p.spinner();
444
505
  s.start("Saving configuration");
@@ -453,3 +514,114 @@ export async function runSetup(): Promise<void> {
453
514
  );
454
515
  }
455
516
  }
517
+
518
+ /** Everything the wizard's prompts collected, in one bag. */
519
+ export type SetupAnswers = {
520
+ selectedFrontends: string[];
521
+ backend: Config["backend"];
522
+ botToken?: string;
523
+ claudeBinary?: string;
524
+ codexApiKey?: string;
525
+ openaiApiKey?: string;
526
+ openaiBaseUrl?: string;
527
+ openaiApiMode?: Config["openaiApiMode"];
528
+ model: string;
529
+ pulse: boolean;
530
+ adminId?: string;
531
+ apiId?: number;
532
+ apiHash?: string;
533
+ teamsWebhookUrl?: string;
534
+ teamsWebhookSecret?: string;
535
+ teamsWebhookPort?: number;
536
+ teamsBotDisplayName?: string;
537
+ discordBotToken?: string;
538
+ discordApplicationId?: string;
539
+ whatsapp?: Config["whatsapp"];
540
+ };
541
+
542
+ /**
543
+ * Fold the wizard's answers onto the existing config.
544
+ *
545
+ * Extracted from `runSetup` so the merge is testable without driving the
546
+ * prompts — the behaviour that matters here is what it *doesn't* touch.
547
+ * The wizard models roughly half of ~/.talon/config.json; it used to
548
+ * rebuild the file from its own named fields alone, which silently
549
+ * deleted every other key (whatsapp, native, soul, memory, github,
550
+ * heartbeat/dream, allowlists, plugin blocks…). Spreading `existing`
551
+ * first keeps them. Fields below still override, and an explicit
552
+ * `undefined` still deletes, because `saveConfig` strips undefined.
553
+ */
554
+ export function buildSetupConfig(
555
+ existing: Config,
556
+ answers: SetupAnswers,
557
+ ): Config {
558
+ const on = (id: string) => answers.selectedFrontends.includes(id);
559
+ /** Did the chosen backend's credential prompt actually run? */
560
+ const ownsCredential = (backend: Config["backend"]) =>
561
+ answers.backend === backend;
562
+ return {
563
+ ...existing,
564
+ frontend:
565
+ answers.selectedFrontends.length === 1
566
+ ? answers.selectedFrontends[0]
567
+ : answers.selectedFrontends,
568
+ backend: answers.backend,
569
+ botToken: on("telegram") ? answers.botToken : undefined,
570
+ // Backend credentials: the wizard prompts for exactly one backend's
571
+ // (an if/else-if chain on the chosen backend), so taking the answer
572
+ // unconditionally deleted every other backend's — picking `claude`
573
+ // wiped a stored openaiApiKey/openaiBaseUrl even though the account
574
+ // stays reachable via enabledBackends and per-chat overrides. Each
575
+ // credential is now overwritten only by the prompt that owns it, and
576
+ // left alone otherwise. Blanking a shown prompt still clears it.
577
+ claudeBinary: ownsCredential("claude")
578
+ ? answers.claudeBinary
579
+ : existing.claudeBinary,
580
+ codexApiKey: ownsCredential("codex")
581
+ ? answers.codexApiKey
582
+ : existing.codexApiKey,
583
+ openaiApiKey: ownsCredential("openai-agents")
584
+ ? answers.openaiApiKey
585
+ : existing.openaiApiKey,
586
+ openaiBaseUrl: ownsCredential("openai-agents")
587
+ ? answers.openaiBaseUrl
588
+ : existing.openaiBaseUrl,
589
+ openaiApiMode: ownsCredential("openai-agents")
590
+ ? answers.openaiApiMode
591
+ : existing.openaiApiMode,
592
+ model: answers.model,
593
+ concurrency: existing.concurrency,
594
+ pulse: answers.pulse,
595
+ pulseIntervalMs: existing.pulseIntervalMs,
596
+ adminUserId: answers.adminId
597
+ ? parseInt(answers.adminId, 10) || undefined
598
+ : undefined,
599
+ apiId: answers.apiId,
600
+ apiHash: answers.apiHash,
601
+ maxMessageLength: existing.maxMessageLength,
602
+ plugins: existing.plugins,
603
+ // Teams
604
+ teamsWebhookUrl: on("teams") ? answers.teamsWebhookUrl : undefined,
605
+ teamsWebhookSecret: on("teams") ? answers.teamsWebhookSecret : undefined,
606
+ teamsWebhookPort: on("teams") ? answers.teamsWebhookPort : undefined,
607
+ teamsBotDisplayName: on("teams") ? answers.teamsBotDisplayName : undefined,
608
+ // Discord — bot token + applicationId. Allowlists / admin IDs /
609
+ // mention vs channel-wide reply behaviour are left as defaults in
610
+ // the wizard; advanced users hand-edit talon.json.
611
+ discord:
612
+ on("discord") && answers.discordBotToken && answers.discordApplicationId
613
+ ? {
614
+ ...existing.discord,
615
+ botToken: answers.discordBotToken,
616
+ applicationId: answers.discordApplicationId,
617
+ }
618
+ : existing.discord,
619
+ // Never dropped. Deselecting WhatsApp shouldn't throw away its
620
+ // pairing setup, and dropping it while `frontend` still lists
621
+ // whatsapp produces a config util/config.ts refuses to load at all
622
+ // ("WhatsApp frontend requires a whatsapp config block") — a daemon
623
+ // that won't start. The `??` keeps that impossible even if the
624
+ // branch that collects the answer is ever skipped.
625
+ whatsapp: answers.whatsapp ?? existing.whatsapp,
626
+ };
627
+ }