copilotoffice 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -152,7 +152,9 @@ var TerminalRelay = class _TerminalRelay {
152
152
  * server→main copilot/terminal events without going through the renderer.
153
153
  * Emits: 'copilot-event' (agentId, event), 'copilot-turn-start' (agentId),
154
154
  * 'copilot-turn-end' (agentId), 'copilot-tool-start' (agentId, toolName, toolId, status),
155
+ * 'copilot-ask-user' (agentId, toolId, requestId, question, options, freeform),
155
156
  * 'session-meta-updated' (agentId, meta), 'terminal-exit' (agentId, exitCode).
157
+ * Accepts (via mainSubmitAnswer): 'submit-answer' (officeId, agentId, requestId?, answer, wasFreeform).
156
158
  */
157
159
  this.mainEvents = new import_events.EventEmitter();
158
160
  this.server = null;
@@ -313,6 +315,22 @@ var TerminalRelay = class _TerminalRelay {
313
315
  mainSubmitPrompt(officeId, agentId, prompt, label) {
314
316
  return this.request({ type: "submit-prompt", requestId: this.id(), officeId, agentId, prompt, label });
315
317
  }
318
+ /**
319
+ * spec 015: answer a pending `ask_user` interaction. Distinct from
320
+ * mainSubmitPrompt — resolves the pending user-input interaction (SDK/ui-server)
321
+ * or injects keystrokes (node-pty). `requestId` is the single-resolution key.
322
+ */
323
+ mainSubmitAnswer(officeId, agentId, a) {
324
+ return this.request({
325
+ type: "submit-answer",
326
+ requestId: this.id(),
327
+ officeId,
328
+ agentId,
329
+ answerRequestId: a.requestId,
330
+ answer: a.answer,
331
+ wasFreeform: a.wasFreeform
332
+ });
333
+ }
316
334
  /** Fire-and-forget: control whether copilot-events are mirrored to main for an agent without a viewer. */
317
335
  mainSetAgentForwarding(officeId, agentId, enabled) {
318
336
  this.send({ type: "set-agent-forwarding", officeId, agentId, enabled });
@@ -366,6 +384,12 @@ var TerminalRelay = class _TerminalRelay {
366
384
  case "copilot-tool-start":
367
385
  this.mainEvents.emit("copilot-tool-start", msg.agentId, msg.toolName, msg.toolId, msg.status);
368
386
  break;
387
+ case "copilot-ask-user":
388
+ this.mainEvents.emit("copilot-ask-user", msg.agentId, msg.toolId, msg.requestId, msg.question, msg.options, msg.freeform);
389
+ break;
390
+ case "copilot-ask-user-complete":
391
+ this.mainEvents.emit("copilot-ask-user-complete", msg.agentId, msg.requestId);
392
+ break;
369
393
  case "session-meta-updated":
370
394
  this.mainEvents.emit("session-meta-updated", msg.agentId, msg.meta);
371
395
  break;
@@ -388,6 +412,12 @@ var TerminalRelay = class _TerminalRelay {
388
412
  case "copilot-tool-start":
389
413
  win.webContents.send("copilot-tool-start", msg.agentId, msg.toolName, msg.toolId, msg.status);
390
414
  break;
415
+ case "copilot-ask-user":
416
+ win.webContents.send("copilot-ask-user", msg.agentId, msg.toolId, msg.requestId, msg.question, msg.options, msg.freeform);
417
+ break;
418
+ case "copilot-ask-user-complete":
419
+ win.webContents.send("copilot-ask-user-complete", msg.agentId, msg.requestId);
420
+ break;
391
421
  case "copilot-tool-complete":
392
422
  win.webContents.send("copilot-tool-complete", msg.agentId, msg.toolId, msg.success);
393
423
  break;
@@ -927,9 +957,12 @@ function sniffImageType(buf) {
927
957
  }
928
958
  function resolveWithinBase(rawPath, baseDir) {
929
959
  if (!baseDir) return null;
930
- if (path5.isAbsolute(rawPath)) return null;
960
+ if (path5.isAbsolute(rawPath) || path5.win32.isAbsolute(rawPath) || path5.posix.isAbsolute(rawPath)) {
961
+ return null;
962
+ }
963
+ const normalizedRaw = rawPath.replace(/\\/g, "/");
931
964
  const root = path5.resolve(baseDir);
932
- const resolved = path5.resolve(root, rawPath);
965
+ const resolved = path5.resolve(root, normalizedRaw);
933
966
  const rel = path5.relative(root, resolved);
934
967
  if (rel === "" || rel.startsWith("..") || path5.isAbsolute(rel)) return null;
935
968
  return resolved;
@@ -1010,9 +1043,131 @@ function pickAckQuip(rng = Math.random) {
1010
1043
  return ACK_QUIPS[Math.floor(rng() * ACK_QUIPS.length)] ?? ACK_QUIPS[0];
1011
1044
  }
1012
1045
 
1046
+ // electron/teams/auth.ts
1047
+ var import_child_process3 = require("child_process");
1048
+ function isAzLoginError(err) {
1049
+ const msg = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase();
1050
+ if (!msg) return false;
1051
+ return msg.includes("az login") || msg.includes("please run") || msg.includes("not logged in") || msg.includes("no subscription") || msg.includes("refresh token") || msg.includes("aadsts") || msg.includes("interaction_required") || msg.includes("token acquisition failed");
1052
+ }
1053
+ var RESOURCE_URLS = {
1054
+ graph: "https://graph.microsoft.com",
1055
+ ic3: "https://ic3.teams.office.com"
1056
+ };
1057
+ var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
1058
+ function decodeJwtExpMs(token) {
1059
+ try {
1060
+ const parts = token.split(".");
1061
+ if (parts.length < 2) return 0;
1062
+ const payload = JSON.parse(
1063
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8")
1064
+ );
1065
+ const exp = Number(payload?.exp);
1066
+ return Number.isFinite(exp) ? exp * 1e3 : 0;
1067
+ } catch {
1068
+ return 0;
1069
+ }
1070
+ }
1071
+ var defaultAzRunner = (resourceUrl) => new Promise((resolve2, reject) => {
1072
+ const isWin = process.platform === "win32";
1073
+ const azArgs = ["account", "get-access-token", "--resource", resourceUrl, "--output", "json"];
1074
+ const [file, args] = isWin ? [process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `az ${azArgs.join(" ")}`]] : ["az", azArgs];
1075
+ (0, import_child_process3.execFile)(
1076
+ file,
1077
+ args,
1078
+ { windowsHide: true, maxBuffer: 1024 * 1024 },
1079
+ (err, stdout) => {
1080
+ if (err) {
1081
+ reject(new Error(`az token acquisition failed for ${resourceUrl} (${err.message})`));
1082
+ return;
1083
+ }
1084
+ try {
1085
+ const parsed = JSON.parse(stdout);
1086
+ const token = String(parsed?.accessToken || "");
1087
+ if (!token) {
1088
+ reject(new Error(`az returned no accessToken for ${resourceUrl}`));
1089
+ return;
1090
+ }
1091
+ resolve2(token);
1092
+ } catch {
1093
+ reject(new Error(`Failed to parse az token output for ${resourceUrl}`));
1094
+ }
1095
+ }
1096
+ );
1097
+ });
1098
+ var AzTokenProvider = class {
1099
+ /**
1100
+ * `runner` is injectable for tests (defaults to the real `az` CLI). `persistence`
1101
+ * (optional) seeds the in-memory cache from an encrypted on-disk store at startup
1102
+ * and is updated on every successful acquisition, so a still-valid token survives
1103
+ * app restarts and avoids a slow `az` cold-start.
1104
+ */
1105
+ constructor(runner = defaultAzRunner, persistence, observer) {
1106
+ this.runner = runner;
1107
+ this.persistence = persistence;
1108
+ this.observer = observer;
1109
+ this.cache = /* @__PURE__ */ new Map();
1110
+ if (persistence) {
1111
+ try {
1112
+ const loaded = persistence.load();
1113
+ for (const [res, ct] of Object.entries(loaded)) {
1114
+ if (ct && typeof ct.token === "string" && ct.token && typeof ct.expiresAt === "number") {
1115
+ this.cache.set(res, ct);
1116
+ }
1117
+ }
1118
+ } catch {
1119
+ }
1120
+ }
1121
+ }
1122
+ saveCache() {
1123
+ if (!this.persistence) return;
1124
+ try {
1125
+ const all = {};
1126
+ for (const [res, ct] of this.cache.entries()) all[res] = ct;
1127
+ this.persistence.save(all);
1128
+ } catch {
1129
+ }
1130
+ }
1131
+ async getToken(resource) {
1132
+ const now = Date.now();
1133
+ const cached = this.cache.get(resource);
1134
+ if (cached && cached.expiresAt - now > REFRESH_BUFFER_MS) {
1135
+ return cached.token;
1136
+ }
1137
+ try {
1138
+ const token = await this.runner(RESOURCE_URLS[resource]);
1139
+ const expMs = decodeJwtExpMs(token);
1140
+ const expiresAt = expMs > 0 ? expMs : now + 30 * 60 * 1e3;
1141
+ this.cache.set(resource, { token, expiresAt });
1142
+ this.saveCache();
1143
+ tlog(`Acquired ${resource} token (expires ${new Date(expiresAt).toISOString()}).`);
1144
+ this.notifyObserver(() => this.observer?.onAcquire?.(resource));
1145
+ return token;
1146
+ } catch (e) {
1147
+ const err = e instanceof Error ? e : new Error(String(e));
1148
+ if (cached && cached.expiresAt > now) {
1149
+ twarn(`Token refresh failed for ${resource}; reusing cached token.`);
1150
+ this.notifyObserver(() => this.observer?.onFailure?.(resource, err, true));
1151
+ return cached.token;
1152
+ }
1153
+ this.notifyObserver(() => this.observer?.onFailure?.(resource, err, false));
1154
+ throw err;
1155
+ }
1156
+ }
1157
+ /** Run an observer callback defensively — a buggy observer must never break token flow. */
1158
+ notifyObserver(fn) {
1159
+ try {
1160
+ fn();
1161
+ } catch {
1162
+ }
1163
+ }
1164
+ };
1165
+
1013
1166
  // electron/teams/teamsService.ts
1014
1167
  var RECONCILE_MS = 15e3;
1015
1168
  var TURN_SETTLE_MS = 2500;
1169
+ var AUTH_TOAST_DURATION_MS = 6e4;
1170
+ var AUTH_TOAST_REPEAT_MS = 5 * 60 * 1e3;
1016
1171
  var TeamsService = class _TeamsService {
1017
1172
  constructor(deps) {
1018
1173
  this.deps = deps;
@@ -1030,6 +1185,14 @@ var TeamsService = class _TeamsService {
1030
1185
  */
1031
1186
  this.ambient = /* @__PURE__ */ new Map();
1032
1187
  // key = agentId
1188
+ /**
1189
+ * Pending `ask_user` questions awaiting an answer, keyed by agentId (spec 015).
1190
+ * At most one per online agent — a new `ask-user` supersedes the prior record.
1191
+ * Transient, in-memory, main-process only (never persisted). Distinct from
1192
+ * {@link pending} (in-flight Teams dispatches) and {@link ambient}.
1193
+ */
1194
+ this.pendingQuestions = /* @__PURE__ */ new Map();
1195
+ // key = agentId
1033
1196
  /**
1034
1197
  * Message ids of every message the app has posted (thread roots + replies).
1035
1198
  * Primary self-loop guard: an inbound message whose id is here is our own echo
@@ -1042,6 +1205,12 @@ var TeamsService = class _TeamsService {
1042
1205
  this.unsubExit = null;
1043
1206
  this.reconcileTimer = null;
1044
1207
  this.started = false;
1208
+ /** True once a hard token failure has been surfaced and not yet recovered. */
1209
+ this.authBroken = false;
1210
+ /** Timestamp of the last az-login toast (throttle guard). 0 ⇒ never shown. */
1211
+ this.lastAuthToastAt = 0;
1212
+ /** Last observed receive-transport health, to emit connect/reconnect notices once per transition. */
1213
+ this.lastTransportHealth = "unknown";
1045
1214
  this.now = deps.now ?? Date.now;
1046
1215
  this.settleMs = deps.turnSettleMs ?? TURN_SETTLE_MS;
1047
1216
  this.filter = new MessageFilter(this.now);
@@ -1164,6 +1333,8 @@ var TeamsService = class _TeamsService {
1164
1333
  teamId: coords.teamId,
1165
1334
  channelId: coords.channelId,
1166
1335
  tenantId: coords.tenantId,
1336
+ mentionType: ctx.officeMentionType,
1337
+ mentionValue: ctx.officeMentionValue,
1167
1338
  threadRootId: thread.threadRootId,
1168
1339
  threadWebUrl: thread.webUrl,
1169
1340
  online: true,
@@ -1184,6 +1355,13 @@ var TeamsService = class _TeamsService {
1184
1355
  const b = this.findBinding(officeId, agentId);
1185
1356
  if (!b) return { success: true };
1186
1357
  tlog(`OFFLINE: @${b.handle} (${officeId}:${agentId}).`);
1358
+ const abandoned = this.pendingQuestions.get(agentId);
1359
+ if (abandoned) {
1360
+ this.pendingQuestions.delete(agentId);
1361
+ if (postNotice && b.online && b.threadRootId) {
1362
+ await this.safeReply(b, `${this.agentLabel(b)} \u26A0\uFE0F This question is no longer answerable (agent offline).`);
1363
+ }
1364
+ }
1187
1365
  if (postNotice && b.online && b.threadRootId) {
1188
1366
  await this.safeReply(b, "\u{1F50C} This agent has gone offline. Replies here will not be answered.");
1189
1367
  }
@@ -1203,7 +1381,7 @@ var TeamsService = class _TeamsService {
1203
1381
  this.bindings = this.bindings.filter((x) => !(x.officeId === officeId && x.agentId === agentId));
1204
1382
  await this.persist();
1205
1383
  this.updateSourceChannels();
1206
- this.deps.emitStatus({ agentId, officeId, online: false, handle: b.handle, health: "disconnected" });
1384
+ this.deps.emitStatus({ agentId, officeId, online: false, handle: b.handle, health: "disconnected", workingDir: b.workingDir });
1207
1385
  return { success: true };
1208
1386
  }
1209
1387
  // ── Inbound handling ─────────────────────────────────────────
@@ -1231,6 +1409,13 @@ var TeamsService = class _TeamsService {
1231
1409
  await this.goOffline(binding.officeId, binding.agentId, true);
1232
1410
  return;
1233
1411
  }
1412
+ const pendingQ = this.pendingQuestions.get(binding.agentId);
1413
+ if (pendingQ) {
1414
+ if (!pendingQ.resolved) {
1415
+ await this.resolveAnswer(pendingQ, msg.content);
1416
+ }
1417
+ return;
1418
+ }
1234
1419
  tlog(`Dispatch: "${msg.senderName}" \u2192 @${binding.handle} (queued=${this.queue.pending(binding.officeId, binding.agentId)}): ${truncate(msg.content, 80)}`);
1235
1420
  this.queue.enqueue({
1236
1421
  officeId: binding.officeId,
@@ -1297,6 +1482,15 @@ var TeamsService = class _TeamsService {
1297
1482
  });
1298
1483
  }
1299
1484
  onAgentEvent(e) {
1485
+ if (e.kind === "ask-user") {
1486
+ void this.onAskUserEvent(e);
1487
+ return;
1488
+ }
1489
+ if (e.kind === "ask-user-complete") {
1490
+ this.maybeLocalResolveByRequestId(e.agentId, e.requestId ?? "");
1491
+ return;
1492
+ }
1493
+ this.maybeLocalResolve(e.agentId);
1300
1494
  const rec = this.pending.get(e.agentId);
1301
1495
  if (!rec) {
1302
1496
  this.onAmbientEvent(e);
@@ -1410,6 +1604,7 @@ var TeamsService = class _TeamsService {
1410
1604
  if (!text) return;
1411
1605
  const elapsed = Math.round((this.now() - rec.startedAt) / 1e3);
1412
1606
  tlog(`Local reply \u2192 @${rec.binding.handle} thread (${text.length} chars, ${elapsed}s): ${truncate(text, 80)}`);
1607
+ rec.lastReplyText = text;
1413
1608
  await this.postReply(rec.binding, text);
1414
1609
  }
1415
1610
  /** Close out an ambient turn once the agent goes idle: flush residual text, drop record. */
@@ -1419,6 +1614,7 @@ var TeamsService = class _TeamsService {
1419
1614
  this.cancelAmbientSettle(rec);
1420
1615
  this.ambient.delete(agentId);
1421
1616
  await this.flushAmbient(rec);
1617
+ await this.maybeNotifyComplete(rec.binding, rec.lastReplyText);
1422
1618
  }
1423
1619
  /** Post a locally-typed user request into the thread, tagged so it's distinct from replies. */
1424
1620
  async postLocalRequest(binding, text) {
@@ -1442,29 +1638,33 @@ var TeamsService = class _TeamsService {
1442
1638
  this.cancelSettle(rec);
1443
1639
  this.pending.delete(agentId);
1444
1640
  await this.flushTurn(rec);
1445
- await this.maybeNotifyComplete(rec);
1641
+ await this.maybeNotifyComplete(rec.binding, rec.lastReplyText);
1446
1642
  rec.resolve();
1447
1643
  }
1448
1644
  /**
1449
1645
  * Post a single end-of-response notification via the distinct-identity {@link
1450
1646
  * TeamsServiceDeps.notifier} (relay/Dump channel) so a Power Automate flow re-posts it
1451
1647
  * under the Flow-bot identity with an @mention. Active only when a notifier is wired and
1452
- * {@link TeamsServiceDeps.isNotifyActive} is true. Skips silently when the dispatch
1648
+ * {@link TeamsServiceDeps.isNotifyActive} is true. Skips silently when the response
1453
1649
  * produced no text. Never throws — a notification failure must not wedge the queue.
1650
+ * Shared by the Teams-dispatch ({@link finalizeDispatch}) and locally-driven ambient
1651
+ * ({@link finalizeAmbient}) idle-finalize paths.
1454
1652
  */
1455
- async maybeNotifyComplete(rec) {
1653
+ async maybeNotifyComplete(binding, lastReplyText) {
1456
1654
  const notifier = this.deps.notifier;
1457
1655
  if (!notifier || !(this.deps.isNotifyActive?.() ?? false)) return;
1458
- if (!rec.lastReplyText) return;
1459
- const title = (rec.binding.sessionTitle || "").trim();
1656
+ if (!lastReplyText) return;
1657
+ const title = (binding.sessionTitle || "").trim();
1460
1658
  const where = title ? ` in \u201C${escapeHtml(title)}\u201D` : "";
1461
- const html = `${this.agentLabel(rec.binding)} has finished responding${where}`;
1659
+ const html = `${this.agentLabel(binding)} has finished responding${where}`;
1462
1660
  try {
1463
1661
  const posted = await notifier.replyToThread({
1464
- teamId: rec.binding.teamId,
1465
- channelId: rec.binding.channelId,
1466
- threadRootId: rec.binding.threadRootId,
1467
- html
1662
+ teamId: binding.teamId,
1663
+ channelId: binding.channelId,
1664
+ threadRootId: binding.threadRootId,
1665
+ html,
1666
+ // Per-office relay @mention override (frozen at register); empty ⇒ global mention.
1667
+ mentionOverride: { type: binding.mentionType ?? "none", value: binding.mentionValue ?? "" }
1468
1668
  });
1469
1669
  if (posted?.messageId) this.rememberPosted(posted.messageId);
1470
1670
  } catch (e) {
@@ -1486,6 +1686,170 @@ var TeamsService = class _TeamsService {
1486
1686
  * under the operator's own Teams identity, this makes automated agent output
1487
1687
  * visually distinct from messages the operator typed by hand.
1488
1688
  */
1689
+ // ── ask_user question / answer flow (spec 015) ───────────────
1690
+ /**
1691
+ * Handle an `ask-user` AgentEvent for an online agent (contract §A). Resolves the
1692
+ * binding, assigns stable selector labels (A, B, C…) to options in order, supersedes any
1693
+ * existing record for the agent, and posts one framed question message listing all
1694
+ * options (+ a freeform hint iff allowed). Ignored when the agent isn't online.
1695
+ */
1696
+ async onAskUserEvent(e) {
1697
+ if (!e.askUser) return;
1698
+ const binding = this.bindings.find((b) => b.agentId === e.agentId && b.online);
1699
+ if (!binding) return;
1700
+ const options = e.askUser.options.map((o, i) => ({
1701
+ label: selectorLabel(i),
1702
+ text: o.text
1703
+ }));
1704
+ const record = {
1705
+ agentId: e.agentId,
1706
+ officeId: binding.officeId,
1707
+ binding,
1708
+ toolId: e.askUser.toolId,
1709
+ requestId: e.askUser.requestId ?? "",
1710
+ question: e.askUser.question,
1711
+ options,
1712
+ freeform: e.askUser.freeform,
1713
+ resolved: false,
1714
+ createdAt: this.now()
1715
+ };
1716
+ this.pendingQuestions.set(e.agentId, record);
1717
+ tlog(`ask_user \u2192 @${binding.handle}: "${truncate(record.question, 80)}" (${options.length} options, freeform=${record.freeform}, requestId=${record.requestId || "\u2205"})`);
1718
+ const html = this.composeQuestion(record);
1719
+ let firstId;
1720
+ for (const chunk of chunkReply(html, 3500)) {
1721
+ const id = await this.safeReply(binding, chunk);
1722
+ if (!firstId) firstId = id;
1723
+ }
1724
+ if (this.pendingQuestions.get(e.agentId) === record) {
1725
+ record.postedMessageId = firstId;
1726
+ }
1727
+ }
1728
+ /** Compose the HTML for a pending question: attention framing + question + labeled
1729
+ * options + optional freeform hint (contract §A, FR-001/002/006; framing per FR-002/T031). */
1730
+ composeQuestion(record) {
1731
+ const lines = [
1732
+ `${this.agentLabel(record.binding)} \u2753 <b>needs your answer</b>`,
1733
+ `<br><br>${escapeHtml(record.question)}`
1734
+ ];
1735
+ for (const opt of record.options) {
1736
+ lines.push(`<br><b>${escapeHtml(opt.label)}</b> \u2014 ${escapeHtml(opt.text)}`);
1737
+ }
1738
+ lines.push(`<br><br><i>Reply with a letter (${record.options.map((o) => escapeHtml(o.label)).join(", ")}) to choose.</i>`);
1739
+ if (record.freeform) {
1740
+ lines.push(`<br><i>Or reply with your own answer.</i>`);
1741
+ }
1742
+ return lines.join("");
1743
+ }
1744
+ /** Compose the nudge re-listing options when a choices-only reply doesn't match a label
1745
+ * (contract §B, FR-005/SC-005). Leaves the record pending. */
1746
+ composeNudge(record) {
1747
+ const lines = [
1748
+ `${this.agentLabel(record.binding)} \u{1F914} I didn't recognize that as one of the choices. Reply with a letter:`
1749
+ ];
1750
+ for (const opt of record.options) {
1751
+ lines.push(`<br><b>${escapeHtml(opt.label)}</b> \u2014 ${escapeHtml(opt.text)}`);
1752
+ }
1753
+ return lines.join("");
1754
+ }
1755
+ /**
1756
+ * Resolve a thread reply against a pending question (contract §B). Selector-label-only
1757
+ * matching (FR-014). The `resolved` check-and-set is synchronous (main process is
1758
+ * single-threaded) so the first resolver wins — a near-simultaneous second reply finds
1759
+ * `resolved === true` and is dropped (single-resolution, FR-007/SC-004). The record is
1760
+ * deleted after the answer is submitted so genuine follow-up prompts dispatch normally.
1761
+ */
1762
+ async resolveAnswer(record, rawText) {
1763
+ const token = (rawText.trim().split(/\s+/)[0] ?? "").replace(/[).:]$/, "");
1764
+ const matched = token ? record.options.find((o) => o.label.toLowerCase() === token.toLowerCase()) : void 0;
1765
+ if (matched) {
1766
+ if (record.resolved) return;
1767
+ record.resolved = true;
1768
+ tlog(`answer \u2192 @${record.binding.handle}: label "${matched.label}" \u21D2 "${truncate(matched.text, 60)}"`);
1769
+ const ok = await this.submitAnswerSafe(record, matched.text, false);
1770
+ this.settleResolution(record, ok);
1771
+ return;
1772
+ }
1773
+ if (record.freeform) {
1774
+ if (record.resolved) return;
1775
+ record.resolved = true;
1776
+ tlog(`answer \u2192 @${record.binding.handle}: freeform "${truncate(rawText, 60)}"`);
1777
+ const ok = await this.submitAnswerSafe(record, rawText.trim(), true);
1778
+ this.settleResolution(record, ok);
1779
+ return;
1780
+ }
1781
+ await this.safeReply(record.binding, this.composeNudge(record));
1782
+ }
1783
+ /**
1784
+ * Finalize a resolution attempt (spec 015 hardening h2). On success, delete the record
1785
+ * so genuine follow-up prompts dispatch normally. On transport FAILURE, RELEASE the
1786
+ * single-resolution latch and KEEP the record so the human can simply reply again, and
1787
+ * post a thread notice — instead of silently dropping the answer and hanging the agent.
1788
+ */
1789
+ settleResolution(record, ok) {
1790
+ if (ok) {
1791
+ this.pendingQuestions.delete(record.agentId);
1792
+ return;
1793
+ }
1794
+ if (this.pendingQuestions.get(record.agentId) === record) {
1795
+ record.resolved = false;
1796
+ void this.safeReply(
1797
+ record.binding,
1798
+ `${this.agentLabel(record.binding)} \u26A0\uFE0F I couldn't deliver that answer \u2014 please reply again.`
1799
+ );
1800
+ }
1801
+ }
1802
+ /** Submit an answer through the gateway. Returns true iff the transport reported success;
1803
+ * a failure (thrown by the gateway when the runtime had no pending interaction to resolve,
1804
+ * or a transient IPC error) returns false so the caller can keep the question open and
1805
+ * re-prompt (FR hardening h2). The submitted value is the option TEXT (never the label)
1806
+ * or the raw freeform text — identical to a local answer (FR-003/004/014). */
1807
+ async submitAnswerSafe(record, answer, wasFreeform) {
1808
+ try {
1809
+ await this.deps.gateway.submitAnswer(record.officeId, record.agentId, {
1810
+ requestId: record.requestId || void 0,
1811
+ answer,
1812
+ wasFreeform
1813
+ });
1814
+ return true;
1815
+ } catch (e) {
1816
+ twarn("submitAnswer failed:", e.message);
1817
+ return false;
1818
+ }
1819
+ }
1820
+ /**
1821
+ * Local-resolution detection for the node-pty degraded path (contract §C, FR-008): if
1822
+ * `agentId` has a still-pending, unresolved node-pty question (empty requestId) and a
1823
+ * non-`ask-user` event arrives, the ask_user was answered in-app. Latch it, clear the
1824
+ * record, and post a one-time "answered in the app" notice. SDK records (non-empty
1825
+ * requestId) are ignored here — they resolve precisely via {@link maybeLocalResolveByRequestId}
1826
+ * on the explicit `user_input.completed` signal, avoiding false positives when an agent
1827
+ * emits events while still blocked on the question.
1828
+ */
1829
+ maybeLocalResolve(agentId) {
1830
+ const record = this.pendingQuestions.get(agentId);
1831
+ if (!record || record.resolved) return;
1832
+ if (record.requestId) return;
1833
+ record.resolved = true;
1834
+ this.pendingQuestions.delete(agentId);
1835
+ tlog(`ask_user answered locally (node-pty) for @${record.binding.handle} \u2014 posting in-app notice.`);
1836
+ void this.safeReply(record.binding, `${this.agentLabel(record.binding)} \u2705 Answered in the app.`);
1837
+ }
1838
+ /**
1839
+ * Precise local-resolution for the SDK/ui-server path (spec 015 hardening h1). Fired on
1840
+ * `user_input.completed`: clear the pending question ONLY when its requestId matches the
1841
+ * resolved interaction. A Teams answer clears the record synchronously before this fires,
1842
+ * so a matching record here means the answer came from the app → post the one-time notice.
1843
+ */
1844
+ maybeLocalResolveByRequestId(agentId, requestId) {
1845
+ const record = this.pendingQuestions.get(agentId);
1846
+ if (!record || record.resolved) return;
1847
+ if (!requestId || record.requestId !== requestId) return;
1848
+ record.resolved = true;
1849
+ this.pendingQuestions.delete(agentId);
1850
+ tlog(`ask_user answered locally (SDK requestId=${requestId}) for @${record.binding.handle} \u2014 posting in-app notice.`);
1851
+ void this.safeReply(record.binding, `${this.agentLabel(record.binding)} \u2705 Answered in the app.`);
1852
+ }
1489
1853
  agentLabel(binding) {
1490
1854
  return `\u{1F916} <b>${escapeHtml(binding.displayName)}</b>`;
1491
1855
  }
@@ -1513,7 +1877,9 @@ var TeamsService = class _TeamsService {
1513
1877
  await this.safeReply(binding, `${prefix}<br>${hostedImagesHtml(images)}`, images);
1514
1878
  }
1515
1879
  }
1516
- /** Reply to a thread, swallowing errors (logs only) so the queue keeps moving. */
1880
+ /** Reply to a thread, swallowing errors (logs only) so the queue keeps moving.
1881
+ * Returns the posted messageId when Graph reports one (used to record the ask_user
1882
+ * question's message id — spec 015 T021), or undefined on failure. */
1517
1883
  async safeReply(binding, html, hostedImages) {
1518
1884
  try {
1519
1885
  const posted = await this.deps.graph.replyToThread({
@@ -1524,11 +1890,129 @@ var TeamsService = class _TeamsService {
1524
1890
  hostedImages
1525
1891
  });
1526
1892
  if (posted?.messageId) this.rememberPosted(posted.messageId);
1893
+ return posted?.messageId;
1527
1894
  } catch (e) {
1528
1895
  twarn("replyToThread failed:", e.message);
1896
+ return void 0;
1529
1897
  }
1530
1898
  }
1531
1899
  // ── Reconnect / teardown reconcile (FR-022/024) ──────────────
1900
+ // ── Credential / connection health (auth + transport) ────────
1901
+ /** True when at least one agent is bound (online or persisted); gates health noise. */
1902
+ hasBoundAgents() {
1903
+ return this.bindings.length > 0;
1904
+ }
1905
+ /**
1906
+ * Called by the token provider (wired in main.ts) on every acquisition outcome.
1907
+ * Owns the actionable credential toast and its throttle, and clears the warning once a
1908
+ * token is acquired again. Only surfaces while an agent is bound (avoid noise when Teams
1909
+ * isn't in use). A soft failure (cached-token fallback) is not user-facing — the agent is
1910
+ * still working — so only hard failures raise the prompt. `err` is used only to tailor the
1911
+ * message (az-login vs generic connectivity); it is never logged or shown verbatim.
1912
+ */
1913
+ onTokenOutcome(kind, _resource, usedCache, err) {
1914
+ if (kind === "acquire") {
1915
+ if (this.authBroken) {
1916
+ const hadToast = this.lastAuthToastAt > 0;
1917
+ this.authBroken = false;
1918
+ this.lastAuthToastAt = 0;
1919
+ if (hadToast && this.hasBoundAgents()) {
1920
+ this.deps.emitToast({ level: "info", message: "Teams: Azure credential restored \u2014 reconnected." });
1921
+ this.lastTransportHealth = "unknown";
1922
+ }
1923
+ }
1924
+ return;
1925
+ }
1926
+ if (usedCache) return;
1927
+ if (!this.hasBoundAgents()) {
1928
+ this.authBroken = true;
1929
+ return;
1930
+ }
1931
+ const now = this.now();
1932
+ const dueForRepeat = now - this.lastAuthToastAt >= AUTH_TOAST_REPEAT_MS;
1933
+ if (!this.authBroken || dueForRepeat) {
1934
+ this.authBroken = true;
1935
+ this.lastAuthToastAt = now;
1936
+ const looksLikeLogin = !err || isAzLoginError(err);
1937
+ this.deps.emitToast({
1938
+ level: "error",
1939
+ message: looksLikeLogin ? 'Teams: Azure credential expired. Run "az login" in a new terminal to reconnect.' : "Teams: can\u2019t reach Azure to authenticate (network issue?). Retrying automatically.",
1940
+ durationMs: AUTH_TOAST_DURATION_MS
1941
+ });
1942
+ }
1943
+ }
1944
+ /**
1945
+ * Emit a one-shot notice when the receive transport (re)connects. Called each reconcile
1946
+ * tick. The az-login prompt is owned by {@link onTokenOutcome}; a transport drop that is
1947
+ * NOT an auth failure (e.g. a network blip) stays quiet — only its eventual recovery is
1948
+ * announced. Gated on bound agents to avoid noise when Teams isn't in use.
1949
+ */
1950
+ checkTransportHealth() {
1951
+ const health = this.deps.source.health;
1952
+ const prev = this.lastTransportHealth;
1953
+ this.lastTransportHealth = health;
1954
+ if (!this.hasBoundAgents()) return;
1955
+ if (health === "connected" && prev !== "connected" && prev !== "unknown") {
1956
+ this.deps.emitToast({ level: "info", message: "Teams: reconnected." });
1957
+ }
1958
+ }
1959
+ /**
1960
+ * One-shot access check run when the feature is enabled + saved in Settings. Confirms the
1961
+ * signed-in user can actually reach the configured default + relay/Dump channels. Acquiring
1962
+ * the graph token here exercises `az`, so a broken credential trips the token observer →
1963
+ * az-login toast automatically. Purely reports via toast; never throws.
1964
+ */
1965
+ async verifyAccess(settings) {
1966
+ const targets = [];
1967
+ if (settings.defaultChannelUrl?.trim()) targets.push({ label: "default", url: settings.defaultChannelUrl });
1968
+ if (settings.relayChannelUrl?.trim()) targets.push({ label: "Dump", url: settings.relayChannelUrl });
1969
+ if (targets.length === 0) return;
1970
+ const getChannel = this.deps.graph.getChannel?.bind(this.deps.graph);
1971
+ if (!getChannel) return;
1972
+ const ok = [];
1973
+ const failed = [];
1974
+ const authFailed = [];
1975
+ let unknownFailure = false;
1976
+ for (const t of targets) {
1977
+ const coords = parseChannelLink(t.url);
1978
+ if (!coords) {
1979
+ failed.push(`${t.label} (unparseable link)`);
1980
+ continue;
1981
+ }
1982
+ try {
1983
+ await getChannel(coords.teamId, coords.channelId);
1984
+ ok.push(t.label);
1985
+ } catch (e) {
1986
+ const msg = e.message || "";
1987
+ if (/\b401\b/.test(msg)) {
1988
+ authFailed.push(t.label);
1989
+ } else if (/\b(403|404)\b/.test(msg)) {
1990
+ failed.push(t.label);
1991
+ } else {
1992
+ unknownFailure = true;
1993
+ }
1994
+ }
1995
+ }
1996
+ if (authFailed.length > 0) {
1997
+ this.deps.emitToast({
1998
+ level: "error",
1999
+ message: `Teams: Azure sign-in was rejected for the ${authFailed.join(" + ")} channel${authFailed.length > 1 ? "s" : ""}. Run "az login" (correct tenant) and re-enable.`,
2000
+ durationMs: AUTH_TOAST_DURATION_MS
2001
+ });
2002
+ }
2003
+ if (failed.length > 0) {
2004
+ this.deps.emitToast({
2005
+ level: "warn",
2006
+ message: `Teams: can't access the ${failed.join(" + ")} channel${failed.length > 1 ? "s" : ""}. Check the link and your Teams membership.`
2007
+ });
2008
+ }
2009
+ if (authFailed.length === 0 && failed.length === 0 && !unknownFailure && ok.length > 0) {
2010
+ this.deps.emitToast({
2011
+ level: "info",
2012
+ message: `Teams: verified access to the ${ok.join(" + ")} channel${ok.length > 1 ? "s" : ""}.`
2013
+ });
2014
+ }
2015
+ }
1532
2016
  /**
1533
2017
  * Run a reconcile pass on demand (e.g. right after the renderer re-attaches
1534
2018
  * terminal sessions on startup / office switch), so Teams bindings re-online
@@ -1539,6 +2023,7 @@ var TeamsService = class _TeamsService {
1539
2023
  await this.reconcile();
1540
2024
  }
1541
2025
  async reconcile() {
2026
+ this.checkTransportHealth();
1542
2027
  let changed = false;
1543
2028
  for (const b of [...this.bindings]) {
1544
2029
  if (!this.started) return;
@@ -1604,7 +2089,8 @@ var TeamsService = class _TeamsService {
1604
2089
  online: b.online,
1605
2090
  handle: b.handle,
1606
2091
  threadWebUrl: b.threadWebUrl,
1607
- health: b.online ? this.deps.source.health : "disconnected"
2092
+ health: b.online ? this.deps.source.health : "disconnected",
2093
+ workingDir: b.workingDir
1608
2094
  };
1609
2095
  }
1610
2096
  findBinding(officeId, agentId) {
@@ -1648,109 +2134,15 @@ function truncate(text, max) {
1648
2134
  const oneLine = (text ?? "").replace(/\s+/g, " ").trim();
1649
2135
  return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max)}\u2026`;
1650
2136
  }
1651
-
1652
- // electron/teams/auth.ts
1653
- var import_child_process3 = require("child_process");
1654
- var RESOURCE_URLS = {
1655
- graph: "https://graph.microsoft.com",
1656
- ic3: "https://ic3.teams.office.com"
1657
- };
1658
- var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
1659
- function decodeJwtExpMs(token) {
1660
- try {
1661
- const parts = token.split(".");
1662
- if (parts.length < 2) return 0;
1663
- const payload = JSON.parse(
1664
- Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8")
1665
- );
1666
- const exp = Number(payload?.exp);
1667
- return Number.isFinite(exp) ? exp * 1e3 : 0;
1668
- } catch {
1669
- return 0;
1670
- }
2137
+ function selectorLabel(index) {
2138
+ let n = index;
2139
+ let s = "";
2140
+ do {
2141
+ s = String.fromCharCode(65 + n % 26) + s;
2142
+ n = Math.floor(n / 26) - 1;
2143
+ } while (n >= 0);
2144
+ return s;
1671
2145
  }
1672
- var defaultAzRunner = (resourceUrl) => new Promise((resolve2, reject) => {
1673
- const isWin = process.platform === "win32";
1674
- const azArgs = ["account", "get-access-token", "--resource", resourceUrl, "--output", "json"];
1675
- const [file, args] = isWin ? [process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `az ${azArgs.join(" ")}`]] : ["az", azArgs];
1676
- (0, import_child_process3.execFile)(
1677
- file,
1678
- args,
1679
- { windowsHide: true, maxBuffer: 1024 * 1024 },
1680
- (err, stdout) => {
1681
- if (err) {
1682
- reject(new Error(`az token acquisition failed for ${resourceUrl} (${err.message})`));
1683
- return;
1684
- }
1685
- try {
1686
- const parsed = JSON.parse(stdout);
1687
- const token = String(parsed?.accessToken || "");
1688
- if (!token) {
1689
- reject(new Error(`az returned no accessToken for ${resourceUrl}`));
1690
- return;
1691
- }
1692
- resolve2(token);
1693
- } catch {
1694
- reject(new Error(`Failed to parse az token output for ${resourceUrl}`));
1695
- }
1696
- }
1697
- );
1698
- });
1699
- var AzTokenProvider = class {
1700
- /**
1701
- * `runner` is injectable for tests (defaults to the real `az` CLI). `persistence`
1702
- * (optional) seeds the in-memory cache from an encrypted on-disk store at startup
1703
- * and is updated on every successful acquisition, so a still-valid token survives
1704
- * app restarts and avoids a slow `az` cold-start.
1705
- */
1706
- constructor(runner = defaultAzRunner, persistence) {
1707
- this.runner = runner;
1708
- this.persistence = persistence;
1709
- this.cache = /* @__PURE__ */ new Map();
1710
- if (persistence) {
1711
- try {
1712
- const loaded = persistence.load();
1713
- for (const [res, ct] of Object.entries(loaded)) {
1714
- if (ct && typeof ct.token === "string" && ct.token && typeof ct.expiresAt === "number") {
1715
- this.cache.set(res, ct);
1716
- }
1717
- }
1718
- } catch {
1719
- }
1720
- }
1721
- }
1722
- saveCache() {
1723
- if (!this.persistence) return;
1724
- try {
1725
- const all = {};
1726
- for (const [res, ct] of this.cache.entries()) all[res] = ct;
1727
- this.persistence.save(all);
1728
- } catch {
1729
- }
1730
- }
1731
- async getToken(resource) {
1732
- const now = Date.now();
1733
- const cached = this.cache.get(resource);
1734
- if (cached && cached.expiresAt - now > REFRESH_BUFFER_MS) {
1735
- return cached.token;
1736
- }
1737
- try {
1738
- const token = await this.runner(RESOURCE_URLS[resource]);
1739
- const expMs = decodeJwtExpMs(token);
1740
- const expiresAt = expMs > 0 ? expMs : now + 30 * 60 * 1e3;
1741
- this.cache.set(resource, { token, expiresAt });
1742
- this.saveCache();
1743
- tlog(`Acquired ${resource} token (expires ${new Date(expiresAt).toISOString()}).`);
1744
- return token;
1745
- } catch (e) {
1746
- if (cached && cached.expiresAt > now) {
1747
- twarn(`Token refresh failed for ${resource}; reusing cached token.`);
1748
- return cached.token;
1749
- }
1750
- throw e;
1751
- }
1752
- }
1753
- };
1754
2146
 
1755
2147
  // electron/teams/tokenCacheStore.ts
1756
2148
  var fs5 = __toESM(require("fs"));
@@ -1882,6 +2274,21 @@ var GraphClient = class {
1882
2274
  const json = await res.json();
1883
2275
  return json.value || [];
1884
2276
  }
2277
+ /**
2278
+ * Probe access to a single channel (`GET /teams/{teamId}/channels/{channelId}`). Used by
2279
+ * the settings-save access check to confirm the signed-in user can actually reach the
2280
+ * configured default / Dump channels. Throws with the HTTP status on non-OK so the caller
2281
+ * can classify 401/403 (permission) vs 404 (wrong link).
2282
+ */
2283
+ async getChannel(teamId, channelId) {
2284
+ const url = `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(
2285
+ channelId
2286
+ )}?$select=id,displayName`;
2287
+ const res = await fetch(url, { headers: await this.authHeaders() });
2288
+ if (!res.ok) throw new Error(`Graph getChannel failed: ${res.status} ${await safeText(res)}`);
2289
+ const json = await res.json();
2290
+ return { id: json.id || channelId, displayName: json.displayName || "" };
2291
+ }
1885
2292
  /** List a team's tags (id + displayName) — used to resolve a tag name to its tagId. */
1886
2293
  async listTags(teamId) {
1887
2294
  const url = `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/tags?$select=id,displayName`;
@@ -2209,6 +2616,12 @@ var RelaySessionGateway = class {
2209
2616
  setForwarding(officeId, agentId, enabled) {
2210
2617
  this.relay.mainSetAgentForwarding(officeId, agentId, enabled);
2211
2618
  }
2619
+ async submitAnswer(officeId, agentId, a) {
2620
+ const res = await this.relay.mainSubmitAnswer(officeId, agentId, a);
2621
+ if (!res.success) {
2622
+ throw new Error(res.error || `Failed to submit answer to ${officeId}:${agentId}`);
2623
+ }
2624
+ }
2212
2625
  onAgentEvent(cb) {
2213
2626
  const onCopilotEvent = (...args) => {
2214
2627
  const agentId = args[0];
@@ -2222,17 +2635,35 @@ var RelaySessionGateway = class {
2222
2635
  const onTurnEnd = (...args) => cb({ agentId: args[0], kind: "turn-end" });
2223
2636
  const onToolStart = (...args) => cb({ agentId: args[0], kind: "tool-start", toolName: args[1] });
2224
2637
  const onUserMessage = (...args) => cb({ agentId: args[0], kind: "user-message", content: args[1] ?? "" });
2638
+ const onAskUser = (...args) => {
2639
+ const agentId = args[0];
2640
+ const toolId = args[1] ?? "";
2641
+ const requestId = args[2] ?? "";
2642
+ const question = args[3] ?? "";
2643
+ const options = args[4] ?? [];
2644
+ const freeform = Boolean(args[5]);
2645
+ cb({ agentId, kind: "ask-user", askUser: { toolId, requestId, question, options, freeform } });
2646
+ };
2647
+ const onAskUserComplete = (...args) => {
2648
+ const agentId = args[0];
2649
+ const requestId = args[1] ?? "";
2650
+ cb({ agentId, kind: "ask-user-complete", requestId });
2651
+ };
2225
2652
  this.relay.mainEvents.on("copilot-event", onCopilotEvent);
2226
2653
  this.relay.mainEvents.on("copilot-turn-start", onTurnStart);
2227
2654
  this.relay.mainEvents.on("copilot-turn-end", onTurnEnd);
2228
2655
  this.relay.mainEvents.on("copilot-tool-start", onToolStart);
2229
2656
  this.relay.mainEvents.on("copilot-user-message", onUserMessage);
2657
+ this.relay.mainEvents.on("copilot-ask-user", onAskUser);
2658
+ this.relay.mainEvents.on("copilot-ask-user-complete", onAskUserComplete);
2230
2659
  return () => {
2231
2660
  this.relay.mainEvents.off("copilot-event", onCopilotEvent);
2232
2661
  this.relay.mainEvents.off("copilot-turn-start", onTurnStart);
2233
2662
  this.relay.mainEvents.off("copilot-turn-end", onTurnEnd);
2234
2663
  this.relay.mainEvents.off("copilot-tool-start", onToolStart);
2235
2664
  this.relay.mainEvents.off("copilot-user-message", onUserMessage);
2665
+ this.relay.mainEvents.off("copilot-ask-user", onAskUser);
2666
+ this.relay.mainEvents.off("copilot-ask-user-complete", onAskUserComplete);
2236
2667
  };
2237
2668
  }
2238
2669
  onSessionExit(cb) {
@@ -2332,6 +2763,9 @@ function createAllowlistedGraphSender(inner, getAllowed) {
2332
2763
  if (inner.listChannels) {
2333
2764
  wrapped.listChannels = (teamId) => inner.listChannels(teamId);
2334
2765
  }
2766
+ if (inner.getChannel) {
2767
+ wrapped.getChannel = (teamId, channelId) => inner.getChannel(teamId, channelId);
2768
+ }
2335
2769
  return wrapped;
2336
2770
  }
2337
2771
  function officeChannelOverridesFromJson(json) {
@@ -2381,6 +2815,19 @@ function encodeMetaBlock(meta) {
2381
2815
  const b64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64");
2382
2816
  return `<p>${META_OPEN}${b64}${META_CLOSE}</p>`;
2383
2817
  }
2818
+ function renderRoutingSummary(meta) {
2819
+ const mention = meta.mentionType === "none" || !meta.mentionId ? "none" : `${meta.mentionType}: ${meta.mentionId}`;
2820
+ const rows = [
2821
+ ["Destination channel", meta.destChannelId],
2822
+ ["Destination team", meta.destTeamId],
2823
+ ["Thread", meta.threadRootId || "(new root)"],
2824
+ ["Mention", mention],
2825
+ ["Title", meta.title || "(none)"]
2826
+ ];
2827
+ const lines = rows.map(([label, value]) => `${escapeHtml(label)}: ${escapeHtml(value)}`).join("<br/>");
2828
+ const block = `<p><em>Relay routing (diagnostic \u2014 ignored by flow)</em><br/>${lines}</p>`;
2829
+ return stripMetaMarkers(block);
2830
+ }
2384
2831
  function createRelaySender(opts) {
2385
2832
  const resolveDump = () => {
2386
2833
  const url = opts.getDumpChannelUrl().trim();
@@ -2389,7 +2836,7 @@ function createRelaySender(opts) {
2389
2836
  if (!coords) throw new Error("Teams Dump channel URL could not be parsed.");
2390
2837
  return { teamId: coords.teamId, channelId: coords.channelId };
2391
2838
  };
2392
- const postToDump = async (dest, title, html, hostedImages) => {
2839
+ const postToDump = async (dest, title, html, hostedImages, mentionOverride) => {
2393
2840
  if (!opts.isDestinationAllowed(dest.channelId)) {
2394
2841
  throw new Error(
2395
2842
  `Teams outbound blocked: destination channel ${dest.channelId} is not in the allowlist (relay).`
@@ -2397,7 +2844,8 @@ function createRelaySender(opts) {
2397
2844
  }
2398
2845
  const dump = resolveDump();
2399
2846
  let resolved = { mentionType: "none", mentionId: "" };
2400
- const ref = opts.getMention();
2847
+ const hasOverride = !!(mentionOverride && mentionOverride.type !== "none" && mentionOverride.value.trim());
2848
+ const ref = hasOverride ? mentionOverride : opts.getMention();
2401
2849
  if (ref && ref.type !== "none" && ref.value.trim()) {
2402
2850
  try {
2403
2851
  resolved = await opts.resolveMention({ type: ref.type, value: ref.value.trim() }, dest.teamId);
@@ -2417,7 +2865,7 @@ function createRelaySender(opts) {
2417
2865
  title,
2418
2866
  html: humanHtml
2419
2867
  };
2420
- const body = `${humanHtml}${encodeMetaBlock(meta)}`;
2868
+ const body = `${humanHtml}${renderRoutingSummary(meta)}${encodeMetaBlock(meta)}`;
2421
2869
  await opts.primary.createThread({
2422
2870
  teamId: dump.teamId,
2423
2871
  channelId: dump.channelId,
@@ -2429,7 +2877,7 @@ function createRelaySender(opts) {
2429
2877
  };
2430
2878
  return {
2431
2879
  async createThread(p) {
2432
- await postToDump({ teamId: p.teamId, channelId: p.channelId, threadRootId: "" }, p.subject, p.html, p.hostedImages);
2880
+ await postToDump({ teamId: p.teamId, channelId: p.channelId, threadRootId: "" }, p.subject, p.html, p.hostedImages, p.mentionOverride);
2433
2881
  return { threadRootId: "", webUrl: "" };
2434
2882
  },
2435
2883
  async replyToThread(p) {
@@ -2437,7 +2885,8 @@ function createRelaySender(opts) {
2437
2885
  { teamId: p.teamId, channelId: p.channelId, threadRootId: p.threadRootId },
2438
2886
  "",
2439
2887
  p.html,
2440
- p.hostedImages
2888
+ p.hostedImages,
2889
+ p.mentionOverride
2441
2890
  );
2442
2891
  return { messageId: "" };
2443
2892
  }
@@ -2620,7 +3069,10 @@ import_electron4.app.whenReady().then(async () => {
2620
3069
  path8.join(process.cwd(), ".data", "teams-token.enc"),
2621
3070
  import_electron4.safeStorage
2622
3071
  );
2623
- const tokens = new AzTokenProvider(void 0, tokenPersistence);
3072
+ const tokens = new AzTokenProvider(void 0, tokenPersistence, {
3073
+ onAcquire: (resource) => teamsService?.onTokenOutcome("acquire", resource, false),
3074
+ onFailure: (resource, err, usedCache) => teamsService?.onTokenOutcome("fail", resource, usedCache, err)
3075
+ });
2624
3076
  const getAllowedChannels = createCachedAllowedChannels(
2625
3077
  () => allowedChannelIdSet(
2626
3078
  settingsStore.load().defaultChannelUrl,
@@ -2693,8 +3145,11 @@ import_electron4.app.whenReady().then(async () => {
2693
3145
  settingsStore,
2694
3146
  getMainWindow: () => mainWindow,
2695
3147
  onSettingsChanged: (settings) => {
2696
- if (settings.enabled) teamsService?.start().catch((e) => console.error("[Main] Teams start failed:", e));
2697
- else teamsService?.stop().catch((e) => console.error("[Main] Teams stop failed:", e));
3148
+ if (settings.enabled) {
3149
+ teamsService?.start().then(() => teamsService?.verifyAccess(settings)).catch((e) => console.error("[Main] Teams start failed:", e));
3150
+ } else {
3151
+ teamsService?.stop().catch((e) => console.error("[Main] Teams stop failed:", e));
3152
+ }
2698
3153
  }
2699
3154
  });
2700
3155
  if (settingsStore.load().enabled) {