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.
- package/README.md +225 -225
- package/bin/copilotoffice.js +39 -39
- package/dist/electron/main.js +580 -125
- package/dist/electron/terminal/events-watcher.js +53 -2
- package/dist/electron/terminal/ipc-relay.js +30 -0
- package/dist/electron/terminal/preload.js +7 -0
- package/dist/electron/terminal/server.js +404 -72
- package/dist/game.bundle.js +536 -176
- package/package.json +62 -62
- package/src/index.html +44 -44
package/dist/electron/main.js
CHANGED
|
@@ -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))
|
|
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,
|
|
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
|
|
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(
|
|
1653
|
+
async maybeNotifyComplete(binding, lastReplyText) {
|
|
1456
1654
|
const notifier = this.deps.notifier;
|
|
1457
1655
|
if (!notifier || !(this.deps.isNotifyActive?.() ?? false)) return;
|
|
1458
|
-
if (!
|
|
1459
|
-
const title = (
|
|
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(
|
|
1659
|
+
const html = `${this.agentLabel(binding)} has finished responding${where}`;
|
|
1462
1660
|
try {
|
|
1463
1661
|
const posted = await notifier.replyToThread({
|
|
1464
|
-
teamId:
|
|
1465
|
-
channelId:
|
|
1466
|
-
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
};
|
|
1658
|
-
|
|
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
|
|
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)
|
|
2697
|
-
|
|
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) {
|