talon-agent 3.33.4 → 3.34.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 (74) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
package/src/cli/setup.ts CHANGED
@@ -7,67 +7,88 @@ 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
+ /** Strip spaces, "+", brackets and dashes from a phone number. */
11
+ const bareDigits = (raw: string) => raw.replace(/[\s+()-]/g, "");
12
+
10
13
  /** "353871234567, +44 7700 900000" → ["353871234567", "447700900000"]. */
11
14
  function parseNumberList(raw: string): string[] {
12
15
  return raw
13
16
  .split(",")
14
- .map((entry) => entry.replace(/[\s+()-]/g, ""))
17
+ .map(bareDigits)
15
18
  .filter((entry) => entry.length > 0);
16
19
  }
17
20
 
18
- export async function runSetup(): Promise<void> {
19
- printBanner();
20
- p.intro(pc.inverse(" Setup Wizard "));
21
+ /** Empty answers mean "unset"; everything else is trimmed. */
22
+ const trimmedOrUndefined = (raw: string) => raw.trim() || undefined;
21
23
 
22
- const config = loadConfig();
24
+ /**
25
+ * Await a clack prompt; on Esc/Ctrl-C say so and leave the wizard.
26
+ *
27
+ * Every prompt used to be followed by the same four-line `isCancel` guard,
28
+ * and because `@clack/core` narrows `isCancel` to its own unique symbol
29
+ * the unguarded remainder still needed an `as string` cast. clack only
30
+ * ever resolves a symbol to mean "cancelled", so narrowing on `typeof`
31
+ * here removes both.
32
+ */
33
+ async function askOrExit<T>(prompt: Promise<T | symbol>): Promise<T> {
34
+ const value = await prompt;
35
+ if (typeof value === "symbol") {
36
+ p.cancel("Cancelled.");
37
+ process.exit(0);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ // ── Steps: each prompts for one section and returns its slice of the answers ──
43
+
44
+ async function askFrontends(config: Config): Promise<string[]> {
23
45
  const existingFrontends = Array.isArray(config.frontend)
24
46
  ? config.frontend
25
47
  : [config.frontend || "telegram"];
26
48
 
27
- const frontendSelection = await p.multiselect({
28
- message: "Frontend platforms (space to toggle, enter to confirm)",
29
- initialValues: existingFrontends,
30
- options: [
31
- {
32
- value: "telegram",
33
- label: `Telegram ${pc.dim("— bot via @BotFather")}`,
34
- },
35
- {
36
- value: "whatsapp",
37
- label: `WhatsApp ${pc.dim("— a real account via Baileys multi-device")}`,
38
- },
39
- {
40
- value: "discord",
41
- label: `Discord ${pc.dim("— bot via Developer Portal (discord.js v14)")}`,
42
- },
43
- {
44
- value: "teams",
45
- label: `Teams ${pc.dim("— Microsoft Teams via Power Automate")}`,
46
- },
47
- {
48
- value: "terminal",
49
- label: `Terminal ${pc.dim("— local CLI chat")}`,
50
- },
51
- {
52
- value: "native",
53
- label: `Native ${pc.dim("— HTTP+SSE bridge for the companion app")}`,
54
- },
55
- ],
56
- required: true,
57
- });
58
- if (p.isCancel(frontendSelection)) {
59
- p.cancel("Cancelled.");
60
- process.exit(0);
61
- }
62
- const selectedFrontends = frontendSelection as string[];
49
+ return askOrExit(
50
+ p.multiselect({
51
+ message: "Frontend platforms (space to toggle, enter to confirm)",
52
+ initialValues: existingFrontends,
53
+ options: [
54
+ {
55
+ value: "telegram",
56
+ label: `Telegram ${pc.dim("— bot via @BotFather")}`,
57
+ },
58
+ {
59
+ value: "whatsapp",
60
+ label: `WhatsApp ${pc.dim("— a real account via Baileys multi-device")}`,
61
+ },
62
+ {
63
+ value: "discord",
64
+ label: `Discord ${pc.dim("— bot via Developer Portal (discord.js v14)")}`,
65
+ },
66
+ {
67
+ value: "teams",
68
+ label: `Teams ${pc.dim("— Microsoft Teams via Power Automate")}`,
69
+ },
70
+ {
71
+ value: "terminal",
72
+ label: `Terminal ${pc.dim("— local CLI chat")}`,
73
+ },
74
+ {
75
+ value: "native",
76
+ label: `Native ${pc.dim("— HTTP+SSE bridge for the companion app")}`,
77
+ },
78
+ ],
79
+ required: true,
80
+ }),
81
+ );
82
+ }
63
83
 
64
- let botToken: string | undefined;
65
- let adminId: string | undefined;
66
- let apiId: number | undefined;
67
- let apiHash: string | undefined;
84
+ type TelegramAnswers = Pick<
85
+ SetupAnswers,
86
+ "botToken" | "adminId" | "apiId" | "apiHash"
87
+ >;
68
88
 
69
- if (selectedFrontends.includes("telegram")) {
70
- const token = await p.text({
89
+ async function askTelegram(config: Config): Promise<TelegramAnswers> {
90
+ const botToken = await askOrExit(
91
+ p.text({
71
92
  message: "Bot token",
72
93
  placeholder: "Paste your token from @BotFather",
73
94
  initialValue: config.botToken || undefined,
@@ -75,76 +96,71 @@ export async function runSetup(): Promise<void> {
75
96
  if (!v) return "Token is required";
76
97
  if (!v.includes(":")) return "Invalid format";
77
98
  },
78
- });
79
- if (p.isCancel(token)) {
80
- p.cancel("Cancelled.");
81
- process.exit(0);
82
- }
83
- botToken = token;
99
+ }),
100
+ );
84
101
 
85
- adminId = (await p.text({
102
+ const adminId = await askOrExit(
103
+ p.text({
86
104
  message: "Your Telegram user ID",
87
105
  placeholder: "optional — message @userinfobot to find yours",
88
106
  initialValue: config.adminUserId ? String(config.adminUserId) : "",
89
- })) as string;
90
- if (p.isCancel(adminId)) {
91
- p.cancel("Cancelled.");
92
- process.exit(0);
93
- }
107
+ }),
108
+ );
94
109
 
95
- const wantUserbot = await p.confirm({
110
+ const wantUserbot = await askOrExit(
111
+ p.confirm({
96
112
  message: "Set up userbot for full history access?",
97
113
  initialValue: !!(config.apiId && config.apiHash),
98
- });
99
- if (p.isCancel(wantUserbot)) {
100
- p.cancel("Cancelled.");
101
- process.exit(0);
102
- }
103
-
104
- if (wantUserbot) {
105
- p.note(
106
- "Get these from https://my.telegram.org → API development tools",
107
- "Telegram API credentials",
108
- );
109
- const id = await p.text({
110
- message: "API ID",
111
- placeholder: "12345678",
112
- initialValue: config.apiId ? String(config.apiId) : "",
113
- validate: (v) => {
114
- if (v && isNaN(parseInt(v, 10))) return "Must be a number";
115
- },
116
- });
117
- if (p.isCancel(id)) {
118
- p.cancel("Cancelled.");
119
- process.exit(0);
120
- }
121
- const hash = await p.text({
122
- message: "API Hash",
123
- initialValue: config.apiHash || "",
124
- });
125
- if (p.isCancel(hash)) {
126
- p.cancel("Cancelled.");
127
- process.exit(0);
128
- }
129
- if (id) apiId = parseInt(id, 10);
130
- if (hash) apiHash = hash as string;
131
- }
132
- }
133
-
134
- let teamsWebhookUrl: string | undefined;
135
- let teamsWebhookSecret: string | undefined;
136
- let teamsWebhookPort: number | undefined;
137
- let teamsBotDisplayName: string | undefined;
138
-
139
- if (selectedFrontends.includes("teams")) {
140
- p.note(
141
- "Set up two Power Automate workflows in Teams:\n" +
142
- "1. Send: 'Post to a channel when a webhook request is received' — copy the URL below\n" +
143
- "2. Receive: 'When a new channel message is added' → HTTP POST to your Talon endpoint",
144
- "Teams Setup",
145
- );
114
+ }),
115
+ );
116
+ if (!wantUserbot) return { botToken, adminId };
117
+
118
+ p.note(
119
+ "Get these from https://my.telegram.org → API development tools",
120
+ "Telegram API credentials",
121
+ );
122
+ const id = await askOrExit(
123
+ p.text({
124
+ message: "API ID",
125
+ placeholder: "12345678",
126
+ initialValue: config.apiId ? String(config.apiId) : "",
127
+ validate: (v) => {
128
+ if (v && isNaN(parseInt(v, 10))) return "Must be a number";
129
+ },
130
+ }),
131
+ );
132
+ const hash = await askOrExit(
133
+ p.text({
134
+ message: "API Hash",
135
+ initialValue: config.apiHash || "",
136
+ }),
137
+ );
138
+ return {
139
+ botToken,
140
+ adminId,
141
+ ...(id ? { apiId: parseInt(id, 10) } : {}),
142
+ ...(hash ? { apiHash: hash } : {}),
143
+ };
144
+ }
146
145
 
147
- const url = await p.text({
146
+ type TeamsAnswers = Pick<
147
+ SetupAnswers,
148
+ | "teamsWebhookUrl"
149
+ | "teamsWebhookSecret"
150
+ | "teamsWebhookPort"
151
+ | "teamsBotDisplayName"
152
+ >;
153
+
154
+ async function askTeams(config: Config): Promise<TeamsAnswers> {
155
+ p.note(
156
+ "Set up two Power Automate workflows in Teams:\n" +
157
+ "1. Send: 'Post to a channel when a webhook request is received' — copy the URL below\n" +
158
+ "2. Receive: 'When a new channel message is added' → HTTP POST to your Talon endpoint",
159
+ "Teams Setup",
160
+ );
161
+
162
+ const teamsWebhookUrl = await askOrExit(
163
+ p.text({
148
164
  message: "Power Automate webhook URL (for sending to Teams)",
149
165
  placeholder: "https://prod-XX.westus.logic.azure.com/workflows/...",
150
166
  initialValue: config.teamsWebhookUrl || undefined,
@@ -156,25 +172,19 @@ export async function runSetup(): Promise<void> {
156
172
  return "Must be a valid URL";
157
173
  }
158
174
  },
159
- });
160
- if (p.isCancel(url)) {
161
- p.cancel("Cancelled.");
162
- process.exit(0);
163
- }
164
- teamsWebhookUrl = url;
175
+ }),
176
+ );
165
177
 
166
- const secret = (await p.text({
178
+ const secret = await askOrExit(
179
+ p.text({
167
180
  message: "Webhook secret for inbound verification",
168
181
  placeholder: "optional — shared secret to verify incoming webhooks",
169
182
  initialValue: config.teamsWebhookSecret || "",
170
- })) as string;
171
- if (p.isCancel(secret)) {
172
- p.cancel("Cancelled.");
173
- process.exit(0);
174
- }
175
- if (secret) teamsWebhookSecret = secret;
176
-
177
- const port = await p.text({
183
+ }),
184
+ );
185
+
186
+ const port = await askOrExit(
187
+ p.text({
178
188
  message: "Webhook receiver port",
179
189
  placeholder: "19878",
180
190
  initialValue: config.teamsWebhookPort
@@ -185,99 +195,92 @@ export async function runSetup(): Promise<void> {
185
195
  const n = parseInt(v, 10);
186
196
  if (isNaN(n) || n < 1024 || n > 65535) return "Port must be 1024-65535";
187
197
  },
188
- });
189
- if (p.isCancel(port)) {
190
- p.cancel("Cancelled.");
191
- process.exit(0);
192
- }
193
- teamsWebhookPort = parseInt(port as string, 10);
198
+ }),
199
+ );
194
200
 
195
- const botName = (await p.text({
201
+ const botName = await askOrExit(
202
+ p.text({
196
203
  message: "Bot display name in Teams (for echo loop prevention)",
197
204
  placeholder: "optional — e.g. 'Talon Bot'",
198
205
  initialValue: config.teamsBotDisplayName || "",
199
- })) as string;
200
- if (p.isCancel(botName)) {
201
- p.cancel("Cancelled.");
202
- process.exit(0);
203
- }
204
- if (botName) teamsBotDisplayName = botName;
205
- }
206
+ }),
207
+ );
206
208
 
207
- let discordBotToken: string | undefined;
208
- let discordApplicationId: string | undefined;
209
+ return {
210
+ teamsWebhookUrl,
211
+ teamsWebhookPort: parseInt(port, 10),
212
+ ...(secret ? { teamsWebhookSecret: secret } : {}),
213
+ ...(botName ? { teamsBotDisplayName: botName } : {}),
214
+ };
215
+ }
209
216
 
210
- if (selectedFrontends.includes("discord")) {
211
- p.note(
212
- "Get bot token + application ID from\n" +
213
- "https://discord.com/developers/applications → your app → Bot",
214
- "Discord Setup",
215
- );
217
+ type DiscordAnswers = Pick<
218
+ SetupAnswers,
219
+ "discordBotToken" | "discordApplicationId"
220
+ >;
221
+
222
+ async function askDiscord(config: Config): Promise<DiscordAnswers> {
223
+ p.note(
224
+ "Get bot token + application ID from\n" +
225
+ "https://discord.com/developers/applications → your app → Bot",
226
+ "Discord Setup",
227
+ );
216
228
 
217
- const token = await p.text({
229
+ const discordBotToken = await askOrExit(
230
+ p.text({
218
231
  message: "Discord bot token",
219
232
  placeholder: "MTxxxxxxxxxxxxxxxxxxxxxxxxxxx",
220
233
  initialValue: config.discord?.botToken || undefined,
221
234
  validate: (v) => {
222
235
  if (!v) return "Bot token is required";
223
236
  },
224
- });
225
- if (p.isCancel(token)) {
226
- p.cancel("Cancelled.");
227
- process.exit(0);
228
- }
229
- discordBotToken = token as string;
237
+ }),
238
+ );
230
239
 
231
- const appId = await p.text({
240
+ const discordApplicationId = await askOrExit(
241
+ p.text({
232
242
  message: "Discord application ID",
233
243
  placeholder: "1234567890123456789",
234
244
  initialValue: config.discord?.applicationId || undefined,
235
245
  validate: (v) => {
236
246
  if (!v) return "Application ID is required";
237
247
  },
238
- });
239
- if (p.isCancel(appId)) {
240
- p.cancel("Cancelled.");
241
- process.exit(0);
242
- }
243
- discordApplicationId = appId as string;
244
- }
248
+ }),
249
+ );
245
250
 
246
- let whatsapp: Config["whatsapp"] | undefined;
251
+ return { discordBotToken, discordApplicationId };
252
+ }
247
253
 
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
- );
254
+ async function askWhatsApp(config: Config): Promise<Config["whatsapp"]> {
255
+ p.note(
256
+ "Talon drives a real WhatsApp account over Baileys multi-device —\n" +
257
+ "the same mechanism as WhatsApp Web. On first start you'll pair it\n" +
258
+ "under WhatsApp → Linked devices.",
259
+ "WhatsApp Setup",
260
+ );
255
261
 
256
- const pairing = (await p.text({
262
+ const pairing = await askOrExit(
263
+ p.text({
257
264
  message: "The bot account's own number (E.164 digits, no +)",
258
265
  placeholder: "leave blank to pair by QR code instead",
259
266
  initialValue: config.whatsapp?.pairingNumber || "",
260
267
  validate: (v) => {
261
- if (v && !/^\d{7,15}$/.test(v.replace(/[\s+()-]/g, "")))
268
+ if (v && !/^\d{7,15}$/.test(bareDigits(v)))
262
269
  return "Digits only, country code included — e.g. 353871234567";
263
270
  },
264
- })) as string;
265
- if (p.isCancel(pairing)) {
266
- p.cancel("Cancelled.");
267
- process.exit(0);
268
- }
271
+ }),
272
+ );
269
273
 
270
- const allowed = (await p.text({
274
+ const allowed = await askOrExit(
275
+ p.text({
271
276
  message: "Numbers allowed to DM the bot (comma-separated)",
272
277
  placeholder: "353871234567, 447700900000 — blank disables DMs",
273
278
  initialValue: (config.whatsapp?.allowedJids ?? []).join(", "),
274
- })) as string;
275
- if (p.isCancel(allowed)) {
276
- p.cancel("Cancelled.");
277
- process.exit(0);
278
- }
279
+ }),
280
+ );
279
281
 
280
- const groupPolicy = await p.select({
282
+ const groupPolicy = await askOrExit(
283
+ p.select<"listed" | "with-allowed-user" | "all">({
281
284
  message: "Which groups should the bot serve?",
282
285
  initialValue: config.whatsapp?.groupPolicy ?? "listed",
283
286
  options: [
@@ -294,25 +297,22 @@ export async function runSetup(): Promise<void> {
294
297
  label: `All ${pc.dim("— every group the account belongs to")}`,
295
298
  },
296
299
  ],
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
- }
300
+ }),
301
+ );
314
302
 
315
- // Discover models from SDK; fall back to static list if SDK isn't available
303
+ const pairingNumber = bareDigits(pairing);
304
+ return {
305
+ // Preserve allowedGroups / respondMode / sendReadReceipts — the
306
+ // wizard doesn't prompt for them but must not drop them.
307
+ ...config.whatsapp,
308
+ allowedJids: parseNumberList(allowed),
309
+ groupPolicy,
310
+ ...(pairingNumber ? { pairingNumber } : {}),
311
+ };
312
+ }
313
+
314
+ /** Discover models from the SDK; fall back to the static list if it isn't installed. */
315
+ async function discoverModels(config: Config) {
316
316
  const {
317
317
  registerClaudeModels,
318
318
  registerClaudeModelsStatic,
@@ -334,171 +334,197 @@ export async function runSetup(): Promise<void> {
334
334
  registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
335
335
  }
336
336
  const { getModels } = await import("../core/models/catalog.js");
337
- const registeredModels = getModels();
338
-
339
- const model = await p.select({
340
- message: "Default model",
341
- initialValue: config.model,
342
- options: registeredModels.map((m) => ({
343
- value: m.id,
344
- label: `${m.displayName.padEnd(12)}${m.description ? pc.dim(`— ${m.description}`) : ""}`,
345
- })),
346
- });
347
- if (p.isCancel(model)) {
348
- p.cancel("Cancelled.");
349
- process.exit(0);
350
- }
337
+ return getModels();
338
+ }
351
339
 
352
- const pulse = !selectedFrontends.every((f) => f === "terminal")
353
- ? await p.confirm({
354
- message: "Enable pulse? (periodic group engagement)",
355
- initialValue: config.pulse,
356
- })
357
- : false;
358
- if (p.isCancel(pulse)) {
359
- p.cancel("Cancelled.");
360
- process.exit(0);
361
- }
340
+ async function askModel(config: Config): Promise<string> {
341
+ const registeredModels = await discoverModels(config);
342
+ return askOrExit(
343
+ p.select({
344
+ message: "Default model",
345
+ initialValue: config.model,
346
+ options: registeredModels.map((m) => ({
347
+ value: m.id,
348
+ label: `${m.displayName.padEnd(12)}${m.description ? pc.dim(`— ${m.description}`) : ""}`,
349
+ })),
350
+ }),
351
+ );
352
+ }
362
353
 
363
- // ── Backend selection ──
364
- const backendSelection = await p.select({
365
- message: "AI backend",
366
- initialValue: config.backend ?? "claude",
367
- options: [
368
- {
369
- value: "claude",
370
- label: `Claude ${pc.dim("— Anthropic Claude Agent SDK")}`,
371
- },
372
- {
373
- value: "kilo",
374
- label: `Kilo ${pc.dim("— @kilocode/sdk (multi-provider routing)")}`,
375
- },
376
- {
377
- value: "opencode",
378
- label: `OpenCode ${pc.dim("— @opencode-ai/sdk")}`,
379
- },
380
- {
381
- value: "codex",
382
- label: `Codex ${pc.dim("— OpenAI Codex CLI (@openai/codex)")}`,
383
- },
384
- {
385
- value: "openai-agents",
386
- label: `OpenAI Agents ${pc.dim("— @openai/agents (OpenAI or any OpenAI-compatible endpoint)")}`,
387
- },
388
- ],
389
- });
390
- if (p.isCancel(backendSelection)) {
391
- p.cancel("Cancelled.");
392
- process.exit(0);
393
- }
394
- const backend = backendSelection as Config["backend"];
354
+ /** Pulse only makes sense with a chat platform — terminal-only installs skip it. */
355
+ async function askPulse(
356
+ config: Config,
357
+ selectedFrontends: string[],
358
+ ): Promise<boolean> {
359
+ if (selectedFrontends.every((f) => f === "terminal")) return false;
360
+ return askOrExit(
361
+ p.confirm({
362
+ message: "Enable pulse? (periodic group engagement)",
363
+ initialValue: config.pulse,
364
+ }),
365
+ );
366
+ }
395
367
 
396
- // ── Backend-specific config ──
397
- let claudeBinary: string | undefined;
398
- let codexApiKey: string | undefined;
399
- let openaiApiKey: string | undefined;
400
- let openaiBaseUrl: string | undefined;
401
- let openaiApiMode: "responses" | "chat_completions" | undefined;
368
+ async function askBackend(config: Config): Promise<Config["backend"]> {
369
+ return askOrExit(
370
+ p.select({
371
+ message: "AI backend",
372
+ initialValue: config.backend ?? "claude",
373
+ options: [
374
+ {
375
+ value: "claude",
376
+ label: `Claude ${pc.dim("— Anthropic Claude Agent SDK")}`,
377
+ },
378
+ {
379
+ value: "kilo",
380
+ label: `Kilo ${pc.dim("— @kilocode/sdk (multi-provider routing)")}`,
381
+ },
382
+ {
383
+ value: "opencode",
384
+ label: `OpenCode ${pc.dim("— @opencode-ai/sdk")}`,
385
+ },
386
+ {
387
+ value: "codex",
388
+ label: `Codex ${pc.dim("— OpenAI Codex CLI (@openai/codex)")}`,
389
+ },
390
+ {
391
+ value: "openai-agents",
392
+ label: `OpenAI Agents ${pc.dim("— @openai/agents (OpenAI or any OpenAI-compatible endpoint)")}`,
393
+ },
394
+ ],
395
+ }),
396
+ );
397
+ }
402
398
 
403
- if (backend === "claude") {
404
- const claudeBinaryInput = await p.text({
399
+ type BackendAnswers = Pick<
400
+ SetupAnswers,
401
+ | "claudeBinary"
402
+ | "codexApiKey"
403
+ | "openaiApiKey"
404
+ | "openaiBaseUrl"
405
+ | "openaiApiMode"
406
+ >;
407
+
408
+ async function askClaudeBackend(config: Config): Promise<BackendAnswers> {
409
+ const claudeBinary = await askOrExit(
410
+ p.text({
405
411
  message: "Claude Code binary path",
406
412
  placeholder: "leave empty for default (claude)",
407
413
  initialValue: config.claudeBinary || "",
408
- });
409
- if (p.isCancel(claudeBinaryInput)) {
410
- p.cancel("Cancelled.");
411
- process.exit(0);
412
- }
413
- claudeBinary = (claudeBinaryInput as string).trim() || undefined;
414
- } else if (backend === "codex") {
415
- const keyInput = await p.text({
414
+ }),
415
+ );
416
+ return { claudeBinary: trimmedOrUndefined(claudeBinary) };
417
+ }
418
+
419
+ async function askCodexBackend(config: Config): Promise<BackendAnswers> {
420
+ const codexApiKey = await askOrExit(
421
+ p.text({
416
422
  message: "Codex OpenAI API key",
417
423
  placeholder:
418
424
  "leave empty to use CODEX_API_KEY / TALON_CODEX_KEY env or `codex login` auth",
419
425
  initialValue: config.codexApiKey || "",
420
- });
421
- if (p.isCancel(keyInput)) {
422
- p.cancel("Cancelled.");
423
- process.exit(0);
424
- }
425
- codexApiKey = (keyInput as string).trim() || undefined;
426
- } else if (backend === "openai-agents") {
427
- const keyInput = await p.text({
428
- message:
429
- "API key (OpenAI, OpenRouter, Azure, or whatever your endpoint requires)",
430
- placeholder: "leave empty to use OPENAI_API_KEY env",
431
- initialValue: config.openaiApiKey || "",
432
- });
433
- if (p.isCancel(keyInput)) {
434
- p.cancel("Cancelled.");
435
- process.exit(0);
436
- }
437
- openaiApiKey = (keyInput as string).trim() || undefined;
438
-
439
- const baseUrlInput = await p.text({
440
- message:
441
- "Base URL " +
442
- pc.dim(
443
- "(leave empty for OpenAI direct; e.g. https://openrouter.ai/api/v1)",
444
- ),
445
- placeholder: "https://openrouter.ai/api/v1",
446
- initialValue: config.openaiBaseUrl || "",
447
- });
448
- if (p.isCancel(baseUrlInput)) {
449
- p.cancel("Cancelled.");
450
- process.exit(0);
451
- }
452
- openaiBaseUrl = (baseUrlInput as string).trim() || undefined;
453
-
454
- if (openaiBaseUrl) {
455
- const modeSelection = await p.select({
456
- message: "OpenAI API surface",
457
- options: [
458
- {
459
- value: "chat_completions",
460
- label: `Chat Completions ${pc.dim("— most third parties (OpenRouter, Ollama, LiteLLM, most Azure)")}`,
461
- },
462
- {
463
- value: "responses",
464
- label: `Responses ${pc.dim("— OpenAI native, requires proxy support")}`,
465
- },
466
- ],
467
- initialValue: config.openaiApiMode ?? "chat_completions",
468
- });
469
- if (p.isCancel(modeSelection)) {
470
- p.cancel("Cancelled.");
471
- process.exit(0);
472
- }
473
- openaiApiMode = modeSelection as "responses" | "chat_completions";
474
- }
426
+ }),
427
+ );
428
+ return { codexApiKey: trimmedOrUndefined(codexApiKey) };
429
+ }
430
+
431
+ async function askOpenAiAgentsBackend(config: Config): Promise<BackendAnswers> {
432
+ const openaiApiKey = trimmedOrUndefined(
433
+ await askOrExit(
434
+ p.text({
435
+ message:
436
+ "API key (OpenAI, OpenRouter, Azure, or whatever your endpoint requires)",
437
+ placeholder: "leave empty to use OPENAI_API_KEY env",
438
+ initialValue: config.openaiApiKey || "",
439
+ }),
440
+ ),
441
+ );
442
+
443
+ const openaiBaseUrl = trimmedOrUndefined(
444
+ await askOrExit(
445
+ p.text({
446
+ message:
447
+ "Base URL " +
448
+ pc.dim(
449
+ "(leave empty for OpenAI direct; e.g. https://openrouter.ai/api/v1)",
450
+ ),
451
+ placeholder: "https://openrouter.ai/api/v1",
452
+ initialValue: config.openaiBaseUrl || "",
453
+ }),
454
+ ),
455
+ );
456
+ if (!openaiBaseUrl) return { openaiApiKey, openaiBaseUrl };
457
+
458
+ const openaiApiMode = await askOrExit(
459
+ p.select<NonNullable<Config["openaiApiMode"]>>({
460
+ message: "OpenAI API surface",
461
+ options: [
462
+ {
463
+ value: "chat_completions",
464
+ label: `Chat Completions ${pc.dim("— most third parties (OpenRouter, Ollama, LiteLLM, most Azure)")}`,
465
+ },
466
+ {
467
+ value: "responses",
468
+ label: `Responses ${pc.dim("— OpenAI native, requires proxy support")}`,
469
+ },
470
+ ],
471
+ initialValue: config.openaiApiMode ?? "chat_completions",
472
+ }),
473
+ );
474
+ return { openaiApiKey, openaiBaseUrl, openaiApiMode };
475
+ }
476
+
477
+ /**
478
+ * kilo / opencode need no extra prompts — bundled SDK + per-provider creds
479
+ * configured separately (kilo via `kilo login`, opencode via its own auth
480
+ * flow).
481
+ */
482
+ async function askBackendCredentials(
483
+ config: Config,
484
+ backend: Config["backend"],
485
+ ): Promise<BackendAnswers> {
486
+ switch (backend) {
487
+ case "claude":
488
+ return askClaudeBackend(config);
489
+ case "codex":
490
+ return askCodexBackend(config);
491
+ case "openai-agents":
492
+ return askOpenAiAgentsBackend(config);
493
+ default:
494
+ return {};
475
495
  }
496
+ }
476
497
 
477
- // kilo / opencode need no extra prompts — bundled SDK + per-provider
478
- // creds configured separately (kilo via `kilo login`, opencode via
479
- // its own auth flow).
498
+ // ── The wizard: the steps above, in order ──
499
+
500
+ export async function runSetup(): Promise<void> {
501
+ printBanner();
502
+ p.intro(pc.inverse(" Setup Wizard "));
503
+
504
+ const config = loadConfig();
505
+ const selectedFrontends = await askFrontends(config);
506
+ const on = (id: string) => selectedFrontends.includes(id);
507
+
508
+ const telegram = on("telegram") ? await askTelegram(config) : {};
509
+ const teams = on("teams") ? await askTeams(config) : {};
510
+ const discord = on("discord") ? await askDiscord(config) : {};
511
+ const whatsapp = on("whatsapp") ? await askWhatsApp(config) : undefined;
512
+
513
+ const model = await askModel(config);
514
+ const pulse = await askPulse(config, selectedFrontends);
515
+ const backend = await askBackend(config);
516
+ const credentials = await askBackendCredentials(config, backend);
480
517
 
481
518
  const newConfig = buildSetupConfig(config, {
482
519
  selectedFrontends,
483
520
  backend,
484
- botToken,
485
- claudeBinary,
486
- codexApiKey,
487
- openaiApiKey,
488
- openaiBaseUrl,
489
- openaiApiMode,
490
- model: model as string,
491
- pulse: pulse as boolean,
492
- adminId,
493
- apiId,
494
- apiHash,
495
- teamsWebhookUrl,
496
- teamsWebhookSecret,
497
- teamsWebhookPort,
498
- teamsBotDisplayName,
499
- discordBotToken,
500
- discordApplicationId,
521
+ model,
522
+ pulse,
501
523
  whatsapp,
524
+ ...telegram,
525
+ ...teams,
526
+ ...discord,
527
+ ...credentials,
502
528
  });
503
529
 
504
530
  const s = p.spinner();
@@ -508,7 +534,7 @@ export async function runSetup(): Promise<void> {
508
534
 
509
535
  p.outro(`Run ${pc.cyan(pc.bold("talon start"))} to launch Talon`);
510
536
 
511
- if (selectedFrontends.includes("telegram") && apiId && apiHash) {
537
+ if (on("telegram") && telegram.apiId && telegram.apiHash) {
512
538
  console.log(
513
539
  ` ${pc.yellow("!")} Run ${pc.cyan("npx tsx src/login.ts")} to authenticate the userbot first.\n`,
514
540
  );