@rubytech/taskmaster 1.16.3 → 1.17.4

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 (51) hide show
  1. package/dist/agents/tools/logs-read-tool.js +9 -0
  2. package/dist/agents/tools/memory-tool.js +1 -0
  3. package/dist/agents/workspace-migrations.js +61 -0
  4. package/dist/auto-reply/group-activation.js +2 -0
  5. package/dist/auto-reply/reply/commands-session.js +28 -11
  6. package/dist/build-info.json +3 -3
  7. package/dist/config/agent-tools-reconcile.js +58 -0
  8. package/dist/config/group-policy.js +16 -0
  9. package/dist/config/zod-schema.providers-whatsapp.js +2 -0
  10. package/dist/control-ui/assets/index-XqRo9tNW.css +1 -0
  11. package/dist/control-ui/assets/{index-Bd75cI7J.js → index-koe4eKhk.js} +526 -493
  12. package/dist/control-ui/assets/index-koe4eKhk.js.map +1 -0
  13. package/dist/control-ui/index.html +2 -2
  14. package/dist/cron/preloaded.js +27 -23
  15. package/dist/cron/service/timer.js +5 -1
  16. package/dist/gateway/protocol/index.js +7 -2
  17. package/dist/gateway/protocol/schema/logs-chat.js +6 -0
  18. package/dist/gateway/protocol/schema/protocol-schemas.js +6 -0
  19. package/dist/gateway/protocol/schema/sessions-transcript.js +1 -0
  20. package/dist/gateway/protocol/schema/sessions.js +6 -1
  21. package/dist/gateway/protocol/schema/whatsapp.js +24 -0
  22. package/dist/gateway/protocol/schema.js +1 -0
  23. package/dist/gateway/public-chat/session-token.js +52 -0
  24. package/dist/gateway/public-chat-api.js +40 -13
  25. package/dist/gateway/server-methods/apikeys.js +2 -0
  26. package/dist/gateway/server-methods/logs.js +17 -1
  27. package/dist/gateway/server-methods/public-chat.js +5 -0
  28. package/dist/gateway/server-methods/sessions-transcript.js +30 -6
  29. package/dist/gateway/server-methods/whatsapp-conversations.js +387 -0
  30. package/dist/gateway/server-methods-list.js +6 -0
  31. package/dist/gateway/server-methods.js +7 -0
  32. package/dist/gateway/server.impl.js +19 -2
  33. package/dist/gateway/sessions-patch.js +1 -1
  34. package/dist/hooks/bundled/ride-dispatch/HOOK.md +7 -6
  35. package/dist/hooks/bundled/ride-dispatch/handler.js +98 -39
  36. package/dist/memory/manager.js +3 -3
  37. package/dist/tui/tui-command-handlers.js +1 -1
  38. package/dist/web/auto-reply/monitor/group-activation.js +12 -10
  39. package/dist/web/auto-reply/monitor/group-gating.js +23 -2
  40. package/dist/web/auto-reply/monitor/on-message.js +27 -5
  41. package/dist/web/auto-reply/monitor/process-message.js +64 -53
  42. package/dist/web/inbound/monitor.js +30 -0
  43. package/extensions/whatsapp/src/channel.ts +1 -1
  44. package/package.json +1 -1
  45. package/skills/log-review/SKILL.md +17 -4
  46. package/skills/log-review/references/review-protocol.md +4 -4
  47. package/taskmaster-docs/USER-GUIDE.md +14 -0
  48. package/templates/beagle-zanzibar/agents/admin/AGENTS.md +16 -8
  49. package/templates/beagle-zanzibar/agents/public/AGENTS.md +10 -5
  50. package/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
  51. package/dist/control-ui/assets/index-BkymP95Y.css +0 -1
@@ -0,0 +1,387 @@
1
+ import { loadConfig, writeConfigFile } from "../../config/config.js";
2
+ import { resolveChannelGroupActivation } from "../../config/group-policy.js";
3
+ import { updateSessionStore } from "../../config/sessions.js";
4
+ import { listRecords } from "../../records/records-manager.js";
5
+ import { normalizeAccountId } from "../../routing/session-key.js";
6
+ import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
7
+ import { requireActiveWebListener } from "../../web/active-listener.js";
8
+ import { stripEnvelope } from "../chat-sanitize.js";
9
+ import { ErrorCodes, errorShape, formatValidationErrors, validateWhatsAppConversationsParams, validateWhatsAppGroupInfoParams, validateWhatsAppMessagesParams, validateWhatsAppSendMessageParams, validateWhatsAppSetActivationParams, } from "../protocol/index.js";
10
+ import { loadCombinedSessionStoreForGateway, parseGroupKey } from "../session-utils.js";
11
+ import { readSessionMessages } from "../session-utils.fs.js";
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers for building conversation + message lists from persistent state
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * Extract a WhatsApp JID from a DM session key rest segment.
17
+ * Handles both `whatsapp:dm:{peer}` and `dm:{peer}` formats.
18
+ * Returns `null` if the key doesn't match a WhatsApp DM pattern.
19
+ */
20
+ function extractDmJid(rest, entryChannel, entryLastChannel) {
21
+ const channelDmMatch = rest.match(/^whatsapp:dm:(.+)$/);
22
+ if (channelDmMatch) {
23
+ const peer = channelDmMatch[1];
24
+ return peer.includes("@") ? peer : `${peer.replace(/^\+/, "")}@s.whatsapp.net`;
25
+ }
26
+ const plainDmMatch = rest.match(/^dm:(.+)$/);
27
+ if (plainDmMatch) {
28
+ const isWhatsApp = entryChannel === "whatsapp" || entryLastChannel === "whatsapp";
29
+ if (!isWhatsApp)
30
+ return null;
31
+ const peer = plainDmMatch[1];
32
+ return peer.includes("@") ? peer : `${peer.replace(/^\+/, "")}@s.whatsapp.net`;
33
+ }
34
+ return null;
35
+ }
36
+ function extractTextFromTranscriptMessage(msg) {
37
+ if (typeof msg.content === "string")
38
+ return msg.content.trim() || null;
39
+ if (Array.isArray(msg.content)) {
40
+ for (const part of msg.content) {
41
+ if (part && typeof part.text === "string" && part.text.trim()) {
42
+ return part.text.trim();
43
+ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Extract sender info from the `[from: Name (phone)]` tag in group user messages.
50
+ */
51
+ const FROM_TAG_RE = /\n?\[from:\s*(.+?)\]\s*$/;
52
+ function extractSenderFromBody(body) {
53
+ const match = body.match(FROM_TAG_RE);
54
+ if (!match)
55
+ return { cleanBody: body, sender: "" };
56
+ const cleanBody = body.slice(0, match.index).trimEnd();
57
+ const senderRaw = match[1];
58
+ // Format: "Name (+447857934268)" or just "+447857934268"
59
+ const namePhoneMatch = senderRaw.match(/^(.+?)\s*\(([^)]+)\)$/);
60
+ if (namePhoneMatch) {
61
+ return {
62
+ cleanBody,
63
+ sender: namePhoneMatch[2],
64
+ senderName: namePhoneMatch[1],
65
+ };
66
+ }
67
+ return { cleanBody, sender: senderRaw };
68
+ }
69
+ /**
70
+ * Build a digits-to-contact-name lookup from the contact records store.
71
+ */
72
+ function buildContactNameLookup() {
73
+ const lookup = new Map();
74
+ try {
75
+ const records = listRecords();
76
+ for (const record of records) {
77
+ if (!record.name)
78
+ continue;
79
+ const phone = record.phone ?? record.id;
80
+ const digits = phone.replace(/\D/g, "");
81
+ if (digits)
82
+ lookup.set(digits, record.name);
83
+ }
84
+ }
85
+ catch {
86
+ // Records file missing or unreadable — no enrichment
87
+ }
88
+ return lookup;
89
+ }
90
+ /**
91
+ * Find the session entry matching a WhatsApp JID in the session store.
92
+ */
93
+ function findSessionForJid(store, jid, accountId) {
94
+ for (const [key, entry] of Object.entries(store)) {
95
+ if (!entry.sessionId)
96
+ continue;
97
+ if (accountId !== "default" &&
98
+ entry.lastAccountId &&
99
+ normalizeAccountId(entry.lastAccountId) !== accountId) {
100
+ continue;
101
+ }
102
+ const parsed = parseGroupKey(key);
103
+ if (parsed?.channel === "whatsapp" && parsed.id === jid) {
104
+ return { key, entry };
105
+ }
106
+ const agentParsed = parseAgentSessionKey(key);
107
+ if (agentParsed) {
108
+ const dmJid = extractDmJid(agentParsed.rest, entry.channel, entry.lastChannel);
109
+ if (dmJid === jid)
110
+ return { key, entry };
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Handlers
117
+ // ---------------------------------------------------------------------------
118
+ export const whatsappConversationsHandlers = {
119
+ "whatsapp.conversations": async ({ respond, params }) => {
120
+ if (!validateWhatsAppConversationsParams(params)) {
121
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.conversations params: ${formatValidationErrors(validateWhatsAppConversationsParams.errors)}`));
122
+ return;
123
+ }
124
+ const accountId = normalizeAccountId(params.accountId);
125
+ try {
126
+ const cfg = loadConfig();
127
+ // 1. Build conversations from persistent session store
128
+ const { store } = loadCombinedSessionStoreForGateway(cfg);
129
+ const conversationMap = new Map();
130
+ for (const [key, entry] of Object.entries(store)) {
131
+ // Account filter
132
+ if (accountId !== "default" &&
133
+ entry.lastAccountId &&
134
+ normalizeAccountId(entry.lastAccountId) !== accountId) {
135
+ continue;
136
+ }
137
+ // Groups: key contains :whatsapp:group:{jid}
138
+ const parsed = parseGroupKey(key);
139
+ if (parsed?.channel === "whatsapp" &&
140
+ (parsed.kind === "group" || parsed.kind === "channel") &&
141
+ parsed.id) {
142
+ conversationMap.set(parsed.id, {
143
+ jid: parsed.id,
144
+ type: "group",
145
+ name: entry.subject ?? entry.displayName ?? parsed.id,
146
+ lastMessageTimestamp: entry.updatedAt ? Math.floor(entry.updatedAt / 1000) : undefined,
147
+ });
148
+ continue;
149
+ }
150
+ // DMs: extract JID from key + entry channel metadata
151
+ const agentParsed = parseAgentSessionKey(key);
152
+ if (!agentParsed)
153
+ continue;
154
+ const dmJid = extractDmJid(agentParsed.rest, entry.channel, entry.lastChannel);
155
+ if (dmJid && !conversationMap.has(dmJid)) {
156
+ // Derive display name from entry or peer identifier
157
+ const peer = agentParsed.rest.replace(/^(?:whatsapp:)?dm:/, "");
158
+ conversationMap.set(dmJid, {
159
+ jid: dmJid,
160
+ type: "dm",
161
+ name: entry.displayName ?? peer,
162
+ lastMessageTimestamp: entry.updatedAt ? Math.floor(entry.updatedAt / 1000) : undefined,
163
+ });
164
+ }
165
+ }
166
+ // 2. Merge with live Baileys data (optional enrichment — may not be connected)
167
+ try {
168
+ const { listener } = requireActiveWebListener(accountId);
169
+ if (listener.listConversations) {
170
+ const live = await listener.listConversations();
171
+ for (const c of live) {
172
+ const existing = conversationMap.get(c.jid);
173
+ if (existing) {
174
+ // Prefer live name when available and meaningful
175
+ if (c.name && c.name !== c.jid)
176
+ existing.name = c.name;
177
+ if (c.lastMessageTimestamp &&
178
+ (!existing.lastMessageTimestamp ||
179
+ c.lastMessageTimestamp > existing.lastMessageTimestamp)) {
180
+ existing.lastMessageTimestamp = c.lastMessageTimestamp;
181
+ }
182
+ }
183
+ else {
184
+ conversationMap.set(c.jid, c);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ catch {
190
+ // Baileys not connected — session store data is sufficient
191
+ }
192
+ // 3. Enrich DM names from contact records
193
+ const contactNames = buildContactNameLookup();
194
+ for (const conv of conversationMap.values()) {
195
+ if (conv.type !== "dm")
196
+ continue;
197
+ // JID format: digits@s.whatsapp.net
198
+ const digits = conv.jid.replace(/@.*$/, "");
199
+ const contactName = contactNames.get(digits);
200
+ if (contactName)
201
+ conv.name = contactName;
202
+ }
203
+ // 4. Enrich with activation and sort by recency
204
+ const conversations = [...conversationMap.values()]
205
+ .map((c) => ({
206
+ ...c,
207
+ activation: c.type === "group"
208
+ ? (resolveChannelGroupActivation({
209
+ cfg,
210
+ channel: "whatsapp",
211
+ groupId: c.jid,
212
+ accountId,
213
+ }) ?? "mention")
214
+ : undefined,
215
+ }))
216
+ .sort((a, b) => (b.lastMessageTimestamp ?? 0) - (a.lastMessageTimestamp ?? 0));
217
+ respond(true, { conversations });
218
+ }
219
+ catch (err) {
220
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
221
+ }
222
+ },
223
+ "whatsapp.messages": async ({ respond, params }) => {
224
+ if (!validateWhatsAppMessagesParams(params)) {
225
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.messages params: ${formatValidationErrors(validateWhatsAppMessagesParams.errors)}`));
226
+ return;
227
+ }
228
+ const accountId = normalizeAccountId(params.accountId);
229
+ const jid = params.jid;
230
+ const limit = params.limit ?? 50;
231
+ try {
232
+ const cfg = loadConfig();
233
+ const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
234
+ // Find the session matching this JID
235
+ const matched = findSessionForJid(store, jid, accountId);
236
+ const matchedSessionId = matched?.entry.sessionId;
237
+ const matchedSessionFile = matched?.entry.sessionFile;
238
+ if (!matchedSessionId) {
239
+ respond(true, { messages: [] });
240
+ return;
241
+ }
242
+ // Read transcript and transform to WhatsApp message format
243
+ const rawMessages = readSessionMessages(matchedSessionId, storePath, matchedSessionFile);
244
+ const messages = [];
245
+ for (const raw of rawMessages) {
246
+ const msg = raw;
247
+ if (!msg.role || (msg.role !== "user" && msg.role !== "assistant"))
248
+ continue;
249
+ let text = extractTextFromTranscriptMessage(msg);
250
+ if (!text)
251
+ continue;
252
+ const fromMe = msg.role === "assistant";
253
+ let sender = "";
254
+ let senderName;
255
+ if (msg.role === "user") {
256
+ // Strip envelope header (e.g. "[WhatsApp 2026-03-05 10:30]")
257
+ text = stripEnvelope(text);
258
+ // Extract sender from [from: ...] tag in group messages
259
+ const extracted = extractSenderFromBody(text);
260
+ text = extracted.cleanBody;
261
+ sender = extracted.sender;
262
+ senderName = extracted.senderName;
263
+ }
264
+ if (!text.trim())
265
+ continue;
266
+ messages.push({
267
+ id: `${matchedSessionId}-${messages.length}`,
268
+ sender,
269
+ senderName,
270
+ body: text,
271
+ timestamp: msg.timestamp ? Math.floor(msg.timestamp / 1000) : 0,
272
+ fromMe,
273
+ });
274
+ }
275
+ // Return the most recent N messages
276
+ const limited = messages.slice(-limit);
277
+ respond(true, { messages: limited });
278
+ }
279
+ catch (err) {
280
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
281
+ }
282
+ },
283
+ "whatsapp.groupInfo": async ({ respond, params }) => {
284
+ if (!validateWhatsAppGroupInfoParams(params)) {
285
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.groupInfo params: ${formatValidationErrors(validateWhatsAppGroupInfoParams.errors)}`));
286
+ return;
287
+ }
288
+ const accountId = normalizeAccountId(params.accountId);
289
+ const jid = params.jid;
290
+ const cfg = loadConfig();
291
+ const activation = resolveChannelGroupActivation({ cfg, channel: "whatsapp", groupId: jid, accountId }) ??
292
+ "mention";
293
+ // Try live Baileys metadata first (has participants)
294
+ try {
295
+ const { listener } = requireActiveWebListener(accountId);
296
+ if (listener.getGroupMetadata) {
297
+ const meta = await listener.getGroupMetadata(jid);
298
+ // Update stored subject if it changed
299
+ const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
300
+ const matched = findSessionForJid(store, jid, accountId);
301
+ if (matched && meta.subject && matched.entry.subject !== meta.subject) {
302
+ updateSessionStore(storePath, (current) => {
303
+ const entry = current[matched.key];
304
+ if (entry)
305
+ entry.subject = meta.subject;
306
+ }).catch(() => { });
307
+ }
308
+ respond(true, { ...meta, activation });
309
+ return;
310
+ }
311
+ }
312
+ catch {
313
+ // Baileys unavailable — fall through to session store
314
+ }
315
+ // Fallback: session store data (no participants, but at least subject + activation)
316
+ try {
317
+ const { store } = loadCombinedSessionStoreForGateway(cfg);
318
+ const matched = findSessionForJid(store, jid, accountId);
319
+ respond(true, {
320
+ subject: matched?.entry.subject ?? matched?.entry.displayName ?? jid,
321
+ participants: [],
322
+ activation,
323
+ });
324
+ }
325
+ catch (err) {
326
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
327
+ }
328
+ },
329
+ "whatsapp.setActivation": async ({ respond, params }) => {
330
+ if (!validateWhatsAppSetActivationParams(params)) {
331
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.setActivation params: ${formatValidationErrors(validateWhatsAppSetActivationParams.errors)}`));
332
+ return;
333
+ }
334
+ const accountId = normalizeAccountId(params.accountId);
335
+ try {
336
+ const cfg = loadConfig();
337
+ if (!cfg.channels)
338
+ cfg.channels = {};
339
+ if (!cfg.channels.whatsapp)
340
+ cfg.channels.whatsapp = {};
341
+ const wa = cfg.channels.whatsapp;
342
+ if (accountId !== "default") {
343
+ if (!wa.accounts)
344
+ wa.accounts = {};
345
+ const accounts = wa.accounts;
346
+ if (!accounts[accountId])
347
+ accounts[accountId] = {};
348
+ if (!accounts[accountId].groups)
349
+ accounts[accountId].groups = {};
350
+ const groups = accounts[accountId].groups;
351
+ groups[params.jid] = {
352
+ ...groups[params.jid],
353
+ activation: params.activation,
354
+ };
355
+ }
356
+ else {
357
+ if (!wa.groups)
358
+ wa.groups = {};
359
+ const groups = wa.groups;
360
+ groups[params.jid] = {
361
+ ...groups[params.jid],
362
+ activation: params.activation,
363
+ };
364
+ }
365
+ await writeConfigFile(cfg);
366
+ respond(true, { ok: true });
367
+ }
368
+ catch (err) {
369
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
370
+ }
371
+ },
372
+ "whatsapp.sendMessage": async ({ respond, params }) => {
373
+ if (!validateWhatsAppSendMessageParams(params)) {
374
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.sendMessage params: ${formatValidationErrors(validateWhatsAppSendMessageParams.errors)}`));
375
+ return;
376
+ }
377
+ const accountId = normalizeAccountId(params.accountId);
378
+ try {
379
+ const { listener } = requireActiveWebListener(accountId);
380
+ const result = await listener.sendMessage(params.jid, params.body);
381
+ respond(true, { id: result.messageId, timestamp: Date.now() });
382
+ }
383
+ catch (err) {
384
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
385
+ }
386
+ },
387
+ };
@@ -95,6 +95,12 @@ const BASE_METHODS = [
95
95
  "chat.history",
96
96
  "chat.abort",
97
97
  "chat.send",
98
+ // WhatsApp conversation browser
99
+ "whatsapp.conversations",
100
+ "whatsapp.messages",
101
+ "whatsapp.groupInfo",
102
+ "whatsapp.setActivation",
103
+ "whatsapp.sendMessage",
98
104
  ];
99
105
  export function listGatewayMethods() {
100
106
  const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
@@ -40,6 +40,7 @@ import { wifiHandlers } from "./server-methods/wifi.js";
40
40
  import { workspacesHandlers } from "./server-methods/workspaces.js";
41
41
  import { brandHandlers } from "./server-methods/brand.js";
42
42
  import { businessHandlers } from "./server-methods/business.js";
43
+ import { whatsappConversationsHandlers } from "./server-methods/whatsapp-conversations.js";
43
44
  const ADMIN_SCOPE = "operator.admin";
44
45
  const READ_SCOPE = "operator.read";
45
46
  const WRITE_SCOPE = "operator.write";
@@ -109,6 +110,9 @@ const READ_METHODS = new Set([
109
110
  "memory.audit",
110
111
  "qr.generate",
111
112
  "business.openingHours.get",
113
+ "whatsapp.conversations",
114
+ "whatsapp.messages",
115
+ "whatsapp.groupInfo",
112
116
  ]);
113
117
  const WRITE_METHODS = new Set([
114
118
  "send",
@@ -124,6 +128,8 @@ const WRITE_METHODS = new Set([
124
128
  "node.invoke",
125
129
  "chat.send",
126
130
  "chat.abort",
131
+ "whatsapp.setActivation",
132
+ "whatsapp.sendMessage",
127
133
  ]);
128
134
  function authorizeGatewayMethod(method, client) {
129
135
  // Access methods bypass all scope checks — needed before PIN login
@@ -250,6 +256,7 @@ export const coreGatewayHandlers = {
250
256
  ...networkHandlers,
251
257
  ...tailscaleHandlers,
252
258
  ...wifiHandlers,
259
+ ...whatsappConversationsHandlers,
253
260
  };
254
261
  export async function handleGatewayRequest(opts) {
255
262
  const { req, respond, client, isWebchatConnect, context } = opts;
@@ -10,7 +10,7 @@ import { CONFIG_PATH_TASKMASTER, isNixMode, loadConfig, migrateLegacyConfig, rea
10
10
  import { VERSION } from "../version.js";
11
11
  import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
12
12
  import { logAcceptedEnvOption } from "../infra/env.js";
13
- import { reconcileAgentContactTools, reconcileBeaglePublicTools, reconcileQrGenerateTool, reconcileStaleToolEntries, } from "../config/agent-tools-reconcile.js";
13
+ import { reconcileAgentContactTools, reconcileBeaglePublicTools, reconcileControlPanelTools, reconcileQrGenerateTool, reconcileStaleToolEntries, } from "../config/agent-tools-reconcile.js";
14
14
  import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
15
15
  import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
16
16
  import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
@@ -198,6 +198,21 @@ export async function startGatewayServer(port = 18789, opts = {}) {
198
198
  log.warn(`gateway: failed to persist qr_generate tool reconciliation: ${String(err)}`);
199
199
  }
200
200
  }
201
+ // Upgrade admin agents from individual control-panel tools to group:control-panel.
202
+ // Agents set up before the group existed miss tools added to it later (e.g. logs_read).
203
+ const cpReconcile = reconcileControlPanelTools({ config: configSnapshot.config });
204
+ if (cpReconcile.changes.length > 0) {
205
+ try {
206
+ await writeConfigFile(cpReconcile.config);
207
+ configSnapshot = await readConfigFileSnapshot();
208
+ log.info(`gateway: reconciled control-panel tools:\n${cpReconcile.changes
209
+ .map((entry) => `- ${entry}`)
210
+ .join("\n")}`);
211
+ }
212
+ catch (err) {
213
+ log.warn(`gateway: failed to persist control-panel tools reconciliation: ${String(err)}`);
214
+ }
215
+ }
201
216
  // Stamp config with running version on startup so upgrades keep the stamp current.
202
217
  const storedVersion = configSnapshot.config.meta?.lastTouchedVersion;
203
218
  if (configSnapshot.exists && storedVersion !== VERSION) {
@@ -475,11 +490,13 @@ export async function startGatewayServer(port = 18789, opts = {}) {
475
490
  if (!bundledSkillsDir)
476
491
  return;
477
492
  try {
493
+ const workspaceIds = Object.keys(cfgAtStart.workspaces ?? {});
494
+ const accountIds = workspaceIds.length > 0 ? workspaceIds : [DEFAULT_ACCOUNT_ID];
478
495
  const seeded = await seedPreloadedCronJobs({
479
496
  bundledSkillsDir,
480
497
  trackerPath: DEFAULT_SEED_TRACKER_PATH,
481
498
  cronService: cron,
482
- defaultAccountId: DEFAULT_ACCOUNT_ID,
499
+ accountIds,
483
500
  });
484
501
  if (seeded > 0) {
485
502
  logCron.info(`cron: seeded ${seeded} preloaded job(s)`);
@@ -274,7 +274,7 @@ export async function applySessionsPatchToStore(params) {
274
274
  else if (raw !== undefined) {
275
275
  const normalized = normalizeGroupActivation(String(raw));
276
276
  if (!normalized) {
277
- return invalid('invalid groupActivation (use "mention"|"always")');
277
+ return invalid('invalid groupActivation (use "mention"|"always"|"off")');
278
278
  }
279
279
  next.groupActivation = normalized;
280
280
  }
@@ -7,7 +7,7 @@ metadata:
7
7
  "taskmaster":
8
8
  {
9
9
  "emoji": "🚕",
10
- "events": ["memory:add", "message:inbound"],
10
+ "events": ["memory:add", "message:before-dispatch"],
11
11
  "requires": { "config": ["workspace.dir"] },
12
12
  "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Taskmaster" }],
13
13
  },
@@ -28,13 +28,13 @@ When the public agent writes a file matching `shared/dispatch/{jobId}-{phase}.md
28
28
  2. **Dispatches to admin agent** in a session scoped to the booking (`ride-{jobId}`)
29
29
  3. **Admin agent autonomously processes** — contacts drivers, generates payment links, or finalises bookings
30
30
 
31
- ### Driver Replies (message:inbound)
31
+ ### Driver Replies (message:before-dispatch)
32
32
 
33
- When a driver replies on WhatsApp (routed to the public agent's DM session):
33
+ Before a WhatsApp DM is dispatched to the public agent's LLM:
34
34
 
35
35
  1. **Checks the active negotiation index** at `shared/active-negotiations/{phone}.md`
36
- 2. **If the driver has an active negotiation**, dispatches their message to the admin's ride session for that job
37
- 3. **If no active negotiation**, ignores the message (normal public agent handling)
36
+ 2. **If the driver has an active negotiation**, sets `event.suppress = true` and dispatches the reply to the admin's ride session the public agent never sees the message
37
+ 3. **If no active negotiation**, does nothing — the message proceeds normally to the public agent
38
38
 
39
39
  ## Why This Exists
40
40
 
@@ -44,7 +44,8 @@ The public agent must not have `contact_lookup` or `message` tools — exposing
44
44
 
45
45
  - Only fires for **Beagle Zanzibar agents** (detected by `beagle` in agent ID)
46
46
  - **Deduplicates** — same dispatch file within 30 seconds is ignored
47
- - **Non-blocking** — dispatch is fire-and-forget so neither agent's reply is delayed
47
+ - **Driver suppression** — driver replies set `event.suppress = true` so the public agent's LLM is never invoked for driver messages
48
+ - **Non-blocking** — admin dispatch is fire-and-forget after suppression is set
48
49
  - Admin agent uses `contact_lookup` → `message` → `memory_write` for driver outreach
49
50
  - Admin agent uses `message` to inject results (offers, payment links, driver details) into tourist's conversation via cross-agent echo
50
51