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.
- package/README.md +225 -192
- package/bin/copilotoffice.js +39 -39
- package/dist/electron/main.js +599 -126
- package/dist/electron/terminal/events-watcher.js +53 -2
- package/dist/electron/terminal/ipc-relay.js +49 -1
- package/dist/electron/terminal/preload.js +57 -13
- package/dist/electron/terminal/server.js +6125 -940
- package/dist/game.bundle.js +611 -206
- package/package.json +62 -58
- 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;
|
|
@@ -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))
|
|
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,
|
|
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
|
|
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(
|
|
1653
|
+
async maybeNotifyComplete(binding, lastReplyText) {
|
|
1438
1654
|
const notifier = this.deps.notifier;
|
|
1439
1655
|
if (!notifier || !(this.deps.isNotifyActive?.() ?? false)) return;
|
|
1440
|
-
if (!
|
|
1441
|
-
const title = (
|
|
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(
|
|
1659
|
+
const html = `${this.agentLabel(binding)} has finished responding${where}`;
|
|
1444
1660
|
try {
|
|
1445
1661
|
const posted = await notifier.replyToThread({
|
|
1446
|
-
teamId:
|
|
1447
|
-
channelId:
|
|
1448
|
-
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
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
};
|
|
1640
|
-
|
|
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
|
|
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)
|
|
2679
|
-
|
|
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) {
|