copilotoffice 2.1.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;
@@ -161,6 +163,8 @@ var TerminalRelay = class _TerminalRelay {
161
163
  this.shuttingDown = false;
162
164
  /** Requests that arrived while the server was not connected. Flushed on ready. */
163
165
  this.queuedRequests = [];
166
+ /** Latest backend-selection outcome reported by the server on 'ready'. */
167
+ this.backendInfo = null;
164
168
  this.getWindow = getWindow;
165
169
  }
166
170
  static {
@@ -311,6 +315,22 @@ var TerminalRelay = class _TerminalRelay {
311
315
  mainSubmitPrompt(officeId, agentId, prompt, label) {
312
316
  return this.request({ type: "submit-prompt", requestId: this.id(), officeId, agentId, prompt, label });
313
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
+ }
314
334
  /** Fire-and-forget: control whether copilot-events are mirrored to main for an agent without a viewer. */
315
335
  mainSetAgentForwarding(officeId, agentId, enabled) {
316
336
  this.send({ type: "set-agent-forwarding", officeId, agentId, enabled });
@@ -320,6 +340,15 @@ var TerminalRelay = class _TerminalRelay {
320
340
  clearTimeout(readyTimeout);
321
341
  this.shuttingDown = false;
322
342
  console.log("[Relay] Terminal server ready");
343
+ if (msg.backend) {
344
+ this.backendInfo = msg.backend;
345
+ if (msg.backend.fellBack) {
346
+ const win2 = this.getWindow();
347
+ if (win2 && !win2.isDestroyed()) {
348
+ win2.webContents.send("backend-fallback", this.backendInfo);
349
+ }
350
+ }
351
+ }
323
352
  if (this.queuedRequests.length > 0) {
324
353
  console.log(`[Relay] Flushing ${this.queuedRequests.length} queued request(s)`);
325
354
  for (const queued of this.queuedRequests) {
@@ -355,6 +384,12 @@ var TerminalRelay = class _TerminalRelay {
355
384
  case "copilot-tool-start":
356
385
  this.mainEvents.emit("copilot-tool-start", msg.agentId, msg.toolName, msg.toolId, msg.status);
357
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;
358
393
  case "session-meta-updated":
359
394
  this.mainEvents.emit("session-meta-updated", msg.agentId, msg.meta);
360
395
  break;
@@ -377,6 +412,12 @@ var TerminalRelay = class _TerminalRelay {
377
412
  case "copilot-tool-start":
378
413
  win.webContents.send("copilot-tool-start", msg.agentId, msg.toolName, msg.toolId, msg.status);
379
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;
380
421
  case "copilot-tool-complete":
381
422
  win.webContents.send("copilot-tool-complete", msg.agentId, msg.toolId, msg.success);
382
423
  break;
@@ -395,17 +436,24 @@ var TerminalRelay = class _TerminalRelay {
395
436
  case "terminal-preload-status":
396
437
  win.webContents.send("terminal-preload-status", msg.agentId, msg.status);
397
438
  break;
439
+ case "backend-online":
440
+ win.webContents.send("backend-online", msg.officeId, msg.backend);
441
+ break;
442
+ case "backend-session-fallback":
443
+ win.webContents.send("backend-session-fallback", msg.officeId, msg.agentId, msg.reason);
444
+ break;
398
445
  }
399
446
  }
400
447
  // ── IPC Handler Registration ──────────────────────────────────
401
448
  registerIpc() {
449
+ import_electron.ipcMain.handle("terminal-backend-info", () => this.backendInfo);
402
450
  import_electron.ipcMain.handle(
403
451
  "terminal-start",
404
452
  (_event, officeId, agentId, workingDir, cols, rows, preseededPrompt, launchMode) => this.request({ type: "start", requestId: this.id(), officeId, agentId, workingDir, cols, rows, preseededPrompt, launchMode })
405
453
  );
406
454
  import_electron.ipcMain.handle(
407
455
  "terminal-attach",
408
- (_event, officeId, agentId) => this.request({ type: "attach", requestId: this.id(), officeId, agentId })
456
+ (_event, officeId, agentId, foreground) => this.request({ type: "attach", requestId: this.id(), officeId, agentId, foreground })
409
457
  );
410
458
  import_electron.ipcMain.handle("terminal-detach", (_event, officeId, agentId) => {
411
459
  this.send({ type: "detach", officeId, agentId });
@@ -909,9 +957,12 @@ function sniffImageType(buf) {
909
957
  }
910
958
  function resolveWithinBase(rawPath, baseDir) {
911
959
  if (!baseDir) return null;
912
- 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, "/");
913
964
  const root = path5.resolve(baseDir);
914
- const resolved = path5.resolve(root, rawPath);
965
+ const resolved = path5.resolve(root, normalizedRaw);
915
966
  const rel = path5.relative(root, resolved);
916
967
  if (rel === "" || rel.startsWith("..") || path5.isAbsolute(rel)) return null;
917
968
  return resolved;
@@ -992,9 +1043,131 @@ function pickAckQuip(rng = Math.random) {
992
1043
  return ACK_QUIPS[Math.floor(rng() * ACK_QUIPS.length)] ?? ACK_QUIPS[0];
993
1044
  }
994
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
+
995
1166
  // electron/teams/teamsService.ts
996
1167
  var RECONCILE_MS = 15e3;
997
1168
  var TURN_SETTLE_MS = 2500;
1169
+ var AUTH_TOAST_DURATION_MS = 6e4;
1170
+ var AUTH_TOAST_REPEAT_MS = 5 * 60 * 1e3;
998
1171
  var TeamsService = class _TeamsService {
999
1172
  constructor(deps) {
1000
1173
  this.deps = deps;
@@ -1012,6 +1185,14 @@ var TeamsService = class _TeamsService {
1012
1185
  */
1013
1186
  this.ambient = /* @__PURE__ */ new Map();
1014
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
1015
1196
  /**
1016
1197
  * Message ids of every message the app has posted (thread roots + replies).
1017
1198
  * Primary self-loop guard: an inbound message whose id is here is our own echo
@@ -1024,6 +1205,12 @@ var TeamsService = class _TeamsService {
1024
1205
  this.unsubExit = null;
1025
1206
  this.reconcileTimer = null;
1026
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";
1027
1214
  this.now = deps.now ?? Date.now;
1028
1215
  this.settleMs = deps.turnSettleMs ?? TURN_SETTLE_MS;
1029
1216
  this.filter = new MessageFilter(this.now);
@@ -1146,6 +1333,8 @@ var TeamsService = class _TeamsService {
1146
1333
  teamId: coords.teamId,
1147
1334
  channelId: coords.channelId,
1148
1335
  tenantId: coords.tenantId,
1336
+ mentionType: ctx.officeMentionType,
1337
+ mentionValue: ctx.officeMentionValue,
1149
1338
  threadRootId: thread.threadRootId,
1150
1339
  threadWebUrl: thread.webUrl,
1151
1340
  online: true,
@@ -1166,6 +1355,13 @@ var TeamsService = class _TeamsService {
1166
1355
  const b = this.findBinding(officeId, agentId);
1167
1356
  if (!b) return { success: true };
1168
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
+ }
1169
1365
  if (postNotice && b.online && b.threadRootId) {
1170
1366
  await this.safeReply(b, "\u{1F50C} This agent has gone offline. Replies here will not be answered.");
1171
1367
  }
@@ -1185,7 +1381,7 @@ var TeamsService = class _TeamsService {
1185
1381
  this.bindings = this.bindings.filter((x) => !(x.officeId === officeId && x.agentId === agentId));
1186
1382
  await this.persist();
1187
1383
  this.updateSourceChannels();
1188
- 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 });
1189
1385
  return { success: true };
1190
1386
  }
1191
1387
  // ── Inbound handling ─────────────────────────────────────────
@@ -1213,6 +1409,13 @@ var TeamsService = class _TeamsService {
1213
1409
  await this.goOffline(binding.officeId, binding.agentId, true);
1214
1410
  return;
1215
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
+ }
1216
1419
  tlog(`Dispatch: "${msg.senderName}" \u2192 @${binding.handle} (queued=${this.queue.pending(binding.officeId, binding.agentId)}): ${truncate(msg.content, 80)}`);
1217
1420
  this.queue.enqueue({
1218
1421
  officeId: binding.officeId,
@@ -1279,6 +1482,15 @@ var TeamsService = class _TeamsService {
1279
1482
  });
1280
1483
  }
1281
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);
1282
1494
  const rec = this.pending.get(e.agentId);
1283
1495
  if (!rec) {
1284
1496
  this.onAmbientEvent(e);
@@ -1392,6 +1604,7 @@ var TeamsService = class _TeamsService {
1392
1604
  if (!text) return;
1393
1605
  const elapsed = Math.round((this.now() - rec.startedAt) / 1e3);
1394
1606
  tlog(`Local reply \u2192 @${rec.binding.handle} thread (${text.length} chars, ${elapsed}s): ${truncate(text, 80)}`);
1607
+ rec.lastReplyText = text;
1395
1608
  await this.postReply(rec.binding, text);
1396
1609
  }
1397
1610
  /** Close out an ambient turn once the agent goes idle: flush residual text, drop record. */
@@ -1401,6 +1614,7 @@ var TeamsService = class _TeamsService {
1401
1614
  this.cancelAmbientSettle(rec);
1402
1615
  this.ambient.delete(agentId);
1403
1616
  await this.flushAmbient(rec);
1617
+ await this.maybeNotifyComplete(rec.binding, rec.lastReplyText);
1404
1618
  }
1405
1619
  /** Post a locally-typed user request into the thread, tagged so it's distinct from replies. */
1406
1620
  async postLocalRequest(binding, text) {
@@ -1424,29 +1638,33 @@ var TeamsService = class _TeamsService {
1424
1638
  this.cancelSettle(rec);
1425
1639
  this.pending.delete(agentId);
1426
1640
  await this.flushTurn(rec);
1427
- await this.maybeNotifyComplete(rec);
1641
+ await this.maybeNotifyComplete(rec.binding, rec.lastReplyText);
1428
1642
  rec.resolve();
1429
1643
  }
1430
1644
  /**
1431
1645
  * Post a single end-of-response notification via the distinct-identity {@link
1432
1646
  * TeamsServiceDeps.notifier} (relay/Dump channel) so a Power Automate flow re-posts it
1433
1647
  * under the Flow-bot identity with an @mention. Active only when a notifier is wired and
1434
- * {@link TeamsServiceDeps.isNotifyActive} is true. Skips silently when the dispatch
1648
+ * {@link TeamsServiceDeps.isNotifyActive} is true. Skips silently when the response
1435
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.
1436
1652
  */
1437
- async maybeNotifyComplete(rec) {
1653
+ async maybeNotifyComplete(binding, lastReplyText) {
1438
1654
  const notifier = this.deps.notifier;
1439
1655
  if (!notifier || !(this.deps.isNotifyActive?.() ?? false)) return;
1440
- if (!rec.lastReplyText) return;
1441
- const title = (rec.binding.sessionTitle || "").trim();
1656
+ if (!lastReplyText) return;
1657
+ const title = (binding.sessionTitle || "").trim();
1442
1658
  const where = title ? ` in \u201C${escapeHtml(title)}\u201D` : "";
1443
- const html = `${this.agentLabel(rec.binding)} has finished responding${where}`;
1659
+ const html = `${this.agentLabel(binding)} has finished responding${where}`;
1444
1660
  try {
1445
1661
  const posted = await notifier.replyToThread({
1446
- teamId: rec.binding.teamId,
1447
- channelId: rec.binding.channelId,
1448
- threadRootId: rec.binding.threadRootId,
1449
- 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 ?? "" }
1450
1668
  });
1451
1669
  if (posted?.messageId) this.rememberPosted(posted.messageId);
1452
1670
  } catch (e) {
@@ -1468,6 +1686,170 @@ var TeamsService = class _TeamsService {
1468
1686
  * under the operator's own Teams identity, this makes automated agent output
1469
1687
  * visually distinct from messages the operator typed by hand.
1470
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
+ }
1471
1853
  agentLabel(binding) {
1472
1854
  return `\u{1F916} <b>${escapeHtml(binding.displayName)}</b>`;
1473
1855
  }
@@ -1495,7 +1877,9 @@ var TeamsService = class _TeamsService {
1495
1877
  await this.safeReply(binding, `${prefix}<br>${hostedImagesHtml(images)}`, images);
1496
1878
  }
1497
1879
  }
1498
- /** 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. */
1499
1883
  async safeReply(binding, html, hostedImages) {
1500
1884
  try {
1501
1885
  const posted = await this.deps.graph.replyToThread({
@@ -1506,11 +1890,129 @@ var TeamsService = class _TeamsService {
1506
1890
  hostedImages
1507
1891
  });
1508
1892
  if (posted?.messageId) this.rememberPosted(posted.messageId);
1893
+ return posted?.messageId;
1509
1894
  } catch (e) {
1510
1895
  twarn("replyToThread failed:", e.message);
1896
+ return void 0;
1511
1897
  }
1512
1898
  }
1513
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
+ }
1514
2016
  /**
1515
2017
  * Run a reconcile pass on demand (e.g. right after the renderer re-attaches
1516
2018
  * terminal sessions on startup / office switch), so Teams bindings re-online
@@ -1521,6 +2023,7 @@ var TeamsService = class _TeamsService {
1521
2023
  await this.reconcile();
1522
2024
  }
1523
2025
  async reconcile() {
2026
+ this.checkTransportHealth();
1524
2027
  let changed = false;
1525
2028
  for (const b of [...this.bindings]) {
1526
2029
  if (!this.started) return;
@@ -1586,7 +2089,8 @@ var TeamsService = class _TeamsService {
1586
2089
  online: b.online,
1587
2090
  handle: b.handle,
1588
2091
  threadWebUrl: b.threadWebUrl,
1589
- health: b.online ? this.deps.source.health : "disconnected"
2092
+ health: b.online ? this.deps.source.health : "disconnected",
2093
+ workingDir: b.workingDir
1590
2094
  };
1591
2095
  }
1592
2096
  findBinding(officeId, agentId) {
@@ -1630,109 +2134,15 @@ function truncate(text, max) {
1630
2134
  const oneLine = (text ?? "").replace(/\s+/g, " ").trim();
1631
2135
  return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max)}\u2026`;
1632
2136
  }
1633
-
1634
- // electron/teams/auth.ts
1635
- var import_child_process3 = require("child_process");
1636
- var RESOURCE_URLS = {
1637
- graph: "https://graph.microsoft.com",
1638
- ic3: "https://ic3.teams.office.com"
1639
- };
1640
- var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
1641
- function decodeJwtExpMs(token) {
1642
- try {
1643
- const parts = token.split(".");
1644
- if (parts.length < 2) return 0;
1645
- const payload = JSON.parse(
1646
- Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8")
1647
- );
1648
- const exp = Number(payload?.exp);
1649
- return Number.isFinite(exp) ? exp * 1e3 : 0;
1650
- } catch {
1651
- return 0;
1652
- }
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;
1653
2145
  }
1654
- var defaultAzRunner = (resourceUrl) => new Promise((resolve2, reject) => {
1655
- const isWin = process.platform === "win32";
1656
- const azArgs = ["account", "get-access-token", "--resource", resourceUrl, "--output", "json"];
1657
- const [file, args] = isWin ? [process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `az ${azArgs.join(" ")}`]] : ["az", azArgs];
1658
- (0, import_child_process3.execFile)(
1659
- file,
1660
- args,
1661
- { windowsHide: true, maxBuffer: 1024 * 1024 },
1662
- (err, stdout) => {
1663
- if (err) {
1664
- reject(new Error(`az token acquisition failed for ${resourceUrl} (${err.message})`));
1665
- return;
1666
- }
1667
- try {
1668
- const parsed = JSON.parse(stdout);
1669
- const token = String(parsed?.accessToken || "");
1670
- if (!token) {
1671
- reject(new Error(`az returned no accessToken for ${resourceUrl}`));
1672
- return;
1673
- }
1674
- resolve2(token);
1675
- } catch {
1676
- reject(new Error(`Failed to parse az token output for ${resourceUrl}`));
1677
- }
1678
- }
1679
- );
1680
- });
1681
- var AzTokenProvider = class {
1682
- /**
1683
- * `runner` is injectable for tests (defaults to the real `az` CLI). `persistence`
1684
- * (optional) seeds the in-memory cache from an encrypted on-disk store at startup
1685
- * and is updated on every successful acquisition, so a still-valid token survives
1686
- * app restarts and avoids a slow `az` cold-start.
1687
- */
1688
- constructor(runner = defaultAzRunner, persistence) {
1689
- this.runner = runner;
1690
- this.persistence = persistence;
1691
- this.cache = /* @__PURE__ */ new Map();
1692
- if (persistence) {
1693
- try {
1694
- const loaded = persistence.load();
1695
- for (const [res, ct] of Object.entries(loaded)) {
1696
- if (ct && typeof ct.token === "string" && ct.token && typeof ct.expiresAt === "number") {
1697
- this.cache.set(res, ct);
1698
- }
1699
- }
1700
- } catch {
1701
- }
1702
- }
1703
- }
1704
- saveCache() {
1705
- if (!this.persistence) return;
1706
- try {
1707
- const all = {};
1708
- for (const [res, ct] of this.cache.entries()) all[res] = ct;
1709
- this.persistence.save(all);
1710
- } catch {
1711
- }
1712
- }
1713
- async getToken(resource) {
1714
- const now = Date.now();
1715
- const cached = this.cache.get(resource);
1716
- if (cached && cached.expiresAt - now > REFRESH_BUFFER_MS) {
1717
- return cached.token;
1718
- }
1719
- try {
1720
- const token = await this.runner(RESOURCE_URLS[resource]);
1721
- const expMs = decodeJwtExpMs(token);
1722
- const expiresAt = expMs > 0 ? expMs : now + 30 * 60 * 1e3;
1723
- this.cache.set(resource, { token, expiresAt });
1724
- this.saveCache();
1725
- tlog(`Acquired ${resource} token (expires ${new Date(expiresAt).toISOString()}).`);
1726
- return token;
1727
- } catch (e) {
1728
- if (cached && cached.expiresAt > now) {
1729
- twarn(`Token refresh failed for ${resource}; reusing cached token.`);
1730
- return cached.token;
1731
- }
1732
- throw e;
1733
- }
1734
- }
1735
- };
1736
2146
 
1737
2147
  // electron/teams/tokenCacheStore.ts
1738
2148
  var fs5 = __toESM(require("fs"));
@@ -1864,6 +2274,21 @@ var GraphClient = class {
1864
2274
  const json = await res.json();
1865
2275
  return json.value || [];
1866
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
+ }
1867
2292
  /** List a team's tags (id + displayName) — used to resolve a tag name to its tagId. */
1868
2293
  async listTags(teamId) {
1869
2294
  const url = `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/tags?$select=id,displayName`;
@@ -2191,6 +2616,12 @@ var RelaySessionGateway = class {
2191
2616
  setForwarding(officeId, agentId, enabled) {
2192
2617
  this.relay.mainSetAgentForwarding(officeId, agentId, enabled);
2193
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
+ }
2194
2625
  onAgentEvent(cb) {
2195
2626
  const onCopilotEvent = (...args) => {
2196
2627
  const agentId = args[0];
@@ -2204,17 +2635,35 @@ var RelaySessionGateway = class {
2204
2635
  const onTurnEnd = (...args) => cb({ agentId: args[0], kind: "turn-end" });
2205
2636
  const onToolStart = (...args) => cb({ agentId: args[0], kind: "tool-start", toolName: args[1] });
2206
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
+ };
2207
2652
  this.relay.mainEvents.on("copilot-event", onCopilotEvent);
2208
2653
  this.relay.mainEvents.on("copilot-turn-start", onTurnStart);
2209
2654
  this.relay.mainEvents.on("copilot-turn-end", onTurnEnd);
2210
2655
  this.relay.mainEvents.on("copilot-tool-start", onToolStart);
2211
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);
2212
2659
  return () => {
2213
2660
  this.relay.mainEvents.off("copilot-event", onCopilotEvent);
2214
2661
  this.relay.mainEvents.off("copilot-turn-start", onTurnStart);
2215
2662
  this.relay.mainEvents.off("copilot-turn-end", onTurnEnd);
2216
2663
  this.relay.mainEvents.off("copilot-tool-start", onToolStart);
2217
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);
2218
2667
  };
2219
2668
  }
2220
2669
  onSessionExit(cb) {
@@ -2314,6 +2763,9 @@ function createAllowlistedGraphSender(inner, getAllowed) {
2314
2763
  if (inner.listChannels) {
2315
2764
  wrapped.listChannels = (teamId) => inner.listChannels(teamId);
2316
2765
  }
2766
+ if (inner.getChannel) {
2767
+ wrapped.getChannel = (teamId, channelId) => inner.getChannel(teamId, channelId);
2768
+ }
2317
2769
  return wrapped;
2318
2770
  }
2319
2771
  function officeChannelOverridesFromJson(json) {
@@ -2363,6 +2815,19 @@ function encodeMetaBlock(meta) {
2363
2815
  const b64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64");
2364
2816
  return `<p>${META_OPEN}${b64}${META_CLOSE}</p>`;
2365
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
+ }
2366
2831
  function createRelaySender(opts) {
2367
2832
  const resolveDump = () => {
2368
2833
  const url = opts.getDumpChannelUrl().trim();
@@ -2371,7 +2836,7 @@ function createRelaySender(opts) {
2371
2836
  if (!coords) throw new Error("Teams Dump channel URL could not be parsed.");
2372
2837
  return { teamId: coords.teamId, channelId: coords.channelId };
2373
2838
  };
2374
- const postToDump = async (dest, title, html, hostedImages) => {
2839
+ const postToDump = async (dest, title, html, hostedImages, mentionOverride) => {
2375
2840
  if (!opts.isDestinationAllowed(dest.channelId)) {
2376
2841
  throw new Error(
2377
2842
  `Teams outbound blocked: destination channel ${dest.channelId} is not in the allowlist (relay).`
@@ -2379,7 +2844,8 @@ function createRelaySender(opts) {
2379
2844
  }
2380
2845
  const dump = resolveDump();
2381
2846
  let resolved = { mentionType: "none", mentionId: "" };
2382
- const ref = opts.getMention();
2847
+ const hasOverride = !!(mentionOverride && mentionOverride.type !== "none" && mentionOverride.value.trim());
2848
+ const ref = hasOverride ? mentionOverride : opts.getMention();
2383
2849
  if (ref && ref.type !== "none" && ref.value.trim()) {
2384
2850
  try {
2385
2851
  resolved = await opts.resolveMention({ type: ref.type, value: ref.value.trim() }, dest.teamId);
@@ -2399,7 +2865,7 @@ function createRelaySender(opts) {
2399
2865
  title,
2400
2866
  html: humanHtml
2401
2867
  };
2402
- const body = `${humanHtml}${encodeMetaBlock(meta)}`;
2868
+ const body = `${humanHtml}${renderRoutingSummary(meta)}${encodeMetaBlock(meta)}`;
2403
2869
  await opts.primary.createThread({
2404
2870
  teamId: dump.teamId,
2405
2871
  channelId: dump.channelId,
@@ -2411,7 +2877,7 @@ function createRelaySender(opts) {
2411
2877
  };
2412
2878
  return {
2413
2879
  async createThread(p) {
2414
- 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);
2415
2881
  return { threadRootId: "", webUrl: "" };
2416
2882
  },
2417
2883
  async replyToThread(p) {
@@ -2419,7 +2885,8 @@ function createRelaySender(opts) {
2419
2885
  { teamId: p.teamId, channelId: p.channelId, threadRootId: p.threadRootId },
2420
2886
  "",
2421
2887
  p.html,
2422
- p.hostedImages
2888
+ p.hostedImages,
2889
+ p.mentionOverride
2423
2890
  );
2424
2891
  return { messageId: "" };
2425
2892
  }
@@ -2602,7 +3069,10 @@ import_electron4.app.whenReady().then(async () => {
2602
3069
  path8.join(process.cwd(), ".data", "teams-token.enc"),
2603
3070
  import_electron4.safeStorage
2604
3071
  );
2605
- 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
+ });
2606
3076
  const getAllowedChannels = createCachedAllowedChannels(
2607
3077
  () => allowedChannelIdSet(
2608
3078
  settingsStore.load().defaultChannelUrl,
@@ -2675,8 +3145,11 @@ import_electron4.app.whenReady().then(async () => {
2675
3145
  settingsStore,
2676
3146
  getMainWindow: () => mainWindow,
2677
3147
  onSettingsChanged: (settings) => {
2678
- if (settings.enabled) teamsService?.start().catch((e) => console.error("[Main] Teams start failed:", e));
2679
- 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
+ }
2680
3153
  }
2681
3154
  });
2682
3155
  if (settingsStore.load().enabled) {