@rubytech/create-realagent-code 0.1.575 → 0.1.576

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.
@@ -4259,9 +4259,9 @@ var require_dist5 = __commonJS({
4259
4259
  try {
4260
4260
  await onEmpty(c);
4261
4261
  } catch (err) {
4262
- const id82 = c.elementId.slice(0, 8);
4262
+ const id83 = c.elementId.slice(0, 8);
4263
4263
  const message = err instanceof Error ? err.message : String(err);
4264
- process.stderr.write(`[trash:empty-run] reason=onEmpty-failed elementId=${id82} labels=${c.labels.join(",")} message=${message}
4264
+ process.stderr.write(`[trash:empty-run] reason=onEmpty-failed elementId=${id83} labels=${c.labels.join(",")} message=${message}
4265
4265
  `);
4266
4266
  continue;
4267
4267
  }
@@ -21386,7 +21386,7 @@ init_neo4j_store();
21386
21386
  async function purgeAdminConversationJsonls(args) {
21387
21387
  const fetchImpl = args.fetchImpl ?? fetch;
21388
21388
  const log2 = args.logger ?? ((line) => console.log(line));
21389
- const id82 = args.sessionId.slice(0, 8);
21389
+ const id83 = args.sessionId.slice(0, 8);
21390
21390
  const purged = [];
21391
21391
  for (const sessionId of args.claudeSessionIds) {
21392
21392
  const url = `${args.managerBase}/${encodeURIComponent(sessionId)}`;
@@ -21396,7 +21396,7 @@ async function purgeAdminConversationJsonls(args) {
21396
21396
  } catch (err) {
21397
21397
  const message2 = err instanceof Error ? err.message : String(err);
21398
21398
  log2(
21399
- `[admin-conversation-delete] sessionId=${id82} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message2}`
21399
+ `[admin-conversation-delete] sessionId=${id83} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message2}`
21400
21400
  );
21401
21401
  return {
21402
21402
  ok: false,
@@ -21413,7 +21413,7 @@ async function purgeAdminConversationJsonls(args) {
21413
21413
  }
21414
21414
  if (res.status === 409) {
21415
21415
  log2(
21416
- `[admin-conversation-delete] sessionId=${id82} reason=pty-still-alive claudeSessionId=${sessionId.slice(0, 8)}`
21416
+ `[admin-conversation-delete] sessionId=${id83} reason=pty-still-alive claudeSessionId=${sessionId.slice(0, 8)}`
21417
21417
  );
21418
21418
  return {
21419
21419
  ok: false,
@@ -21430,7 +21430,7 @@ async function purgeAdminConversationJsonls(args) {
21430
21430
  } catch {
21431
21431
  }
21432
21432
  log2(
21433
- `[admin-conversation-delete] sessionId=${id82} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message}`
21433
+ `[admin-conversation-delete] sessionId=${id83} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message}`
21434
21434
  );
21435
21435
  return {
21436
21436
  ok: false,
@@ -21449,13 +21449,13 @@ async function cascadeAdminConversationDelete(args) {
21449
21449
  const started2 = Date.now();
21450
21450
  const fetchImpl = args.fetchImpl ?? fetch;
21451
21451
  const log2 = args.logger ?? ((line) => console.log(line));
21452
- const id82 = args.sessionId.slice(0, 8);
21452
+ const id83 = args.sessionId.slice(0, 8);
21453
21453
  let claudeSessionIds;
21454
21454
  try {
21455
21455
  claudeSessionIds = args.claudeSessionIds ?? await getConversationClaudeSessionIds(args.sessionId, args.accountId);
21456
21456
  } catch (err) {
21457
21457
  const message = err instanceof Error ? err.message : String(err);
21458
- log2(`[admin-conversation-delete] sessionId=${id82} reason=graph-delete-failed claudeSessionIds-purged= message=transcript-resolve:${message}`);
21458
+ log2(`[admin-conversation-delete] sessionId=${id83} reason=graph-delete-failed claudeSessionIds-purged= message=transcript-resolve:${message}`);
21459
21459
  return { ok: false, reason: "graph-delete-failed", claudeSessionIdsPurged: [], message };
21460
21460
  }
21461
21461
  const purgeOutcome = await purgeAdminConversationJsonls({
@@ -21486,7 +21486,7 @@ async function cascadeAdminConversationDelete(args) {
21486
21486
  } catch (err) {
21487
21487
  const message = err instanceof Error ? err.message : String(err);
21488
21488
  const csv3 = purged.map((s) => s.slice(0, 8)).join(",");
21489
- log2(`[admin-conversation-delete] sessionId=${id82} reason=graph-delete-failed claudeSessionIds-purged=${csv3} message=${message}`);
21489
+ log2(`[admin-conversation-delete] sessionId=${id83} reason=graph-delete-failed claudeSessionIds-purged=${csv3} message=${message}`);
21490
21490
  return {
21491
21491
  ok: false,
21492
21492
  reason: "graph-delete-failed",
@@ -21499,7 +21499,7 @@ async function cascadeAdminConversationDelete(args) {
21499
21499
  }
21500
21500
  const ms = Date.now() - started2;
21501
21501
  const csv2 = purged.map((s) => s.slice(0, 8)).join(",");
21502
- log2(`[admin-conversation-delete] sessionId=${id82} claudeSessionIds=${csv2} ms=${ms}`);
21502
+ log2(`[admin-conversation-delete] sessionId=${id83} claudeSessionIds=${csv2} ms=${ms}`);
21503
21503
  return { ok: true, claudeSessionIds: purged, ms };
21504
21504
  }
21505
21505
 
@@ -29716,21 +29716,21 @@ app52.get("/booking-link", requireAdminSession, (c) => {
29716
29716
  console.error("[calendar] op=booking-link auth-rejected reason=no-account-for-session accountId=none");
29717
29717
  return c.json({ error: "no account for session", reason: "no-account-for-session" }, 401);
29718
29718
  }
29719
- const id82 = account.accountId.slice(0, 8);
29719
+ const id83 = account.accountId.slice(0, 8);
29720
29720
  try {
29721
29721
  if (!existsSync41(join50(account.accountDir, AVAILABILITY_FILENAME))) {
29722
- console.log(`[calendar] op=booking-link accountId=${id82} bookingDomain=none source=availability-config`);
29722
+ console.log(`[calendar] op=booking-link accountId=${id83} bookingDomain=none source=availability-config`);
29723
29723
  return c.json({ bookingDomain: null });
29724
29724
  }
29725
29725
  const config = readAvailabilityConfig(account.accountDir);
29726
29726
  const bookingDomain = typeof config.bookingDomain === "string" && config.bookingDomain.trim() ? config.bookingDomain.trim() : null;
29727
29727
  console.log(
29728
- `[calendar] op=booking-link accountId=${id82} bookingDomain=${bookingDomain ?? "none"} source=availability-config`
29728
+ `[calendar] op=booking-link accountId=${id83} bookingDomain=${bookingDomain ?? "none"} source=availability-config`
29729
29729
  );
29730
29730
  return c.json({ bookingDomain });
29731
29731
  } catch (err) {
29732
29732
  const message = err instanceof Error ? err.message : String(err);
29733
- console.error(`[calendar] op=booking-link-fail accountId=${id82} err="${message}"`);
29733
+ console.error(`[calendar] op=booking-link-fail accountId=${id83} err="${message}"`);
29734
29734
  return c.json({ bookingDomain: null });
29735
29735
  }
29736
29736
  });
@@ -29750,20 +29750,20 @@ app53.get("/portal-link", requireAdminSession, (c) => {
29750
29750
  console.error("[data-portal] op=portal-link auth-rejected reason=no-account-for-session accountId=none");
29751
29751
  return c.json({ error: "no account for session", reason: "no-account-for-session" }, 401);
29752
29752
  }
29753
- const id82 = account.accountId.slice(0, 8);
29753
+ const id83 = account.accountId.slice(0, 8);
29754
29754
  try {
29755
29755
  const path = join51(account.accountDir, PORTAL_FILENAME);
29756
29756
  if (!existsSync42(path)) {
29757
- console.log(`[data-portal] op=portal-link accountId=${id82} portalDomain=none source=data-portal-config`);
29757
+ console.log(`[data-portal] op=portal-link accountId=${id83} portalDomain=none source=data-portal-config`);
29758
29758
  return c.json({ portalDomain: null });
29759
29759
  }
29760
29760
  const cfg = JSON.parse(readFileSync42(path, "utf-8"));
29761
29761
  const portalDomain = typeof cfg.portalDomain === "string" && cfg.portalDomain.trim() ? cfg.portalDomain.trim() : null;
29762
- console.log(`[data-portal] op=portal-link accountId=${id82} portalDomain=${portalDomain ?? "none"} source=data-portal-config`);
29762
+ console.log(`[data-portal] op=portal-link accountId=${id83} portalDomain=${portalDomain ?? "none"} source=data-portal-config`);
29763
29763
  return c.json({ portalDomain });
29764
29764
  } catch (err) {
29765
29765
  const message = err instanceof Error ? err.message : String(err);
29766
- console.error(`[data-portal] op=portal-link-fail accountId=${id82} err="${message}"`);
29766
+ console.error(`[data-portal] op=portal-link-fail accountId=${id83} err="${message}"`);
29767
29767
  return c.json({ portalDomain: null });
29768
29768
  }
29769
29769
  });
@@ -29785,13 +29785,13 @@ app54.get("/planner-link", requireAdminSession, (c) => {
29785
29785
  );
29786
29786
  return c.json({ error: "no account for session", reason: "no-account-for-session" }, 401);
29787
29787
  }
29788
- const id82 = account.accountId.slice(0, 8);
29788
+ const id83 = account.accountId.slice(0, 8);
29789
29789
  try {
29790
29790
  const portalPath = join52(account.accountDir, PORTAL_FILENAME2);
29791
29791
  const dispatchConfigured = existsSync43(join52(account.accountDir, "dispatch", "config.json"));
29792
29792
  if (!existsSync43(portalPath) || !dispatchConfigured) {
29793
29793
  console.log(
29794
- `[dispatch-planner] op=planner-link accountId=${id82} plannerUrl=none portal=${existsSync43(portalPath)} dispatch=${dispatchConfigured}`
29794
+ `[dispatch-planner] op=planner-link accountId=${id83} plannerUrl=none portal=${existsSync43(portalPath)} dispatch=${dispatchConfigured}`
29795
29795
  );
29796
29796
  return c.json({ plannerUrl: null });
29797
29797
  }
@@ -29799,12 +29799,12 @@ app54.get("/planner-link", requireAdminSession, (c) => {
29799
29799
  const domain = typeof cfg.portalDomain === "string" && cfg.portalDomain.trim() ? cfg.portalDomain.trim() : null;
29800
29800
  const plannerUrl = domain ? `https://${domain}/planner` : null;
29801
29801
  console.log(
29802
- `[dispatch-planner] op=planner-link accountId=${id82} plannerUrl=${plannerUrl ?? "none"}`
29802
+ `[dispatch-planner] op=planner-link accountId=${id83} plannerUrl=${plannerUrl ?? "none"}`
29803
29803
  );
29804
29804
  return c.json({ plannerUrl });
29805
29805
  } catch (err) {
29806
29806
  const message = err instanceof Error ? err.message : String(err);
29807
- console.error(`[dispatch-planner] op=planner-link-fail accountId=${id82} err="${message}"`);
29807
+ console.error(`[dispatch-planner] op=planner-link-fail accountId=${id83} err="${message}"`);
29808
29808
  return c.json({ plannerUrl: null });
29809
29809
  }
29810
29810
  });
@@ -30385,7 +30385,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30385
30385
  if (!accountId) return c.json({ error: "Account not found for session" }, 401);
30386
30386
  const eventId = c.req.param("eventId");
30387
30387
  const body = await c.req.json().catch(() => ({}));
30388
- const id82 = eventId.slice(0, 8);
30388
+ const id83 = eventId.slice(0, 8);
30389
30389
  const setClauses = [];
30390
30390
  const params = { eventId, accountId };
30391
30391
  const fields = [];
@@ -30397,11 +30397,11 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30397
30397
  const clearing = bindingTouched && body.agentChannel === null && body.agentDestination === null;
30398
30398
  const binding = bindingTouched && typeof body.agentChannel === "string" && body.agentChannel.length > 0 && typeof body.agentDestination === "string" && body.agentDestination.length > 0;
30399
30399
  if (bindingTouched && !clearing && !binding) {
30400
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=partial-binding`);
30400
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=partial-binding`);
30401
30401
  return c.json({ error: "A dispatch binding needs both a channel and a destination, or both cleared." }, 400);
30402
30402
  }
30403
30403
  if (binding && !DISPATCH_CHANNELS2.has(body.agentChannel)) {
30404
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=invalid-channel channel=${body.agentChannel}`);
30404
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=invalid-channel channel=${body.agentChannel}`);
30405
30405
  return c.json({ error: `Unknown dispatch channel "${body.agentChannel}".` }, 400);
30406
30406
  }
30407
30407
  const describing = "description" in body;
@@ -30430,7 +30430,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30430
30430
  storedDescription = pre.records[0]?.get("description") ?? null;
30431
30431
  } catch (err) {
30432
30432
  const message = err instanceof Error ? err.message : String(err);
30433
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=tz-read-fail err="${message}"`);
30433
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=tz-read-fail err="${message}"`);
30434
30434
  return c.json({ error: `Routine unavailable: ${message}` }, 503);
30435
30435
  } finally {
30436
30436
  await preSession.close();
@@ -30438,7 +30438,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30438
30438
  }
30439
30439
  if (binding) {
30440
30440
  if (storedActionPlugin !== null) {
30441
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=action-routine`);
30441
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=action-routine`);
30442
30442
  return c.json(
30443
30443
  { error: "This routine runs a tool. Binding a dispatch would stop the tool running, so it is refused; make a separate routine for the dispatch." },
30444
30444
  400
@@ -30446,7 +30446,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30446
30446
  }
30447
30447
  const effectivePrompt = typeof body.agentPrompt === "string" ? body.agentPrompt : storedPrompt ?? "";
30448
30448
  if (effectivePrompt.trim().length === 0) {
30449
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=no-prompt`);
30449
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=no-prompt`);
30450
30450
  return c.json({ error: "A bound routine needs an instruction, or it will fire and do nothing." }, 400);
30451
30451
  }
30452
30452
  const channel = body.agentChannel;
@@ -30456,12 +30456,12 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30456
30456
  houseAccountId = channel === "whatsapp" ? resolveHouseOrSoleAccountId(ACCOUNTS_DIR) : accountId;
30457
30457
  } catch (err) {
30458
30458
  const message = err instanceof Error ? err.message : String(err);
30459
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=house-unresolved err="${message}"`);
30459
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=house-unresolved err="${message}"`);
30460
30460
  return c.json({ error: `WhatsApp destinations cannot be validated on this install: ${message}` }, 503);
30461
30461
  }
30462
30462
  const check = validateAgentDestination(ACCOUNTS_DIR, houseAccountId, accountId, channel, destination);
30463
30463
  if (!check.valid) {
30464
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=${check.reason}`);
30464
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=${check.reason}`);
30465
30465
  return c.json(
30466
30466
  {
30467
30467
  error: check.reason === "cross-account" ? `That destination belongs to a different account on ${channel}.` : `That destination is not a registered admin or account manager on ${channel}.`
@@ -30478,7 +30478,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30478
30478
  if (clearing) {
30479
30479
  const keepsRow = typeof body.recurrence === "string" || storedRecurrence !== null || storedActionPlugin !== null;
30480
30480
  if (!keepsRow) {
30481
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=clear-would-orphan`);
30481
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=clear-would-orphan`);
30482
30482
  return c.json(
30483
30483
  { error: "Clearing the dispatch on a one-time routine would leave nothing to show. Give it a schedule first, or delete it." },
30484
30484
  400
@@ -30498,12 +30498,12 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30498
30498
  let nextName = null;
30499
30499
  if (naming) {
30500
30500
  if (typeof body.name !== "string" || body.name.trim().length === 0) {
30501
- console.error(`[admin:routines] op=patch id=${id82} fields=name outcome=rejected reason=empty-name`);
30501
+ console.error(`[admin:routines] op=patch id=${id83} fields=name outcome=rejected reason=empty-name`);
30502
30502
  return c.json({ error: "A routine needs a name." }, 400);
30503
30503
  }
30504
30504
  nextName = body.name.trim();
30505
30505
  if (nextName.length > 200) {
30506
- console.error(`[admin:routines] op=patch id=${id82} fields=name outcome=rejected reason=too-long len=${nextName.length}`);
30506
+ console.error(`[admin:routines] op=patch id=${id83} fields=name outcome=rejected reason=too-long len=${nextName.length}`);
30507
30507
  return c.json({ error: "A routine name is at most 200 characters." }, 400);
30508
30508
  }
30509
30509
  setClauses.push("e.name = $name");
@@ -30516,7 +30516,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30516
30516
  if (describing) {
30517
30517
  const raw = body.description;
30518
30518
  if (typeof raw !== "string" && raw !== null) {
30519
- console.error(`[admin:routines] op=patch id=${id82} fields=description outcome=rejected reason=not-a-string`);
30519
+ console.error(`[admin:routines] op=patch id=${id83} fields=description outcome=rejected reason=not-a-string`);
30520
30520
  return c.json({ error: "A description must be text, or null to clear it." }, 400);
30521
30521
  }
30522
30522
  nextDescription = typeof raw === "string" && raw.trim().length > 0 ? raw : null;
@@ -30537,7 +30537,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30537
30537
  params.embedding = embedding;
30538
30538
  } else {
30539
30539
  embedLog = " embed=failed";
30540
- console.error(`[admin:routines] op=patch id=${id82} fields=${fields.join("+")} embed=failed`);
30540
+ console.error(`[admin:routines] op=patch id=${id83} fields=${fields.join("+")} embed=failed`);
30541
30541
  }
30542
30542
  }
30543
30543
  if (typeof body.recurrence === "string") {
@@ -30551,7 +30551,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30551
30551
  nextRunLog = nextRun;
30552
30552
  } catch (err) {
30553
30553
  const reason = err instanceof Error ? err.message : String(err);
30554
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=invalid-cron cron=${body.recurrence}`);
30554
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=invalid-cron cron=${body.recurrence}`);
30555
30555
  return c.json({ error: `Invalid recurrence: ${reason}` }, 400);
30556
30556
  }
30557
30557
  }
@@ -30569,15 +30569,15 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30569
30569
  params
30570
30570
  );
30571
30571
  if (res.records.length === 0) {
30572
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=not-found`);
30572
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=not-found`);
30573
30573
  return c.json({ error: "Routine not found" }, 404);
30574
30574
  }
30575
30575
  const nameLog = nextName === null ? "" : ` name=len:${nextName.length} operatorEdited=stamped`;
30576
- console.log(`[admin:routines] op=patch id=${id82} fields=${fields.join("+")} outcome=ok nextRun=${nextRunLog} cron=${params.recurrence ?? "unchanged"}${bindingLog}${nameLog}${embedLog}`);
30576
+ console.log(`[admin:routines] op=patch id=${id83} fields=${fields.join("+")} outcome=ok nextRun=${nextRunLog} cron=${params.recurrence ?? "unchanged"}${bindingLog}${nameLog}${embedLog}`);
30577
30577
  return c.json({ routine: mapRoutine(res.records[0]) });
30578
30578
  } catch (err) {
30579
30579
  const message = err instanceof Error ? err.message : String(err);
30580
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=${message}`);
30580
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=${message}`);
30581
30581
  return c.json({ error: `Update failed: ${message}` }, 503);
30582
30582
  } finally {
30583
30583
  await session.close();
@@ -34975,8 +34975,18 @@ async function migrateSidecarAccountIds(opts = {}) {
34975
34975
  init_src2();
34976
34976
 
34977
34977
  // app/lib/whatsapp/gateway/inbound-hub.ts
34978
+ var HUB_SEPARATOR = "\0";
34978
34979
  function hubKey(accountId, senderId) {
34979
- return `${accountId}\0${senderId}`;
34980
+ return `${accountId}${HUB_SEPARATOR}${senderId}`;
34981
+ }
34982
+ function decodeHubKeys(keys) {
34983
+ const out = [];
34984
+ for (const key2 of keys) {
34985
+ const i = key2.indexOf(HUB_SEPARATOR);
34986
+ if (i < 0) continue;
34987
+ out.push({ accountId: key2.slice(0, i), senderId: key2.slice(i + HUB_SEPARATOR.length) });
34988
+ }
34989
+ return out;
34980
34990
  }
34981
34991
  var InboundHub = class {
34982
34992
  senders = /* @__PURE__ */ new Map();
@@ -35968,6 +35978,14 @@ var WaGateway = class {
35968
35978
  liveReplyTarget(accountId, senderId) {
35969
35979
  return this.replies.get(hubKey(accountId, normalizeTarget(senderId) ?? senderId)) ?? null;
35970
35980
  }
35981
+ /** Task 2557 — every (account, sender) this gateway currently holds a live
35982
+ * delivery closure for. The follower census reconciles against exactly this
35983
+ * set, so "should have a reader" and "can be sent to" are one fact and
35984
+ * cannot drift. Deriving the set from the follower registry instead would
35985
+ * ask the map that already lost the entry. */
35986
+ boundTargets() {
35987
+ return decodeHubKeys(this.replies.keys());
35988
+ }
35971
35989
  async sendOutbound(accountId, senderId, text) {
35972
35990
  this.outboundSends++;
35973
35991
  const reply = this.replies.get(hubKey(accountId, senderId));
@@ -37188,7 +37206,10 @@ function startEmitter(opts) {
37188
37206
  const abort2 = new AbortController();
37189
37207
  const { entry: entry2, tag } = opts;
37190
37208
  const answers = new AnswerAccumulator();
37209
+ let answersEmitted = 0;
37210
+ let closeReason = "stream-end";
37191
37211
  const emitAnswer = async (answer) => {
37212
+ answersEmitted += 1;
37192
37213
  if (answer.text.trim()) await fanOut2(entry2.subscribers, answer.text, opts.onError, tag);
37193
37214
  if (!opts.onAnswer) return;
37194
37215
  const guarded = { ...answer, text: guardOutbound(entry2.senderId, answer.text) };
@@ -37361,9 +37382,14 @@ function startEmitter(opts) {
37361
37382
  for (const answer of answers.flush()) await emitAnswer(answer);
37362
37383
  } catch (err) {
37363
37384
  if (!abort2.signal.aborted) {
37385
+ closeReason = "error";
37364
37386
  opts.onError?.(`emitter-error: ${err instanceof Error ? err.message : String(err)}`);
37365
37387
  }
37366
37388
  } finally {
37389
+ if (abort2.signal.aborted) closeReason = "aborted";
37390
+ console.error(
37391
+ `${tag} op=follower-closed sessionId=${entry2.sessionId.slice(0, 8)} reason=${closeReason} answers=${answersEmitted}`
37392
+ );
37367
37393
  opts.onClose();
37368
37394
  }
37369
37395
  })();
@@ -37564,6 +37590,12 @@ function startWebchatNativeFileFollower(input) {
37564
37590
  return startEmitter({
37565
37591
  entry: entry2,
37566
37592
  tag: "[webchat-adaptor]",
37593
+ // Task 2557 — the emitter has always had this channel and no follower ever
37594
+ // passed one, so every cause it reported was discarded. The close line
37595
+ // carries the outcome; this carries the cause.
37596
+ onError: (reason) => {
37597
+ console.error(`[webchat-adaptor] op=follower-error sessionId=${input.sessionId.slice(0, 8)} reason=${reason}`);
37598
+ },
37567
37599
  fileDelivery: makeWebchatFileDelivery(entry2),
37568
37600
  // A resumed session's JSONL already holds prior SendUserFile tool_uses;
37569
37601
  // suppress replay so historical calls are not re-reconciled on attach.
@@ -37728,6 +37760,14 @@ function startNativeFileFollower(input) {
37728
37760
  return startEmitter({
37729
37761
  entry: entry2,
37730
37762
  tag: "[whatsapp-adaptor]",
37763
+ // Task 2557 — the emitter has always had this channel and no follower ever
37764
+ // passed one, so every cause it reported (`follow-status-404`,
37765
+ // `emitter-error: …`, `subscriber-error: …`) was discarded. The close line
37766
+ // carries the outcome; this carries the cause, and it can fire without the
37767
+ // stream ending, so neither substitutes for the other.
37768
+ onError: (reason) => {
37769
+ console.error(`[whatsapp-adaptor] op=follower-error sessionId=${input.sessionId.slice(0, 8)} reason=${reason}`);
37770
+ },
37731
37771
  // Task 2521 — admin-only. A public spawn has no tools (Task 2078), so a
37732
37772
  // handler here would reconcile a call that can never fire.
37733
37773
  fileDelivery: input.role === "admin" ? makeWhatsAppFileDelivery(entry2, input.maxyAccountId) : null,
@@ -37752,6 +37792,69 @@ function startNativeFileFollower(input) {
37752
37792
  });
37753
37793
  }
37754
37794
 
37795
+ // app/lib/channel-delivery/follower-registry.ts
37796
+ function createFollowerRegistry(start) {
37797
+ const controllers = /* @__PURE__ */ new Map();
37798
+ const params = /* @__PURE__ */ new Map();
37799
+ return {
37800
+ attach(p) {
37801
+ controllers.get(p.senderId)?.abort();
37802
+ params.set(p.senderId, p);
37803
+ let ac;
37804
+ ac = start(p, () => {
37805
+ if (controllers.get(p.senderId) === ac) controllers.delete(p.senderId);
37806
+ });
37807
+ controllers.set(p.senderId, ac);
37808
+ },
37809
+ live: () => [...controllers.keys()],
37810
+ paramsFor: (senderId) => params.get(senderId) ?? null
37811
+ };
37812
+ }
37813
+
37814
+ // app/lib/channel-delivery/follower-census.ts
37815
+ var CENSUS_MAX_REATTEMPTS = 3;
37816
+ function makeCensusState() {
37817
+ return { attempts: /* @__PURE__ */ new Map() };
37818
+ }
37819
+ var id82 = (s) => s.slice(0, 8);
37820
+ function followerCensusTick(deps, state = makeCensusState()) {
37821
+ const bound = deps.bound();
37822
+ const live = new Set(deps.live());
37823
+ for (const senderId of live) state.attempts.delete(senderId);
37824
+ const missing = bound.filter((b) => !live.has(b.senderId));
37825
+ console.error(
37826
+ `[channel-follower] op=follower-census bound=${bound.length} attached=${live.size} missing=${missing.length} missingSenders=${missing.map((m) => id82(m.senderId)).join(",") || "none"}`
37827
+ );
37828
+ for (const m of missing) {
37829
+ const used = state.attempts.get(m.senderId) ?? 0;
37830
+ if (used >= CENSUS_MAX_REATTEMPTS) {
37831
+ console.error(
37832
+ `[channel-follower] op=follower-reattach sessionId=${id82(deps.paramsFor(m.senderId)?.sessionId ?? "none")} senderId=${id82(m.senderId)} outcome=skipped reason=reattach-exhausted attempts=${used}`
37833
+ );
37834
+ continue;
37835
+ }
37836
+ const p = deps.paramsFor(m.senderId);
37837
+ if (!p) {
37838
+ console.error(
37839
+ `[channel-follower] op=follower-reattach sessionId=none senderId=${id82(m.senderId)} outcome=failed reason=no-attach-params`
37840
+ );
37841
+ continue;
37842
+ }
37843
+ try {
37844
+ state.attempts.set(m.senderId, used + 1);
37845
+ deps.attach(p);
37846
+ console.error(
37847
+ `[channel-follower] op=follower-reattach sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=attached reason=none`
37848
+ );
37849
+ } catch (err) {
37850
+ console.error(
37851
+ `[channel-follower] op=follower-reattach sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=failed reason=${err instanceof Error ? err.message : String(err)}`
37852
+ );
37853
+ }
37854
+ }
37855
+ return `${bound.length} door(s), ${missing.length} without a reader`;
37856
+ }
37857
+
37755
37858
  // app/lib/telegram/gateway/inbound-hub.ts
37756
37859
  var InboundHub3 = class {
37757
37860
  keys = /* @__PURE__ */ new Map();
@@ -38449,6 +38552,12 @@ function startTelegramNativeFileFollower(input) {
38449
38552
  return startEmitter({
38450
38553
  entry: entry2,
38451
38554
  tag: "[telegram-adaptor]",
38555
+ // Task 2557 — the emitter has always had this channel and no follower ever
38556
+ // passed one, so every cause it reported was discarded. The close line
38557
+ // carries the outcome; this carries the cause.
38558
+ onError: (reason) => {
38559
+ console.error(`[telegram-adaptor] op=follower-error sessionId=${input.sessionId.slice(0, 8)} reason=${reason}`);
38560
+ },
38452
38561
  fileDelivery: makeTelegramFileDelivery(entry2, input.botToken),
38453
38562
  // A resumed session's JSONL already holds prior SendUserFile tool_uses;
38454
38563
  // suppress replay so historical files are not re-sent on attach.
@@ -39367,7 +39476,27 @@ function resolveChannelOverride(accountId, channel, senderId) {
39367
39476
  return null;
39368
39477
  }
39369
39478
  }
39370
- var nativeFileFollowers = /* @__PURE__ */ new Map();
39479
+ var nativeFileFollowers = createFollowerRegistry(
39480
+ (p, onClose) => startNativeFileFollower({
39481
+ sessionId: p.sessionId,
39482
+ senderId: p.senderId,
39483
+ // Task 1390 — the file-path validation scope is the sender's effective
39484
+ // session account (the sub-account for an account-manager), not the house
39485
+ // socket account. `accountId` stays the getSocket key.
39486
+ accountId: p.accountId,
39487
+ maxyAccountId: p.effectiveAccountId,
39488
+ // Task 2521 — file delivery is admin-only; the text emitter is not.
39489
+ role: p.role,
39490
+ // Task 2521 — resolved per answer, not captured once, so a door that goes
39491
+ // live after this session started still receives.
39492
+ // Task 2531 — `effectiveAccountId`, NOT `accountId`. The reply map is keyed
39493
+ // on the platform UUID (wa-gateway.ts:567) while `accountId` here is the
39494
+ // Baileys credential dirname. Passing the dirname missed every lookup and
39495
+ // delivered session 8e5bc4e8's answer to nothing.
39496
+ fanTargets: () => whatsappFanTargets(p.effectiveAccountId, p.senderId),
39497
+ onClose
39498
+ })
39499
+ );
39371
39500
  var webchatFileFollowers = /* @__PURE__ */ new Map();
39372
39501
  async function fetchAccountStandingRules(accountId) {
39373
39502
  const session = getSession();
@@ -39598,34 +39727,29 @@ var waGateway = new WaGateway({
39598
39727
  console.error(`[whatsapp-native] spawn-failed senderId=${senderId} role=${role} error=${result.error} status=${result.status}`);
39599
39728
  return;
39600
39729
  }
39601
- const prior = nativeFileFollowers.get(senderId);
39602
- if (prior) prior.abort();
39603
- let ac;
39604
- ac = startNativeFileFollower({
39730
+ nativeFileFollowers.attach({
39605
39731
  sessionId: req.sessionId,
39606
39732
  senderId,
39607
39733
  accountId,
39608
- // Task 1390 — the file-path validation scope is the sender's effective
39609
- // session account (the sub-account for an account-manager), not the house
39610
- // socket account. accountId stays the getSocket key.
39611
- maxyAccountId: effectiveAccountId,
39612
- // Task 2521 — file delivery is admin-only; the text emitter is not.
39613
- role,
39614
- // Task 2521 — resolved per answer, not captured once, so a door that goes
39615
- // live after this session started still receives.
39616
- // Task 2531 — `effectiveAccountId`, NOT `accountId`. The reply map is
39617
- // keyed on the platform UUID (wa-gateway.ts:567) while `accountId` here is
39618
- // the Baileys credential dirname (see the Task 1850 note above). Passing
39619
- // the dirname missed every lookup and delivered session 8e5bc4e8's answer
39620
- // to nothing. `accountId` stays correct on line 718: that is the socket.
39621
- fanTargets: () => whatsappFanTargets(effectiveAccountId, senderId),
39622
- onClose: () => {
39623
- if (nativeFileFollowers.get(senderId) === ac) nativeFileFollowers.delete(senderId);
39624
- }
39734
+ effectiveAccountId,
39735
+ role
39625
39736
  });
39626
- nativeFileFollowers.set(senderId, ac);
39627
39737
  }
39628
39738
  });
39739
+ var followerCensusState = makeCensusState();
39740
+ registerLoop({
39741
+ name: "channel-follower",
39742
+ intervalMs: 6e4,
39743
+ run: () => followerCensusTick(
39744
+ {
39745
+ bound: () => waGateway.boundTargets(),
39746
+ live: () => nativeFileFollowers.live(),
39747
+ paramsFor: (senderId) => nativeFileFollowers.paramsFor(senderId),
39748
+ attach: (p) => nativeFileFollowers.attach(p)
39749
+ },
39750
+ followerCensusState
39751
+ )
39752
+ });
39629
39753
  app80.route("/", waGateway.routes());
39630
39754
  waGateway.startSweeper();
39631
39755
  registerLoop({
@@ -1,50 +0,0 @@
1
- // Task 1623 — acceptance for the installer convergence that drops the vestigial
2
- // admins[] key from existing client account.json. Filesystem fixtures under a
3
- // tmp dir; no installer side effects. Runs under `node --test dist/__tests__/*`.
4
- import test from "node:test";
5
- import assert from "node:assert/strict";
6
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { join } from "node:path";
9
- import { removeClientAdminsKey } from "../converge-client-admins.js";
10
- function makeRoot() {
11
- return mkdtempSync(join(tmpdir(), "conv-1623-"));
12
- }
13
- function acct(root, id, cfg) {
14
- mkdirSync(join(root, id), { recursive: true });
15
- writeFileSync(join(root, id, "account.json"), JSON.stringify(cfg, null, 2));
16
- }
17
- function hasAdmins(root, id) {
18
- return "admins" in JSON.parse(readFileSync(join(root, id, "account.json"), "utf-8"));
19
- }
20
- test("removes admins from a client, keeps it on the house, is idempotent", () => {
21
- const root = makeRoot();
22
- try {
23
- acct(root, "c1", { accountId: "c1", role: "client", admins: [] });
24
- acct(root, "h1", { accountId: "h1", role: "house", admins: [{ userId: "u1", role: "owner" }] });
25
- acct(root, "c2", { accountId: "c2", role: "client" }); // already stripped
26
- const first = removeClientAdminsKey(root);
27
- assert.equal(first.find((r) => r.accountId === "c1")?.action, "removed");
28
- assert.equal(first.find((r) => r.accountId === "h1")?.action, "house-kept");
29
- assert.equal(first.find((r) => r.accountId === "c2")?.action, "absent");
30
- assert.equal(hasAdmins(root, "c1"), false);
31
- assert.equal(hasAdmins(root, "h1"), true);
32
- const second = removeClientAdminsKey(root);
33
- assert.equal(second.find((r) => r.accountId === "c1")?.action, "absent");
34
- }
35
- finally {
36
- rmSync(root, { recursive: true, force: true });
37
- }
38
- });
39
- test("unreadable account.json is reported, not thrown", () => {
40
- const root = makeRoot();
41
- try {
42
- mkdirSync(join(root, "bad"), { recursive: true });
43
- writeFileSync(join(root, "bad", "account.json"), "{ not json");
44
- const res = removeClientAdminsKey(root);
45
- assert.equal(res.find((r) => r.accountId === "bad")?.action, "unreadable");
46
- }
47
- finally {
48
- rmSync(root, { recursive: true, force: true });
49
- }
50
- });