@rubytech/taskmaster 1.9.4 → 1.9.6

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 (76) hide show
  1. package/dist/agents/pi-embedded-runner/run/attempt.js +40 -0
  2. package/dist/agents/taskmaster-tools.js +3 -0
  3. package/dist/agents/tool-policy.js +10 -1
  4. package/dist/agents/tools/apikeys-tool.js +2 -2
  5. package/dist/agents/tools/file-delete-tool.js +20 -15
  6. package/dist/agents/tools/file-list-tool.js +9 -2
  7. package/dist/agents/tools/verify-contact-tool.js +197 -0
  8. package/dist/agents/workspace-migrations.js +163 -0
  9. package/dist/build-info.json +3 -3
  10. package/dist/config/defaults.js +4 -0
  11. package/dist/config/legacy.migrations.part-3.js +24 -0
  12. package/dist/config/zod-schema.js +21 -0
  13. package/dist/control-ui/assets/index-DpyzE2YD.js +4532 -0
  14. package/dist/control-ui/assets/index-DpyzE2YD.js.map +1 -0
  15. package/dist/control-ui/assets/index-ouo9dqKk.css +1 -0
  16. package/dist/control-ui/index.html +2 -2
  17. package/dist/gateway/control-ui.js +6 -1
  18. package/dist/gateway/public-chat/deliver-email.js +39 -0
  19. package/dist/gateway/public-chat/deliver-otp.js +59 -6
  20. package/dist/gateway/public-chat/deliver-sms.js +44 -0
  21. package/dist/gateway/public-chat/otp.js +14 -12
  22. package/dist/gateway/public-chat-api.js +100 -24
  23. package/dist/gateway/server-chat.js +5 -0
  24. package/dist/gateway/server-methods/access.js +11 -1
  25. package/dist/gateway/server-methods/apikeys.js +8 -4
  26. package/dist/gateway/server-methods/chat.js +14 -0
  27. package/dist/gateway/server-methods/public-chat.js +94 -22
  28. package/dist/gateway/server-methods/tailscale.js +83 -24
  29. package/dist/gateway/server.impl.js +5 -0
  30. package/dist/memory/manager.js +6 -2
  31. package/dist/records/records-manager.js +25 -1
  32. package/package.json +1 -1
  33. package/skills/twilio/SKILL.md +29 -0
  34. package/skills/twilio/references/browser-setup.md +95 -0
  35. package/templates/beagle/agents/admin/AGENTS.md +24 -0
  36. package/templates/beagle/agents/public/AGENTS.md +6 -0
  37. package/templates/customer/agents/admin/AGENTS.md +24 -0
  38. package/templates/customer/agents/public/AGENTS.md +6 -0
  39. package/templates/education-hero/agents/admin/AGENTS.md +184 -0
  40. package/templates/education-hero/agents/admin/BOOTSTRAP.md +114 -0
  41. package/templates/education-hero/agents/admin/HEARTBEAT.md +10 -0
  42. package/templates/education-hero/agents/admin/IDENTITY.md +13 -0
  43. package/templates/education-hero/agents/admin/SOUL.md +34 -0
  44. package/templates/education-hero/agents/admin/TOOLS.md +36 -0
  45. package/templates/education-hero/agents/admin/USER.md +13 -0
  46. package/templates/education-hero/agents/public/AGENTS.md +173 -0
  47. package/templates/education-hero/agents/public/IDENTITY.md +10 -0
  48. package/templates/education-hero/agents/public/SOUL.md +84 -0
  49. package/templates/education-hero/skills/education-hero/SKILL.md +43 -0
  50. package/templates/education-hero/skills/education-hero/references/admin-process.md +28 -0
  51. package/templates/education-hero/skills/education-hero/references/brand-voice.md +51 -0
  52. package/templates/education-hero/skills/education-hero/references/deregistration.md +34 -0
  53. package/templates/education-hero/skills/education-hero/references/educational-approach.md +28 -0
  54. package/templates/education-hero/skills/education-hero/references/intent-classification.md +39 -0
  55. package/templates/education-hero/skills/education-hero/references/la-email-analysis.md +42 -0
  56. package/templates/education-hero/skills/education-hero/references/legal-rights.md +37 -0
  57. package/templates/education-hero/skills/education-hero/references/report-writing.md +30 -0
  58. package/templates/education-hero/skills/interactive-tutor/SKILL.md +60 -0
  59. package/templates/education-hero/skills/interactive-tutor/references/assessment.md +70 -0
  60. package/templates/education-hero/skills/interactive-tutor/references/classroom-conduct.md +43 -0
  61. package/templates/education-hero/skills/interactive-tutor/references/teaching-modes.md +83 -0
  62. package/templates/education-hero/skills/lesson-planner/SKILL.md +49 -0
  63. package/templates/education-hero/skills/lesson-planner/references/context-gathering.md +41 -0
  64. package/templates/education-hero/skills/lesson-planner/references/plan-structure.md +94 -0
  65. package/templates/education-hero/skills/study-pack-builder/SKILL.md +53 -0
  66. package/templates/education-hero/skills/study-pack-builder/references/disaggregation.md +49 -0
  67. package/templates/education-hero/skills/study-pack-builder/references/materials.md +116 -0
  68. package/templates/maxy/agents/admin/AGENTS.md +20 -0
  69. package/templates/maxy/agents/public/AGENTS.md +4 -0
  70. package/templates/taskmaster/agents/admin/AGENTS.md +24 -0
  71. package/templates/taskmaster/agents/public/AGENTS.md +6 -0
  72. package/templates/tradesupport/agents/admin/AGENTS.md +24 -0
  73. package/templates/tradesupport/agents/public/AGENTS.md +6 -0
  74. package/dist/control-ui/assets/index-CHIqq3Nn.css +0 -1
  75. package/dist/control-ui/assets/index-zUaHKRVM.js +0 -4227
  76. package/dist/control-ui/assets/index-zUaHKRVM.js.map +0 -1
@@ -5,16 +5,18 @@
5
5
  *
6
6
  * Endpoints:
7
7
  * POST /session — create an anonymous session (returns sessionKey)
8
- * POST /otp/request — request a WhatsApp OTP code
8
+ * POST /otp/request — request an OTP code (phone via WhatsApp/SMS, or email via Resend)
9
9
  * POST /otp/verify — verify OTP and get a verified session (returns sessionKey)
10
10
  * POST /chat — send a message, receive the agent reply (sync or SSE stream)
11
11
  * GET /chat/history — retrieve past messages for a session
12
12
  * POST /chat/abort — cancel an in-progress agent run
13
+ * GET /capabilities — discover available auth methods and OTP channels
13
14
  *
14
15
  * Authentication mirrors the public chat widget: anonymous sessions use a
15
- * client-provided identity string; verified sessions use WhatsApp OTP.
16
- * The sessionKey returned from /session or /otp/verify is passed via the
17
- * X-Session-Key header on subsequent requests.
16
+ * client-provided identity string; verified sessions use OTP sent via phone
17
+ * (WhatsApp with SMS fallback) or email (Resend). The sessionKey returned
18
+ * from /session or /otp/verify is passed via the X-Session-Key header on
19
+ * subsequent requests.
18
20
  *
19
21
  * The chat endpoint uses `dispatchInboundMessage` — the same full pipeline
20
22
  * as the WebSocket `chat.send` handler — so filler messages, internal hooks,
@@ -34,10 +36,10 @@ import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-
34
36
  import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
35
37
  import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
36
38
  import { requestOtp, verifyOtp } from "./public-chat/otp.js";
37
- import { deliverOtp } from "./public-chat/deliver-otp.js";
39
+ import { deliverOtp, detectOtpChannels } from "./public-chat/deliver-otp.js";
38
40
  import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
39
41
  import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
40
- import { extractFileAttachments, sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
42
+ import { extractFileAttachments, sanitizeMediaForChat, stripEnvelopeFromMessages, } from "./chat-sanitize.js";
41
43
  import { resolveWorkspaceRoot } from "./media-http.js";
42
44
  import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
43
45
  // ---------------------------------------------------------------------------
@@ -88,6 +90,10 @@ function getSessionKeyHeader(req) {
88
90
  function isValidPhone(phone) {
89
91
  return /^\+\d{7,15}$/.test(phone);
90
92
  }
93
+ /** Basic email format check. */
94
+ function isValidEmail(email) {
95
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
96
+ }
91
97
  /** Strip spaces, dashes, and parentheses from a phone number. */
92
98
  function normalizePhone(raw) {
93
99
  return raw.replace(/[\s\-()]/g, "");
@@ -216,12 +222,38 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
216
222
  if (body === undefined)
217
223
  return;
218
224
  const payload = (body && typeof body === "object" ? body : {});
219
- const phone = typeof payload.phone === "string" ? normalizePhone(payload.phone.trim()) : "";
220
- if (!phone || !isValidPhone(phone)) {
221
- sendInvalidRequest(res, "invalid phone number — use E.164 format (e.g. +447123456789)");
222
- return;
225
+ // Accept `identifier` (preferred) or `phone` (backward compat).
226
+ const rawIdentifier = typeof payload.identifier === "string"
227
+ ? payload.identifier.trim()
228
+ : typeof payload.phone === "string"
229
+ ? payload.phone.trim()
230
+ : "";
231
+ const isEmail = rawIdentifier.includes("@");
232
+ const verifyMethods = cfg.publicChat?.verifyMethods ?? ["phone"];
233
+ let identifier;
234
+ if (isEmail) {
235
+ if (!verifyMethods.includes("email")) {
236
+ sendForbidden(res, "email verification is not enabled for this account");
237
+ return;
238
+ }
239
+ identifier = rawIdentifier.toLowerCase();
240
+ if (!isValidEmail(identifier)) {
241
+ sendInvalidRequest(res, "invalid email address");
242
+ return;
243
+ }
244
+ }
245
+ else {
246
+ if (!verifyMethods.includes("phone")) {
247
+ sendForbidden(res, "phone verification is not enabled for this account");
248
+ return;
249
+ }
250
+ identifier = normalizePhone(rawIdentifier);
251
+ if (!identifier || !isValidPhone(identifier)) {
252
+ sendInvalidRequest(res, "invalid phone number — use E.164 format (e.g. +447123456789)");
253
+ return;
254
+ }
223
255
  }
224
- const result = requestOtp(phone);
256
+ const result = requestOtp(identifier);
225
257
  if (!result.ok) {
226
258
  sendJson(res, 429, {
227
259
  error: { message: "rate limited — try again shortly", type: "rate_limited" },
@@ -234,13 +266,13 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
234
266
  const agentId = resolvePublicAgentId(cfg, accountId);
235
267
  const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
236
268
  try {
237
- await deliverOtp(phone, result.code, whatsappAccountId);
269
+ const delivery = await deliverOtp(identifier, result.code, whatsappAccountId);
270
+ sendJson(res, 200, { ok: true, channel: delivery.channel });
238
271
  }
239
- catch {
240
- sendUnavailable(res, "failed to send verification code — is WhatsApp connected?");
241
- return;
272
+ catch (err) {
273
+ const message = err instanceof Error ? err.message : "failed to send verification code";
274
+ sendUnavailable(res, message);
242
275
  }
243
- sendJson(res, 200, { ok: true });
244
276
  }
245
277
  // ---------------------------------------------------------------------------
246
278
  // Route: POST /otp/verify
@@ -258,21 +290,38 @@ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
258
290
  if (body === undefined)
259
291
  return;
260
292
  const payload = (body && typeof body === "object" ? body : {});
261
- const phone = typeof payload.phone === "string" ? normalizePhone(payload.phone.trim()) : "";
293
+ // Accept `identifier` (preferred) or `phone` (backward compat).
294
+ const rawIdentifier = typeof payload.identifier === "string"
295
+ ? payload.identifier.trim()
296
+ : typeof payload.phone === "string"
297
+ ? payload.phone.trim()
298
+ : "";
262
299
  const code = typeof payload.code === "string" ? payload.code.trim() : "";
263
300
  const name = typeof payload.name === "string" ? payload.name.trim() : undefined;
264
- if (!phone || !isValidPhone(phone)) {
265
- sendInvalidRequest(res, "invalid phone number");
266
- return;
301
+ const isEmail = rawIdentifier.includes("@");
302
+ let identifier;
303
+ if (isEmail) {
304
+ identifier = rawIdentifier.toLowerCase();
305
+ if (!isValidEmail(identifier)) {
306
+ sendInvalidRequest(res, "invalid email address");
307
+ return;
308
+ }
309
+ }
310
+ else {
311
+ identifier = normalizePhone(rawIdentifier);
312
+ if (!identifier || !isValidPhone(identifier)) {
313
+ sendInvalidRequest(res, "invalid phone number");
314
+ return;
315
+ }
267
316
  }
268
317
  if (!code) {
269
318
  sendInvalidRequest(res, "code is required");
270
319
  return;
271
320
  }
272
- const result = verifyOtp(phone, code);
321
+ const result = verifyOtp(identifier, code);
273
322
  if (!result.ok) {
274
323
  const messages = {
275
- not_found: "no pending verification for this number",
324
+ not_found: "no pending verification for this identifier",
276
325
  expired: "verification code expired",
277
326
  max_attempts: "too many attempts — request a new code",
278
327
  invalid: "incorrect code",
@@ -286,11 +335,13 @@ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
286
335
  return;
287
336
  }
288
337
  const agentId = resolvePublicAgentId(cfg, accountId);
289
- const sessionKey = buildPublicSessionKey(agentId, phone);
338
+ const sessionKey = buildPublicSessionKey(agentId, identifier);
290
339
  sendJson(res, 200, {
291
340
  session_key: sessionKey,
292
341
  agent_id: agentId,
293
- phone,
342
+ identifier,
343
+ // Backward-compat: include named field matching the identifier type.
344
+ ...(isEmail ? { email: identifier } : { phone: identifier }),
294
345
  ...(name ? { name } : {}),
295
346
  });
296
347
  }
@@ -692,6 +743,28 @@ async function handleChatAbort(req, res, maxBodyBytes) {
692
743
  sendJson(res, 200, { ok: true, run_id: runId ?? null });
693
744
  }
694
745
  // ---------------------------------------------------------------------------
746
+ // Route: GET /capabilities
747
+ // ---------------------------------------------------------------------------
748
+ async function handleCapabilities(req, res, accountId, cfg) {
749
+ if (req.method !== "GET") {
750
+ sendMethodNotAllowed(res, "GET");
751
+ return;
752
+ }
753
+ const authMode = cfg.publicChat?.auth ?? "anonymous";
754
+ const verifyMethods = cfg.publicChat?.verifyMethods ?? ["phone"];
755
+ const agentId = resolvePublicAgentId(cfg, accountId);
756
+ const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
757
+ const channels = detectOtpChannels(whatsappAccountId);
758
+ sendJson(res, 200, {
759
+ auth: authMode,
760
+ verify_methods: verifyMethods,
761
+ otp: {
762
+ available: channels.length > 0,
763
+ channels,
764
+ },
765
+ });
766
+ }
767
+ // ---------------------------------------------------------------------------
695
768
  // Main handler
696
769
  // ---------------------------------------------------------------------------
697
770
  /**
@@ -741,6 +814,9 @@ export async function handlePublicChatApiRequest(req, res, opts) {
741
814
  case "otp/verify":
742
815
  await handleOtpVerify(req, res, accountId, cfg, maxBodyBytes);
743
816
  return true;
817
+ case "capabilities":
818
+ await handleCapabilities(req, res, accountId, cfg);
819
+ return true;
744
820
  case "chat":
745
821
  await handleChat(req, res, accountId, cfg, maxBodyBytes);
746
822
  return true;
@@ -47,12 +47,14 @@ export function createChatRunState() {
47
47
  const deltaSentAt = new Map();
48
48
  const abortedRuns = new Map();
49
49
  const finalHadContent = new Map();
50
+ const finalTexts = new Map();
50
51
  const clear = () => {
51
52
  registry.clear();
52
53
  buffers.clear();
53
54
  deltaSentAt.clear();
54
55
  abortedRuns.clear();
55
56
  finalHadContent.clear();
57
+ finalTexts.clear();
56
58
  };
57
59
  return {
58
60
  registry,
@@ -60,6 +62,7 @@ export function createChatRunState() {
60
62
  deltaSentAt,
61
63
  abortedRuns,
62
64
  finalHadContent,
65
+ finalTexts,
63
66
  clear,
64
67
  };
65
68
  }
@@ -97,6 +100,8 @@ export function createAgentEventHandler({ broadcast, nodeSendToSession, agentRun
97
100
  // Record whether the streaming buffer had content so the chat.send .then()
98
101
  // handler knows whether it needs to broadcast the dispatcher's final reply.
99
102
  chatRunState.finalHadContent.set(clientRunId, !!text);
103
+ if (text)
104
+ chatRunState.finalTexts.set(clientRunId, text);
100
105
  if (jobState === "done") {
101
106
  const payload = {
102
107
  runId: clientRunId,
@@ -196,7 +196,17 @@ export const accessHandlers = {
196
196
  */
197
197
  "access.setAccountPin": async ({ params, respond }) => {
198
198
  try {
199
- const workspace = params.workspace?.trim();
199
+ // Normalise workspace name to match sanitiseName() in workspaces.ts —
200
+ // the UI may pass the raw user-typed name (e.g. "Joe's Store") while
201
+ // the workspace was created with the sanitised slug ("joes-store").
202
+ const rawWorkspace = params.workspace?.trim();
203
+ const workspace = rawWorkspace
204
+ ? rawWorkspace
205
+ .toLowerCase()
206
+ .replace(/[^a-z0-9_-]/g, "-")
207
+ .replace(/-+/g, "-")
208
+ .replace(/^-|-$/g, "")
209
+ : "";
200
210
  const pin = params.pin?.trim();
201
211
  if (!workspace) {
202
212
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "workspace is required"));
@@ -16,9 +16,7 @@ import { readConfigFileSnapshot, writeConfigFile } from "../../config/config.js"
16
16
  import { ErrorCodes, errorShape } from "../protocol/index.js";
17
17
  import { formatForLog } from "../ws-log.js";
18
18
  /** Providers whose API keys are stored as auth profiles (type: api_key). */
19
- const AUTH_PROFILE_PROVIDERS = new Set([
20
- "anthropic", "openai", "google", "replicate", "hume",
21
- ]);
19
+ const AUTH_PROFILE_PROVIDERS = new Set(["anthropic", "openai", "google", "replicate", "hume"]);
22
20
  const PROVIDER_CATALOG = [
23
21
  { id: "anthropic", name: "Anthropic", category: "AI Model", primary: true },
24
22
  { id: "google", name: "Google", category: "Voice & Video", primary: true },
@@ -28,6 +26,7 @@ const PROVIDER_CATALOG = [
28
26
  { id: "hume", name: "Hume", category: "Voice" },
29
27
  { id: "brave", name: "Brave", category: "Web Search" },
30
28
  { id: "elevenlabs", name: "ElevenLabs", category: "Voice" },
29
+ { id: "resend", name: "Resend", category: "Email" },
31
30
  ];
32
31
  const VALID_PROVIDER_IDS = new Set(PROVIDER_CATALOG.map((p) => p.id));
33
32
  export const apikeysHandlers = {
@@ -38,7 +37,12 @@ export const apikeysHandlers = {
38
37
  const disabledMap = snapshot.config.apiKeysDisabled ?? {};
39
38
  const providers = PROVIDER_CATALOG.map((p) => {
40
39
  const raw = storedKeys[p.id]?.trim();
41
- return { ...p, hasKey: Boolean(raw), disabled: Boolean(disabledMap[p.id]), key: raw || undefined };
40
+ return {
41
+ ...p,
42
+ hasKey: Boolean(raw),
43
+ disabled: Boolean(disabledMap[p.id]),
44
+ key: raw || undefined,
45
+ };
42
46
  });
43
47
  respond(true, { providers });
44
48
  }
@@ -18,6 +18,7 @@ import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from ".
18
18
  import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "../chat-sanitize.js";
19
19
  import { resolveWorkspaceRoot } from "../media-http.js";
20
20
  import { formatForLog } from "../ws-log.js";
21
+ import { fireSuggestion } from "../../suggestions/broadcast.js";
21
22
  function resolveTranscriptPath(params) {
22
23
  const { sessionId, storePath, sessionFile } = params;
23
24
  if (sessionFile)
@@ -701,6 +702,19 @@ export const chatHandlers = {
701
702
  cfg,
702
703
  }));
703
704
  }
705
+ // Fire suggestion chips for the next user interaction
706
+ const streamedText = context.chatFinalTexts.get(clientRunId) ?? "";
707
+ context.chatFinalTexts.delete(clientRunId);
708
+ const lastAssistantReply = outboundText || streamedText;
709
+ if (lastAssistantReply) {
710
+ fireSuggestion({
711
+ sessionKey: p.sessionKey,
712
+ broadcast: context.broadcast,
713
+ cfg,
714
+ lastUserMessage: p.message,
715
+ lastAssistantReply,
716
+ });
717
+ }
704
718
  context.dedupe.set(`chat:${clientRunId}`, {
705
719
  ts: Date.now(),
706
720
  ok: true,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { loadConfig } from "../../config/config.js";
5
5
  import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
6
+ import { generateGreeting } from "../../suggestions/greeting.js";
6
7
  import { ErrorCodes, errorShape } from "../protocol/index.js";
7
8
  import { requestOtp, verifyOtp } from "../public-chat/otp.js";
8
9
  import { deliverOtp } from "../public-chat/deliver-otp.js";
@@ -15,6 +16,10 @@ function normalizePhone(raw) {
15
16
  function isValidPhone(phone) {
16
17
  return /^\+\d{7,15}$/.test(phone);
17
18
  }
19
+ /** Basic email format check. */
20
+ function isValidEmail(email) {
21
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
22
+ }
18
23
  function validateAccountId(raw) {
19
24
  if (typeof raw !== "string")
20
25
  return null;
@@ -25,22 +30,48 @@ function validateAccountId(raw) {
25
30
  }
26
31
  export const publicChatHandlers = {
27
32
  /**
28
- * Request an OTP code — sends a 6-digit code to the given phone via WhatsApp.
29
- * Params: { phone: string }
33
+ * Request an OTP code — sends a 6-digit code via WhatsApp/SMS (phone) or
34
+ * Resend (email).
35
+ * Params: { identifier: string } or { phone: string } (backward compat)
30
36
  */
31
37
  "public.otp.request": async ({ params, respond, context }) => {
32
- const phone = typeof params.phone === "string" ? normalizePhone(params.phone.trim()) : "";
38
+ const rawIdentifier = typeof params.identifier === "string"
39
+ ? params.identifier.trim()
40
+ : typeof params.phone === "string"
41
+ ? params.phone.trim()
42
+ : "";
33
43
  const accountId = validateAccountId(params.accountId);
34
- if (!phone || !isValidPhone(phone)) {
35
- respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
36
- return;
37
- }
38
44
  const cfg = loadConfig();
39
45
  if (!cfg.publicChat?.enabled) {
40
46
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
41
47
  return;
42
48
  }
43
- const result = requestOtp(phone);
49
+ const isEmail = rawIdentifier.includes("@");
50
+ const verifyMethods = cfg.publicChat?.verifyMethods ?? ["phone"];
51
+ let identifier;
52
+ if (isEmail) {
53
+ if (!verifyMethods.includes("email")) {
54
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "email verification is not enabled"));
55
+ return;
56
+ }
57
+ identifier = rawIdentifier.toLowerCase();
58
+ if (!isValidEmail(identifier)) {
59
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid email address"));
60
+ return;
61
+ }
62
+ }
63
+ else {
64
+ if (!verifyMethods.includes("phone")) {
65
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "phone verification is not enabled"));
66
+ return;
67
+ }
68
+ identifier = normalizePhone(rawIdentifier);
69
+ if (!identifier || !isValidPhone(identifier)) {
70
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
71
+ return;
72
+ }
73
+ }
74
+ const result = requestOtp(identifier);
44
75
  if (!result.ok) {
45
76
  respond(false, { retryAfterMs: result.retryAfterMs }, errorShape(ErrorCodes.INVALID_REQUEST, "rate limited — try again shortly"));
46
77
  return;
@@ -52,27 +83,44 @@ export const publicChatHandlers = {
52
83
  ? (resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined)
53
84
  : undefined;
54
85
  try {
55
- await deliverOtp(phone, result.code, whatsappAccountId);
86
+ const delivery = await deliverOtp(identifier, result.code, whatsappAccountId);
87
+ respond(true, { ok: true, channel: delivery.channel });
56
88
  }
57
89
  catch (err) {
58
- context.logGateway.warn(`public-chat OTP delivery failed: ${String(err)}`);
59
- respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "failed to send verification code"));
60
- return;
90
+ const message = err instanceof Error ? err.message : "failed to send verification code";
91
+ context.logGateway.warn(`public-chat OTP delivery failed: ${message}`);
92
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
61
93
  }
62
- respond(true, { ok: true });
63
94
  },
64
95
  /**
65
96
  * Verify an OTP code and return the session key.
66
- * Params: { phone: string, code: string, accountId: string, name?: string }
97
+ * Params: { identifier: string, code: string, accountId: string, name?: string }
98
+ * (or { phone: string } for backward compat)
67
99
  */
68
100
  "public.otp.verify": async ({ params, respond }) => {
69
- const phone = typeof params.phone === "string" ? normalizePhone(params.phone.trim()) : "";
101
+ const rawIdentifier = typeof params.identifier === "string"
102
+ ? params.identifier.trim()
103
+ : typeof params.phone === "string"
104
+ ? params.phone.trim()
105
+ : "";
70
106
  const code = typeof params.code === "string" ? params.code.trim() : "";
71
107
  const name = typeof params.name === "string" ? params.name.trim() : undefined;
72
108
  const accountId = validateAccountId(params.accountId);
73
- if (!phone || !isValidPhone(phone)) {
74
- respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
75
- return;
109
+ const isEmail = rawIdentifier.includes("@");
110
+ let identifier;
111
+ if (isEmail) {
112
+ identifier = rawIdentifier.toLowerCase();
113
+ if (!isValidEmail(identifier)) {
114
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid email address"));
115
+ return;
116
+ }
117
+ }
118
+ else {
119
+ identifier = normalizePhone(rawIdentifier);
120
+ if (!identifier || !isValidPhone(identifier)) {
121
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
122
+ return;
123
+ }
76
124
  }
77
125
  if (!code) {
78
126
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "code required"));
@@ -87,10 +135,10 @@ export const publicChatHandlers = {
87
135
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
88
136
  return;
89
137
  }
90
- const result = verifyOtp(phone, code);
138
+ const result = verifyOtp(identifier, code);
91
139
  if (!result.ok) {
92
140
  const messages = {
93
- not_found: "no pending verification for this number",
141
+ not_found: "no pending verification for this identifier",
94
142
  expired: "verification code expired",
95
143
  max_attempts: "too many attempts — request a new code",
96
144
  invalid: "incorrect code",
@@ -99,12 +147,14 @@ export const publicChatHandlers = {
99
147
  return;
100
148
  }
101
149
  const agentId = resolvePublicAgentId(cfg, accountId);
102
- const sessionKey = buildPublicSessionKey(agentId, phone);
150
+ const sessionKey = buildPublicSessionKey(agentId, identifier);
103
151
  respond(true, {
104
152
  ok: true,
105
153
  sessionKey,
106
154
  agentId,
107
- phone,
155
+ identifier,
156
+ // Backward compat: include named field matching the identifier type.
157
+ ...(isEmail ? { email: identifier } : { phone: identifier }),
108
158
  name,
109
159
  });
110
160
  },
@@ -137,4 +187,26 @@ export const publicChatHandlers = {
137
187
  agentId,
138
188
  });
139
189
  },
190
+ /**
191
+ * Generate a proactive greeting using the agent's identity and persona.
192
+ * Params: { accountId: string }
193
+ * Returns: { ok: true, greeting: string }
194
+ */
195
+ "public.greeting.generate": async ({ params, respond }) => {
196
+ const accountId = validateAccountId(params.accountId);
197
+ if (!accountId) {
198
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "accountId required"));
199
+ return;
200
+ }
201
+ const cfg = loadConfig();
202
+ const agentId = resolvePublicAgentId(cfg, accountId);
203
+ try {
204
+ const result = await generateGreeting({ cfg, agentId });
205
+ respond(true, result);
206
+ }
207
+ catch (err) {
208
+ const message = err instanceof Error ? err.message : "greeting generation failed";
209
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
210
+ }
211
+ },
140
212
  };
@@ -108,36 +108,77 @@ export const tailscaleHandlers = {
108
108
  * `loggedIn` becomes true.
109
109
  */
110
110
  "tailscale.enable": async ({ respond, context }) => {
111
+ // Matches auth URLs across Tailscale versions:
112
+ // - https://login.tailscale.com/a/<code>
113
+ // - https://login.tailscale.com/authorize/<code>
114
+ // - any other login.tailscale.com path with an auth token
115
+ const authUrlPattern = /https:\/\/login\.tailscale\.com\/[a-z]+\/[A-Za-z0-9_-]+/;
116
+ const extractAuthUrl = (output) => output.match(authUrlPattern)?.[0];
117
+ const captureExecOutput = (err) => {
118
+ const errObj = err;
119
+ return {
120
+ stdout: typeof errObj.stdout === "string" ? errObj.stdout : "",
121
+ stderr: typeof errObj.stderr === "string" ? errObj.stderr : "",
122
+ message: typeof errObj.message === "string" ? errObj.message : "",
123
+ };
124
+ };
125
+ const needsSudo = (output) => {
126
+ const lower = output.toLowerCase();
127
+ return (lower.includes("access denied") ||
128
+ lower.includes("permission denied") ||
129
+ lower.includes("use 'sudo tailscale") ||
130
+ lower.includes("requires root"));
131
+ };
132
+ // Run `tailscale up` (with optional extra args) and capture output.
133
+ // If the command fails with a permission error, retries with `sudo -n`.
134
+ // `tailscale up` blocks until auth completes, so timeouts are expected —
135
+ // the auth URL appears in stdout/stderr before the timeout kills it.
136
+ const runTailscaleUp = async (binary, extraArgs = []) => {
137
+ const args = ["up", ...extraArgs];
138
+ const execOpts = { timeoutMs: 60_000, maxBuffer: 100_000 };
139
+ const result = await runExec(binary, args, execOpts).catch((err) => captureExecOutput(err));
140
+ const output = `${result.stdout}\n${result.stderr}`;
141
+ if (!needsSudo(output))
142
+ return output;
143
+ // Permission denied — retry with sudo -n (non-interactive)
144
+ context.logGateway.info(`tailscale.enable: permission denied, retrying with sudo: ${args.join(" ")}`);
145
+ const sudoResult = await runExec("sudo", ["-n", binary, ...args], execOpts).catch((err) => captureExecOutput(err));
146
+ return `${sudoResult.stdout}\n${sudoResult.stderr}`;
147
+ };
111
148
  try {
112
149
  const binary = await findTailscaleBinary();
113
150
  if (!binary) {
114
151
  respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Tailscale is not installed on this device"));
115
152
  return;
116
153
  }
117
- // Run `tailscale up` on a headless device this prints:
118
- // "To authenticate, visit:\n\n\thttps://login.tailscale.com/a/..."
119
- // The command blocks until auth completes, so we run it with a
120
- // timeout and parse the auth URL from the output. The URL appears
121
- // within seconds; the timeout just kills the blocking wait.
122
- const child = await runExec(binary, ["up"], {
123
- timeoutMs: 60_000,
124
- maxBuffer: 100_000,
125
- }).catch((err) => {
126
- // `tailscale up` exits non-zero while waiting for auth but still
127
- // prints the URL to stdout/stderr before the timeout kills it.
128
- const errObj = err;
129
- return {
130
- stdout: typeof errObj.stdout === "string" ? errObj.stdout : "",
131
- stderr: typeof errObj.stderr === "string" ? errObj.stderr : "",
132
- };
133
- });
134
- const combined = `${child.stdout}\n${child.stderr}`;
135
- const urlMatch = combined.match(/https:\/\/login\.tailscale\.com\/a\/[A-Za-z0-9]+/);
136
- if (urlMatch) {
137
- respond(true, { authUrl: urlMatch[0] });
154
+ // ── Pre-check: is tailscaled reachable? ──
155
+ // On Linux (Pi), tailscaled runs as a systemd service. After a package
156
+ // upgrade it may need restarting. Detect this early with a clear message
157
+ // instead of falling through to a generic "no URL" error.
158
+ try {
159
+ await runExec(binary, ["status", "--json"], { timeoutMs: 5_000, maxBuffer: 100_000 });
160
+ }
161
+ catch (statusErr) {
162
+ const { stderr, message } = captureExecOutput(statusErr);
163
+ const detail = `${stderr}\n${message}`.toLowerCase();
164
+ if (detail.includes("connection refused") ||
165
+ detail.includes("no such file or directory") ||
166
+ detail.includes("connect:") ||
167
+ detail.includes("is tailscaled running")) {
168
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "The Tailscale service (tailscaled) is not running. " +
169
+ "Try restarting it: sudo systemctl restart tailscaled"));
170
+ return;
171
+ }
172
+ // Other status errors (e.g. NeedsLogin) are fine — continue to `tailscale up`
173
+ }
174
+ // ── Attempt 1: `tailscale up` ──
175
+ const combined = await runTailscaleUp(binary);
176
+ const authUrl = extractAuthUrl(combined);
177
+ if (authUrl) {
178
+ respond(true, { authUrl });
138
179
  return;
139
180
  }
140
- // No auth URL found — maybe already logged in
181
+ // No auth URL found — check if already logged in
141
182
  try {
142
183
  const status = await readTailscaleStatusJson();
143
184
  if (status.BackendState === "Running") {
@@ -148,8 +189,26 @@ export const tailscaleHandlers = {
148
189
  catch {
149
190
  // ignore
150
191
  }
151
- context.logGateway.warn(`tailscale.enable: no auth URL found in output: ${combined.slice(0, 500)}`);
152
- respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Could not start Tailscale login no authentication URL received"));
192
+ // ── Attempt 2: `tailscale up --force-reauth` ──
193
+ // After a software upgrade or expired auth, the node may be in a stale
194
+ // state where plain `tailscale up` doesn't produce a new auth URL.
195
+ // --force-reauth forces a fresh login flow that always emits a URL.
196
+ context.logGateway.info("tailscale.enable: no auth URL from initial attempt, retrying with --force-reauth");
197
+ const retryCombined = await runTailscaleUp(binary, ["--force-reauth"]);
198
+ const retryUrl = extractAuthUrl(retryCombined);
199
+ if (retryUrl) {
200
+ respond(true, { authUrl: retryUrl });
201
+ return;
202
+ }
203
+ // ── Still nothing — surface diagnostic info ──
204
+ const allOutput = `${combined}\n${retryCombined}`.trim();
205
+ context.logGateway.warn(`tailscale.enable: no auth URL found in output: ${allOutput.slice(0, 500)}`);
206
+ // Include a snippet of what Tailscale actually said, so the user can diagnose
207
+ const snippet = allOutput.replace(/\s+/g, " ").trim().slice(0, 200);
208
+ const detail = snippet
209
+ ? ` Tailscale output: ${snippet}`
210
+ : " No output received from Tailscale.";
211
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `Could not start Tailscale login — no authentication URL received.${detail}`));
153
212
  }
154
213
  catch (err) {
155
214
  context.logGateway.warn(`tailscale.enable failed: ${err instanceof Error ? err.message : String(err)}`);