copilotoffice 2.0.0 → 2.2.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.
@@ -23,24 +23,146 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  ));
24
24
 
25
25
  // electron/main.ts
26
- var import_electron3 = require("electron");
27
- var path3 = __toESM(require("path"));
28
- var fs2 = __toESM(require("fs"));
29
- var import_child_process2 = require("child_process");
26
+ var import_electron4 = require("electron");
27
+ var path8 = __toESM(require("path"));
28
+ var fs7 = __toESM(require("fs"));
29
+ var import_child_process4 = require("child_process");
30
30
 
31
31
  // electron/terminal/ipc-relay.ts
32
32
  var import_electron = require("electron");
33
+ var import_child_process2 = require("child_process");
34
+ var import_events = require("events");
35
+ var crypto2 = __toESM(require("crypto"));
36
+ var path2 = __toESM(require("path"));
37
+
38
+ // electron/terminal/pty-registry.ts
33
39
  var import_child_process = require("child_process");
34
40
  var crypto = __toESM(require("crypto"));
41
+ var fs = __toESM(require("fs"));
42
+ var os = __toESM(require("os"));
35
43
  var path = __toESM(require("path"));
44
+ var START_TIME_GRACE_MS = 2e3;
45
+ var DEFAULT_REGISTRY_FILE = path.join(process.cwd(), ".data", "pty-pids.json");
46
+ function readPtyRegistry(file = DEFAULT_REGISTRY_FILE) {
47
+ try {
48
+ const raw = fs.readFileSync(file, "utf8");
49
+ const parsed = JSON.parse(raw);
50
+ if (!Array.isArray(parsed)) return [];
51
+ return parsed.filter(
52
+ (r) => !!r && typeof r.pid === "number" && Number.isFinite(r.pid)
53
+ );
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+ function writePtyRegistry(records, file = DEFAULT_REGISTRY_FILE) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
62
+ fs.writeFileSync(tmp, JSON.stringify(records, null, 2));
63
+ fs.renameSync(tmp, file);
64
+ } catch {
65
+ }
66
+ }
67
+ function isPidAlive(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (err) {
72
+ return err?.code === "EPERM";
73
+ }
74
+ }
75
+ function processStartTimeMs(pid) {
76
+ try {
77
+ if (os.platform() === "win32") {
78
+ const cmd = `powershell -NoProfile -NonInteractive -Command "$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; if ($p) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds() }"`;
79
+ const out2 = (0, import_child_process.execSync)(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
80
+ const ms2 = parseInt(out2, 10);
81
+ return Number.isFinite(ms2) ? ms2 : null;
82
+ }
83
+ const out = (0, import_child_process.execSync)(`ps -o lstart= -p ${pid}`, {
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "ignore"]
86
+ }).trim();
87
+ if (!out) return null;
88
+ const ms = Date.parse(out);
89
+ return Number.isNaN(ms) ? null : ms;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ function processIdentityMatches(record, startTimeOf) {
95
+ const startMs = startTimeOf(record.pid);
96
+ if (startMs == null) return false;
97
+ return startMs <= record.startedAt + START_TIME_GRACE_MS;
98
+ }
99
+ function killTree(pid) {
100
+ try {
101
+ if (os.platform() === "win32") {
102
+ (0, import_child_process.execSync)(`taskkill /T /F /PID ${pid}`, { stdio: "ignore" });
103
+ } else {
104
+ try {
105
+ process.kill(-pid, "SIGKILL");
106
+ } catch {
107
+ process.kill(pid, "SIGKILL");
108
+ }
109
+ }
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ function reapRegisteredPtys(options = {}) {
116
+ const file = options.file ?? DEFAULT_REGISTRY_FILE;
117
+ const isAlive = options.isAlive ?? isPidAlive;
118
+ const kill = options.kill ?? killTree;
119
+ const startTimeOf = options.startTimeOf ?? processStartTimeMs;
120
+ const protectedPids = /* @__PURE__ */ new Set([process.pid, ...options.protectedPids ?? []]);
121
+ const records = readPtyRegistry(file);
122
+ const reaped = [];
123
+ const skipped = [];
124
+ const failed = [];
125
+ const survivors = [];
126
+ for (const record of records) {
127
+ const { pid } = record;
128
+ if (protectedPids.has(pid) || !isAlive(pid)) {
129
+ skipped.push(pid);
130
+ continue;
131
+ }
132
+ if (!processIdentityMatches(record, startTimeOf)) {
133
+ skipped.push(pid);
134
+ continue;
135
+ }
136
+ if (kill(pid)) {
137
+ reaped.push(pid);
138
+ } else {
139
+ failed.push(pid);
140
+ survivors.push(record);
141
+ }
142
+ }
143
+ writePtyRegistry(survivors, file);
144
+ return { reaped, skipped, failed };
145
+ }
146
+
147
+ // electron/terminal/ipc-relay.ts
36
148
  var TerminalRelay = class _TerminalRelay {
37
149
  constructor(getWindow) {
150
+ /**
151
+ * Event bus for main-process consumers (e.g. the Teams service) that need
152
+ * server→main copilot/terminal events without going through the renderer.
153
+ * Emits: 'copilot-event' (agentId, event), 'copilot-turn-start' (agentId),
154
+ * 'copilot-turn-end' (agentId), 'copilot-tool-start' (agentId, toolName, toolId, status),
155
+ * 'session-meta-updated' (agentId, meta), 'terminal-exit' (agentId, exitCode).
156
+ */
157
+ this.mainEvents = new import_events.EventEmitter();
38
158
  this.server = null;
39
159
  this.pendingRequests = /* @__PURE__ */ new Map();
40
160
  /** True while a deliberate shutdown→respawn is in progress (prevents double-spawn). */
41
161
  this.shuttingDown = false;
42
162
  /** Requests that arrived while the server was not connected. Flushed on ready. */
43
163
  this.queuedRequests = [];
164
+ /** Latest backend-selection outcome reported by the server on 'ready'. */
165
+ this.backendInfo = null;
44
166
  this.getWindow = getWindow;
45
167
  }
46
168
  static {
@@ -49,10 +171,10 @@ var TerminalRelay = class _TerminalRelay {
49
171
  }
50
172
  // ── Server Lifecycle ──────────────────────────────────────────
51
173
  spawnServer(distDir) {
52
- return new Promise((resolve, reject) => {
53
- const serverPath = path.join(distDir, "terminal", "server.js");
174
+ return new Promise((resolve2, reject) => {
175
+ const serverPath = path2.join(distDir, "terminal", "server.js");
54
176
  console.log("[Relay] Forking terminal server:", serverPath);
55
- this.server = (0, import_child_process.fork)(serverPath, [], {
177
+ this.server = (0, import_child_process2.fork)(serverPath, [], {
56
178
  cwd: process.cwd(),
57
179
  stdio: ["pipe", "inherit", "inherit", "ipc"]
58
180
  });
@@ -60,7 +182,7 @@ var TerminalRelay = class _TerminalRelay {
60
182
  reject(new Error("Terminal server did not send ready in time"));
61
183
  }, 15e3);
62
184
  this.server.on("message", (msg) => {
63
- this.handleServerMessage(msg, readyTimeout, resolve);
185
+ this.handleServerMessage(msg, readyTimeout, resolve2);
64
186
  });
65
187
  this.server.on("exit", (code, signal) => {
66
188
  console.error(`[Relay] Terminal server exited (code=${code}, signal=${signal})`);
@@ -76,6 +198,14 @@ var TerminalRelay = class _TerminalRelay {
76
198
  }
77
199
  const win = this.getWindow();
78
200
  if (win && !win.isDestroyed()) {
201
+ try {
202
+ const { reaped } = reapRegisteredPtys();
203
+ if (reaped.length > 0) {
204
+ console.log(`[Relay] Reaped ${reaped.length} orphaned PTY tree(s) from crashed server:`, reaped);
205
+ }
206
+ } catch (e) {
207
+ console.error("[Relay] Failed to reap orphaned PTYs after crash:", e);
208
+ }
79
209
  console.log("[Relay] Respawning terminal server...");
80
210
  this.spawnServer(distDir).catch(
81
211
  (e) => console.error("[Relay] Failed to respawn:", e)
@@ -107,10 +237,10 @@ var TerminalRelay = class _TerminalRelay {
107
237
  this.pendingRequests.clear();
108
238
  this.queuedRequests = [];
109
239
  const oldPid = oldServer.pid;
110
- return new Promise((resolve) => {
240
+ return new Promise((resolve2) => {
111
241
  const onExit = () => {
112
242
  clearTimeout(timeout);
113
- resolve();
243
+ resolve2();
114
244
  };
115
245
  oldServer.once("exit", onExit);
116
246
  const timeout = setTimeout(() => {
@@ -120,14 +250,14 @@ var TerminalRelay = class _TerminalRelay {
120
250
  process.kill(oldPid, 0);
121
251
  console.log(`[Relay] Server PID ${oldPid} still alive after timeout \u2014 force killing`);
122
252
  if (process.platform === "win32") {
123
- (0, import_child_process.execSync)(`taskkill /T /F /PID ${oldPid}`, { stdio: "ignore" });
253
+ (0, import_child_process2.execSync)(`taskkill /T /F /PID ${oldPid}`, { stdio: "ignore" });
124
254
  } else {
125
255
  process.kill(oldPid, "SIGKILL");
126
256
  }
127
257
  } catch {
128
258
  }
129
259
  }
130
- resolve();
260
+ resolve2();
131
261
  }, 3e3);
132
262
  });
133
263
  }
@@ -140,32 +270,67 @@ var TerminalRelay = class _TerminalRelay {
140
270
  request(msg) {
141
271
  if (!this.server?.connected) {
142
272
  console.log(`[Relay] Server not connected \u2014 queuing request ${msg.type} (${msg.requestId})`);
143
- return new Promise((resolve) => {
144
- this.queuedRequests.push({ msg, resolve });
273
+ return new Promise((resolve2) => {
274
+ this.queuedRequests.push({ msg, resolve: resolve2 });
145
275
  });
146
276
  }
147
- return new Promise((resolve) => {
277
+ return new Promise((resolve2) => {
148
278
  const timeout = setTimeout(() => {
149
279
  if (this.pendingRequests.delete(msg.requestId)) {
150
280
  console.warn(`[Relay] Request ${msg.type} (${msg.requestId}) timed out after ${_TerminalRelay.REQUEST_TIMEOUT_MS}ms`);
151
- resolve({ success: false, error: "Request timed out" });
281
+ resolve2({ success: false, error: "Request timed out" });
152
282
  }
153
283
  }, _TerminalRelay.REQUEST_TIMEOUT_MS);
154
284
  this.pendingRequests.set(msg.requestId, (result) => {
155
285
  clearTimeout(timeout);
156
- resolve(result);
286
+ resolve2(result);
157
287
  });
158
288
  this.send(msg);
159
289
  });
160
290
  }
161
291
  id() {
162
- return crypto.randomUUID();
292
+ return crypto2.randomUUID();
293
+ }
294
+ // ── Main-process gateway (for the Teams service) ──────────────
295
+ // These let a main-process consumer talk to the terminal server directly,
296
+ // reusing the same request/response plumbing as the renderer IPC handlers.
297
+ mainGetSessionId(officeId, agentId) {
298
+ return this.request({ type: "get-session-id", requestId: this.id(), officeId, agentId });
299
+ }
300
+ /** True only when the agent's PTY is alive AND the CLI has signalled ready. */
301
+ mainIsAgentReady(officeId, agentId) {
302
+ return this.request({ type: "query-agent-statuses", requestId: this.id(), officeId }).then((statuses) => {
303
+ const s = statuses?.[agentId];
304
+ return !!(s && s.alive && s.ready);
305
+ });
306
+ }
307
+ mainGetSessionMeta(officeId, agentId) {
308
+ return this.request({ type: "get-session-meta", requestId: this.id(), officeId, agentId });
309
+ }
310
+ mainWrite(officeId, agentId, data) {
311
+ return this.request({ type: "write", requestId: this.id(), officeId, agentId, data });
312
+ }
313
+ mainSubmitPrompt(officeId, agentId, prompt, label) {
314
+ return this.request({ type: "submit-prompt", requestId: this.id(), officeId, agentId, prompt, label });
315
+ }
316
+ /** Fire-and-forget: control whether copilot-events are mirrored to main for an agent without a viewer. */
317
+ mainSetAgentForwarding(officeId, agentId, enabled) {
318
+ this.send({ type: "set-agent-forwarding", officeId, agentId, enabled });
163
319
  }
164
320
  handleServerMessage(msg, readyTimeout, onReady) {
165
321
  if (msg.type === "ready") {
166
322
  clearTimeout(readyTimeout);
167
323
  this.shuttingDown = false;
168
324
  console.log("[Relay] Terminal server ready");
325
+ if (msg.backend) {
326
+ this.backendInfo = msg.backend;
327
+ if (msg.backend.fellBack) {
328
+ const win2 = this.getWindow();
329
+ if (win2 && !win2.isDestroyed()) {
330
+ win2.webContents.send("backend-fallback", this.backendInfo);
331
+ }
332
+ }
333
+ }
169
334
  if (this.queuedRequests.length > 0) {
170
335
  console.log(`[Relay] Flushing ${this.queuedRequests.length} queued request(s)`);
171
336
  for (const queued of this.queuedRequests) {
@@ -185,6 +350,29 @@ var TerminalRelay = class _TerminalRelay {
185
350
  }
186
351
  return;
187
352
  }
353
+ switch (msg.type) {
354
+ case "copilot-event":
355
+ this.mainEvents.emit("copilot-event", msg.agentId, msg.event);
356
+ break;
357
+ case "copilot-turn-start":
358
+ this.mainEvents.emit("copilot-turn-start", msg.agentId);
359
+ break;
360
+ case "copilot-turn-end":
361
+ this.mainEvents.emit("copilot-turn-end", msg.agentId);
362
+ break;
363
+ case "copilot-user-message":
364
+ this.mainEvents.emit("copilot-user-message", msg.agentId, msg.text);
365
+ break;
366
+ case "copilot-tool-start":
367
+ this.mainEvents.emit("copilot-tool-start", msg.agentId, msg.toolName, msg.toolId, msg.status);
368
+ break;
369
+ case "session-meta-updated":
370
+ this.mainEvents.emit("session-meta-updated", msg.agentId, msg.meta);
371
+ break;
372
+ case "terminal-exit":
373
+ this.mainEvents.emit("terminal-exit", msg.agentId, msg.exitCode);
374
+ break;
375
+ }
188
376
  const win = this.getWindow();
189
377
  if (!win || win.isDestroyed()) return;
190
378
  switch (msg.type) {
@@ -195,7 +383,7 @@ var TerminalRelay = class _TerminalRelay {
195
383
  win.webContents.send("terminal-exit", msg.agentId, msg.exitCode);
196
384
  break;
197
385
  case "copilot-event":
198
- win.webContents.send("copilot-event", msg.agentId, msg.event);
386
+ if (!msg.mainOnly) win.webContents.send("copilot-event", msg.agentId, msg.event);
199
387
  break;
200
388
  case "copilot-tool-start":
201
389
  win.webContents.send("copilot-tool-start", msg.agentId, msg.toolName, msg.toolId, msg.status);
@@ -210,7 +398,7 @@ var TerminalRelay = class _TerminalRelay {
210
398
  win.webContents.send("copilot-turn-start", msg.agentId);
211
399
  break;
212
400
  case "copilot-user-message":
213
- win.webContents.send("copilot-user-message", msg.agentId);
401
+ win.webContents.send("copilot-user-message", msg.agentId, msg.text);
214
402
  break;
215
403
  case "session-meta-updated":
216
404
  win.webContents.send("session-meta-updated", msg.agentId, msg.meta);
@@ -218,17 +406,24 @@ var TerminalRelay = class _TerminalRelay {
218
406
  case "terminal-preload-status":
219
407
  win.webContents.send("terminal-preload-status", msg.agentId, msg.status);
220
408
  break;
409
+ case "backend-online":
410
+ win.webContents.send("backend-online", msg.officeId, msg.backend);
411
+ break;
412
+ case "backend-session-fallback":
413
+ win.webContents.send("backend-session-fallback", msg.officeId, msg.agentId, msg.reason);
414
+ break;
221
415
  }
222
416
  }
223
417
  // ── IPC Handler Registration ──────────────────────────────────
224
418
  registerIpc() {
419
+ import_electron.ipcMain.handle("terminal-backend-info", () => this.backendInfo);
225
420
  import_electron.ipcMain.handle(
226
421
  "terminal-start",
227
422
  (_event, officeId, agentId, workingDir, cols, rows, preseededPrompt, launchMode) => this.request({ type: "start", requestId: this.id(), officeId, agentId, workingDir, cols, rows, preseededPrompt, launchMode })
228
423
  );
229
424
  import_electron.ipcMain.handle(
230
425
  "terminal-attach",
231
- (_event, officeId, agentId) => this.request({ type: "attach", requestId: this.id(), officeId, agentId })
426
+ (_event, officeId, agentId, foreground) => this.request({ type: "attach", requestId: this.id(), officeId, agentId, foreground })
232
427
  );
233
428
  import_electron.ipcMain.handle("terminal-detach", (_event, officeId, agentId) => {
234
429
  this.send({ type: "detach", officeId, agentId });
@@ -242,6 +437,14 @@ var TerminalRelay = class _TerminalRelay {
242
437
  this.send({ type: "resize", officeId, agentId, cols, rows });
243
438
  return { success: true };
244
439
  });
440
+ import_electron.ipcMain.handle("set-yolo", (_event, enabled) => {
441
+ this.send({ type: "set-yolo", enabled: !!enabled });
442
+ return { success: true };
443
+ });
444
+ import_electron.ipcMain.handle("set-additional-params", (_event, params) => {
445
+ this.send({ type: "set-additional-params", params: String(params ?? "") });
446
+ return { success: true };
447
+ });
245
448
  import_electron.ipcMain.handle(
246
449
  "terminal-kill",
247
450
  (_event, officeId, agentId) => this.request({ type: "kill", requestId: this.id(), officeId, agentId })
@@ -314,22 +517,22 @@ var TerminalRelay = class _TerminalRelay {
314
517
  };
315
518
 
316
519
  // electron/officeFileStore.ts
317
- var fs = __toESM(require("fs"));
318
- var path2 = __toESM(require("path"));
520
+ var fs2 = __toESM(require("fs"));
521
+ var path3 = __toESM(require("path"));
319
522
  var DEFAULT_DATA_SUBDIR = ".data";
320
523
  var DEFAULT_FILE_NAME = "copilot-offices.json";
321
524
  function createOfficeFileStore(options = {}) {
322
525
  const cwd = options.cwd ?? process.cwd();
323
526
  const dataSubdir = options.dataSubdir ?? DEFAULT_DATA_SUBDIR;
324
527
  const fileName = options.fileName ?? DEFAULT_FILE_NAME;
325
- const dataDir = path2.join(cwd, dataSubdir);
326
- const filePath = path2.join(dataDir, fileName);
528
+ const dataDir = path3.join(cwd, dataSubdir);
529
+ const filePath = path3.join(dataDir, fileName);
327
530
  return {
328
531
  filePath,
329
532
  load() {
330
533
  try {
331
- if (!fs.existsSync(filePath)) return { success: true, data: null };
332
- const data = fs.readFileSync(filePath, "utf8");
534
+ if (!fs2.existsSync(filePath)) return { success: true, data: null };
535
+ const data = fs2.readFileSync(filePath, "utf8");
333
536
  return { success: true, data };
334
537
  } catch (e) {
335
538
  return { success: false, data: null, error: String(e) };
@@ -337,8 +540,8 @@ function createOfficeFileStore(options = {}) {
337
540
  },
338
541
  save(data) {
339
542
  try {
340
- fs.mkdirSync(dataDir, { recursive: true });
341
- fs.writeFileSync(filePath, data, "utf8");
543
+ fs2.mkdirSync(dataDir, { recursive: true });
544
+ fs2.writeFileSync(filePath, data, "utf8");
342
545
  return { success: true };
343
546
  } catch (e) {
344
547
  return { success: false, error: String(e) };
@@ -403,123 +606,1995 @@ function registerNonTerminalIpc(hooks) {
403
606
  });
404
607
  }
405
608
 
406
- // electron/main.ts
407
- var OPEN_DEVTOOLS_ON_START = process.env.COPILOT_OFFICE_OPEN_DEVTOOLS !== "0";
408
- var ENABLE_FILE_WATCHER = process.env.COPILOT_OFFICE_ENABLE_WATCHER !== "0";
409
- function killOrphanedProcesses() {
410
- try {
411
- if (process.platform === "win32") {
412
- const out = (0, import_child_process2.execSync)(
413
- `wmic process where "CommandLine like '%COPILOT_OFFICE_PROCESS%'" get ProcessId`,
414
- { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
415
- );
416
- const pids = out.split(/\r?\n/).map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n) && n !== process.pid);
417
- if (pids.length > 0) {
418
- console.log(`[Main] Killing ${pids.length} orphaned COPILOT_OFFICE processes:`, pids);
419
- for (const pid of pids) {
420
- try {
421
- (0, import_child_process2.execSync)(`taskkill /T /F /PID ${pid}`, { stdio: "ignore" });
422
- } catch {
423
- }
609
+ // electron/teams/onlineAgentsStore.ts
610
+ var fs3 = __toESM(require("fs"));
611
+ var path4 = __toESM(require("path"));
612
+
613
+ // electron/teams/log.ts
614
+ var PREFIX = "[TeamsRemote]";
615
+ function tlog(...args) {
616
+ console.log(PREFIX, ...args);
617
+ }
618
+ function twarn(...args) {
619
+ console.warn(PREFIX, ...args);
620
+ }
621
+ function terror(...args) {
622
+ console.error(PREFIX, ...args);
623
+ }
624
+
625
+ // electron/teams/onlineAgentsStore.ts
626
+ var STORE_VERSION = 1;
627
+ var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
628
+ function emptyState() {
629
+ return { bindings: [], knownThreads: [] };
630
+ }
631
+ function gcStale(bindings, nowMs, maxAgeMs = THIRTY_DAYS_MS) {
632
+ const kept = [];
633
+ const removed = [];
634
+ for (const b of bindings) {
635
+ if (nowMs - (b.lastConnected || 0) > maxAgeMs) removed.push(b);
636
+ else kept.push(b);
637
+ }
638
+ return { kept, removed };
639
+ }
640
+ var FileTeamsOnlineStore = class {
641
+ constructor(filePath) {
642
+ this.filePath = filePath;
643
+ }
644
+ static defaultPath(dataDir) {
645
+ return path4.join(dataDir, "teams-online-agents.json");
646
+ }
647
+ async load() {
648
+ try {
649
+ const raw = await fs3.promises.readFile(this.filePath, "utf-8");
650
+ const parsed = JSON.parse(raw);
651
+ return {
652
+ bindings: Array.isArray(parsed.bindings) ? parsed.bindings : [],
653
+ knownThreads: Array.isArray(parsed.knownThreads) ? parsed.knownThreads : []
654
+ };
655
+ } catch (e) {
656
+ if (e?.code === "ENOENT") return emptyState();
657
+ terror("Failed to load online store \u2014 starting empty:", e);
658
+ return emptyState();
659
+ }
660
+ }
661
+ async save(state) {
662
+ const dir = path4.dirname(this.filePath);
663
+ await fs3.promises.mkdir(dir, { recursive: true });
664
+ const payload = JSON.stringify(
665
+ { version: STORE_VERSION, bindings: state.bindings, knownThreads: state.knownThreads },
666
+ null,
667
+ 2
668
+ );
669
+ await fs3.promises.writeFile(this.filePath, payload, "utf-8");
670
+ }
671
+ };
672
+
673
+ // electron/teams/dispatchQueue.ts
674
+ var DispatchQueue = class {
675
+ constructor(process2) {
676
+ this.process = process2;
677
+ this.queues = /* @__PURE__ */ new Map();
678
+ this.processing = /* @__PURE__ */ new Set();
679
+ }
680
+ key(officeId, agentId) {
681
+ return `${officeId}:${agentId}`;
682
+ }
683
+ enqueue(item) {
684
+ const k = this.key(item.officeId, item.agentId);
685
+ const q = this.queues.get(k) ?? [];
686
+ q.push(item);
687
+ this.queues.set(k, q);
688
+ void this.drain(k);
689
+ }
690
+ /** Number of pending items for an agent (excludes the in-flight one). */
691
+ pending(officeId, agentId) {
692
+ return this.queues.get(this.key(officeId, agentId))?.length ?? 0;
693
+ }
694
+ /** Drop all queued work for an agent (e.g. on going offline). */
695
+ clear(officeId, agentId) {
696
+ this.queues.delete(this.key(officeId, agentId));
697
+ }
698
+ async drain(k) {
699
+ if (this.processing.has(k)) return;
700
+ this.processing.add(k);
701
+ try {
702
+ for (; ; ) {
703
+ const q = this.queues.get(k);
704
+ const item = q?.shift();
705
+ if (!item) break;
706
+ try {
707
+ await this.process(item);
708
+ } catch (e) {
709
+ twarn("dispatch item failed:", e.message);
424
710
  }
425
711
  }
426
- } else {
427
- const out = (0, import_child_process2.execSync)("pgrep -f COPILOT_OFFICE_PROCESS || true", {
428
- encoding: "utf8",
429
- stdio: ["pipe", "pipe", "ignore"]
430
- });
431
- const pids = out.split(/\r?\n/).map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n) && n !== process.pid);
432
- if (pids.length > 0) {
433
- console.log(`[Main] Killing ${pids.length} orphaned COPILOT_OFFICE processes:`, pids);
434
- for (const pid of pids) {
435
- try {
436
- process.kill(pid, "SIGKILL");
437
- } catch {
438
- }
439
- }
712
+ } finally {
713
+ this.processing.delete(k);
714
+ }
715
+ }
716
+ };
717
+
718
+ // electron/teams/channelResolver.ts
719
+ function resolveChannel(office, settings) {
720
+ const override = office?.teamsChannelUrl?.trim();
721
+ if (override) return override;
722
+ return settings?.defaultChannelUrl?.trim() || "";
723
+ }
724
+ function activeChannelSet(bindings) {
725
+ const set = /* @__PURE__ */ new Set();
726
+ for (const b of bindings) {
727
+ if (b.online && b.channelId) set.add(b.channelId);
728
+ }
729
+ return set;
730
+ }
731
+ function findBinding(channelId, threadRootId, bindings) {
732
+ return bindings.find(
733
+ (b) => b.online && b.channelId === channelId && b.threadRootId === threadRootId
734
+ );
735
+ }
736
+ function classifyThread(channelId, threadRootId, bindings, knownThreads) {
737
+ if (findBinding(channelId, threadRootId, bindings)) return "bound";
738
+ if (threadRootId && knownThreads.some((t) => t.threadRootId === threadRootId)) {
739
+ return "orphaned";
740
+ }
741
+ return "foreign";
742
+ }
743
+
744
+ // electron/teams/messageFilter.ts
745
+ var STALE_MS = 5 * 60 * 1e3;
746
+ var INJECTION_PATTERNS = [
747
+ /ignore (all |your )?(previous|prior|above) instructions/i,
748
+ /disregard (the |your )?(system|previous) prompt/i,
749
+ /reveal (your )?(system )?prompt/i
750
+ ];
751
+ function scanInjection(content) {
752
+ return INJECTION_PATTERNS.some((re) => re.test(content));
753
+ }
754
+ var BOT_DISPLAY_NAMES = /^(flow bot|power automate|microsoft power automate)$/i;
755
+ function isBotSender(msg) {
756
+ const id = (msg.senderId || "").trim();
757
+ if (id) return id.includes("28:");
758
+ return BOT_DISPLAY_NAMES.test((msg.senderName || "").trim());
759
+ }
760
+ var MessageFilter = class _MessageFilter {
761
+ constructor(nowFn = Date.now) {
762
+ this.nowFn = nowFn;
763
+ this.seen = /* @__PURE__ */ new Set();
764
+ this.seenOrder = [];
765
+ }
766
+ static {
767
+ this.MAX_SEEN = 5e3;
768
+ }
769
+ /** Reset dedup memory (e.g. on service restart). */
770
+ reset() {
771
+ this.seen.clear();
772
+ this.seenOrder = [];
773
+ }
774
+ remember(id) {
775
+ this.seen.add(id);
776
+ this.seenOrder.push(id);
777
+ if (this.seenOrder.length > _MessageFilter.MAX_SEEN) {
778
+ const old = this.seenOrder.shift();
779
+ if (old) this.seen.delete(old);
780
+ }
781
+ }
782
+ evaluate(msg, bindings, knownThreads) {
783
+ if (msg.messageId) {
784
+ if (this.seen.has(msg.messageId)) return { action: "ignore", reason: "duplicate" };
785
+ this.remember(msg.messageId);
786
+ }
787
+ if (msg.hasMarker) return { action: "ignore", reason: "self-post" };
788
+ if (isBotSender(msg)) return { action: "ignore", reason: "bot-sender" };
789
+ if (msg.composeTime) {
790
+ const t = Date.parse(msg.composeTime);
791
+ if (Number.isFinite(t) && this.nowFn() - t > STALE_MS) {
792
+ return { action: "ignore", reason: "stale" };
440
793
  }
441
794
  }
795
+ const active = activeChannelSet(bindings);
796
+ if (!active.has(msg.channelId)) return { action: "ignore", reason: "inactive-channel" };
797
+ if (!msg.threadRootId) return { action: "ignore", reason: "root-message" };
798
+ const classification = classifyThread(msg.channelId, msg.threadRootId, bindings, knownThreads);
799
+ if (classification === "foreign") {
800
+ return { action: "ignore", classification, reason: "foreign-thread" };
801
+ }
802
+ if (classification === "orphaned") {
803
+ return { action: "orphaned-notice", classification, reason: "orphaned-thread" };
804
+ }
805
+ if (scanInjection(msg.content)) {
806
+ return { action: "ignore", classification, reason: "injection-blocked" };
807
+ }
808
+ const binding = findBinding(msg.channelId, msg.threadRootId, bindings);
809
+ return { action: "dispatch", classification, binding };
810
+ }
811
+ };
812
+
813
+ // electron/teams/handleRegistry.ts
814
+ function normalizeHandle(name) {
815
+ if (!name) return "";
816
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
817
+ }
818
+ function assignHandle(base, takenOnline) {
819
+ if (!base) {
820
+ throw new Error("Cannot derive a Teams handle from an empty/invalid agent name");
821
+ }
822
+ if (!takenOnline.has(base)) return base;
823
+ for (let i = 1; ; i++) {
824
+ const candidate = `${base}-${i}`;
825
+ if (!takenOnline.has(candidate)) return candidate;
826
+ }
827
+ }
828
+
829
+ // electron/teams/channelLink.ts
830
+ function parseChannelLink(url) {
831
+ if (!url || typeof url !== "string") return null;
832
+ const trimmed = url.trim();
833
+ if (!trimmed) return null;
834
+ let parsed;
835
+ try {
836
+ parsed = new URL(trimmed);
442
837
  } catch {
838
+ return null;
839
+ }
840
+ const teamId = (parsed.searchParams.get("groupId") || "").trim();
841
+ const tenantId = (parsed.searchParams.get("tenantId") || "").trim();
842
+ const marker = "/l/channel/";
843
+ const idx = parsed.pathname.indexOf(marker);
844
+ let channelId = "";
845
+ if (idx >= 0) {
846
+ const rest = parsed.pathname.slice(idx + marker.length);
847
+ const seg = rest.split("/")[0] || "";
848
+ try {
849
+ channelId = decodeURIComponent(seg).trim();
850
+ } catch {
851
+ channelId = seg.trim();
852
+ }
443
853
  }
854
+ if (!teamId || !channelId) return null;
855
+ if (!/^19:.+@thread\./.test(channelId)) return null;
856
+ return { teamId, channelId, tenantId };
444
857
  }
445
- var mainWindow = null;
446
- var watcherProcess = null;
447
- var relay = new TerminalRelay(() => mainWindow);
448
- var pendingHardReload = false;
449
- function startFileWatcher() {
450
- const copilotOfficePath = path3.join(process.cwd(), "CopilotOffice");
451
- if (!fs2.existsSync(path3.join(copilotOfficePath, "package.json"))) {
452
- if (fs2.existsSync(path3.join(process.cwd(), "src", "main.ts"))) {
453
- watcherProcess = (0, import_child_process2.spawn)("npx", ["esbuild", "src/main.ts", "--bundle", "--outfile=dist/game.bundle.js", "--platform=browser", "--format=iife", "--global-name=CopilotOffice", "--watch"], {
454
- cwd: process.cwd(),
455
- shell: true,
456
- stdio: "pipe"
858
+
859
+ // electron/teams/chunk.ts
860
+ var DEFAULT_MAX = 3500;
861
+ function chunkReply(text, max = DEFAULT_MAX) {
862
+ const body = text ?? "";
863
+ const prefixBudget = 12;
864
+ const limit = Math.max(1, max - prefixBudget);
865
+ if (body.length <= limit) {
866
+ return body.length === 0 ? [""] : [body];
867
+ }
868
+ const raw = [];
869
+ let remaining = body;
870
+ while (remaining.length > limit) {
871
+ let cut = remaining.lastIndexOf("\n", limit);
872
+ if (cut <= 0) cut = limit;
873
+ raw.push(remaining.slice(0, cut));
874
+ remaining = remaining.slice(cut).replace(/^\n/, "");
875
+ }
876
+ if (remaining.length > 0) raw.push(remaining);
877
+ const n = raw.length;
878
+ if (n === 1) return raw;
879
+ return raw.map((part, i) => `(${i + 1}/${n}) ${part}`);
880
+ }
881
+
882
+ // electron/teams/htmlText.ts
883
+ function stripHtml(html) {
884
+ if (!html) return "";
885
+ return html.replace(/<!--[\s\S]*?-->/g, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/\n{3,}/g, "\n\n").trim();
886
+ }
887
+ function escapeHtml(text) {
888
+ return (text ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
889
+ }
890
+
891
+ // electron/teams/imageMarker.ts
892
+ var fs4 = __toESM(require("fs"));
893
+ var path5 = __toESM(require("path"));
894
+ var IMAGE_MARKER_SOURCE = "<!--office-image:(.*?)-->";
895
+ var IMAGE_MARKER_RE = new RegExp(IMAGE_MARKER_SOURCE, "g");
896
+ function extractImageMarkers(input) {
897
+ const src = input ?? "";
898
+ const paths = [];
899
+ const seen = /* @__PURE__ */ new Set();
900
+ const matcher = new RegExp(IMAGE_MARKER_SOURCE, "g");
901
+ for (const m of src.matchAll(matcher)) {
902
+ const p = (m[1] ?? "").trim();
903
+ if (!p || seen.has(p)) continue;
904
+ seen.add(p);
905
+ paths.push(p);
906
+ }
907
+ const text = src.replace(new RegExp(IMAGE_MARKER_SOURCE, "g"), "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
908
+ return { text, paths };
909
+ }
910
+ function sniffImageType(buf) {
911
+ if (buf.length >= 8 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71 && buf[4] === 13 && buf[5] === 10 && buf[6] === 26 && buf[7] === 10) {
912
+ return "image/png";
913
+ }
914
+ if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
915
+ return "image/jpeg";
916
+ }
917
+ if (buf.length >= 6 && buf[0] === 71 && buf[1] === 73 && buf[2] === 70 && buf[3] === 56 && (buf[4] === 55 || buf[4] === 57) && buf[5] === 97) {
918
+ return "image/gif";
919
+ }
920
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
921
+ return "image/webp";
922
+ }
923
+ if (buf.length >= 2 && buf[0] === 66 && buf[1] === 77) {
924
+ return "image/bmp";
925
+ }
926
+ return null;
927
+ }
928
+ function resolveWithinBase(rawPath, baseDir) {
929
+ if (!baseDir) return null;
930
+ if (path5.isAbsolute(rawPath)) return null;
931
+ const root = path5.resolve(baseDir);
932
+ const resolved = path5.resolve(root, rawPath);
933
+ const rel = path5.relative(root, resolved);
934
+ if (rel === "" || rel.startsWith("..") || path5.isAbsolute(rel)) return null;
935
+ return resolved;
936
+ }
937
+ var DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
938
+ var DEFAULT_MAX_IMAGES = 8;
939
+ var DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
940
+ async function loadHostedImages(paths, options = {}) {
941
+ const baseDir = options.baseDir;
942
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
943
+ const maxImages = options.maxImages ?? DEFAULT_MAX_IMAGES;
944
+ const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
945
+ const readFile = options.readFile ?? ((p) => fs4.promises.readFile(p));
946
+ const warn = options.warn ?? (() => {
947
+ });
948
+ const images = [];
949
+ let totalBytes = 0;
950
+ for (const rawPath of paths) {
951
+ if (images.length >= maxImages) {
952
+ warn(`office-image: image count cap (${maxImages}) reached \u2014 ignoring "${rawPath}"`);
953
+ continue;
954
+ }
955
+ const absPath = resolveWithinBase(rawPath, baseDir);
956
+ if (!absPath) {
957
+ warn(`office-image: rejected path outside agent sandbox: "${rawPath}"`);
958
+ continue;
959
+ }
960
+ try {
961
+ const buf = await readFile(absPath);
962
+ if (buf.length === 0) {
963
+ warn(`office-image: skipping empty file ${absPath}`);
964
+ continue;
965
+ }
966
+ if (buf.length > maxBytes) {
967
+ warn(`office-image: skipping ${absPath} \u2014 ${buf.length} bytes exceeds ${maxBytes} limit`);
968
+ continue;
969
+ }
970
+ const sniffed = sniffImageType(buf);
971
+ if (!sniffed) {
972
+ warn(`office-image: skipping ${absPath} \u2014 not a recognized image (magic-byte check failed)`);
973
+ continue;
974
+ }
975
+ if (totalBytes + buf.length > maxTotalBytes) {
976
+ warn(`office-image: aggregate byte cap (${maxTotalBytes}) reached \u2014 ignoring ${absPath}`);
977
+ continue;
978
+ }
979
+ totalBytes += buf.length;
980
+ images.push({
981
+ id: String(images.length + 1),
982
+ contentType: sniffed,
983
+ contentBytesBase64: buf.toString("base64")
457
984
  });
985
+ } catch (e) {
986
+ warn(`office-image: could not read ${absPath}: ${e.message}`);
458
987
  }
459
- } else {
460
- watcherProcess = (0, import_child_process2.spawn)("npx", ["esbuild", "src/main.ts", "--bundle", "--outfile=dist/game.bundle.js", "--platform=browser", "--format=iife", "--global-name=CopilotOffice", "--watch"], {
461
- cwd: copilotOfficePath,
462
- shell: true,
463
- stdio: "pipe"
464
- });
465
988
  }
466
- if (watcherProcess) {
467
- watcherProcess.stdout?.on("data", (data) => {
468
- const msg = data.toString();
469
- console.log("[Watcher]", msg);
470
- if (msg.includes("watching") || msg.includes("built")) {
471
- if (mainWindow && !mainWindow.isDestroyed()) {
472
- mainWindow.webContents.send("build-complete");
473
- }
474
- }
475
- });
476
- watcherProcess.stderr?.on("data", (data) => {
477
- console.error("[Watcher Error]", data.toString());
989
+ return images;
990
+ }
991
+ function hostedImagesHtml(images) {
992
+ return images.map((img) => `<img src="../hostedContents/${img.id}/$value" alt="attachment" style="max-width:100%">`).join("<br>");
993
+ }
994
+
995
+ // electron/teams/ackQuips.ts
996
+ var ACK_QUIPS = [
997
+ "Cracking my knuckles\u2026",
998
+ "Summoning the electrons\u2026",
999
+ "Bribing the compiler\u2026",
1000
+ "Yelling at the terminal politely\u2026",
1001
+ "Consulting the rubber duck\u2026",
1002
+ "Putting down my coffee\u2026",
1003
+ "Rolling a d20 for initiative\u2026",
1004
+ "Waking up the hamsters\u2026",
1005
+ "Pretending I wasn't slacking\u2026",
1006
+ "Feeding the machine spirit\u2026"
1007
+ ];
1008
+ function pickAckQuip(rng = Math.random) {
1009
+ if (ACK_QUIPS.length === 0) return "Working on this\u2026";
1010
+ return ACK_QUIPS[Math.floor(rng() * ACK_QUIPS.length)] ?? ACK_QUIPS[0];
1011
+ }
1012
+
1013
+ // electron/teams/teamsService.ts
1014
+ var RECONCILE_MS = 15e3;
1015
+ var TURN_SETTLE_MS = 2500;
1016
+ var TeamsService = class _TeamsService {
1017
+ constructor(deps) {
1018
+ this.deps = deps;
1019
+ this.bindings = [];
1020
+ this.knownThreads = [];
1021
+ this.pending = /* @__PURE__ */ new Map();
1022
+ // key = agentId
1023
+ /**
1024
+ * Ambient (locally-driven) turn accumulators, keyed by agentId. Populated only
1025
+ * when an online agent produces output that was NOT triggered by a Teams dispatch
1026
+ * (i.e. someone drove the agent in the app's own terminal). Streams those turns
1027
+ * into the bound thread so the channel mirrors everything the online agent does,
1028
+ * not just replies to Teams-originated requests. Disjoint from {@link pending}:
1029
+ * an agent with an in-flight Teams dispatch never uses this map.
1030
+ */
1031
+ this.ambient = /* @__PURE__ */ new Map();
1032
+ // key = agentId
1033
+ /**
1034
+ * Message ids of every message the app has posted (thread roots + replies).
1035
+ * Primary self-loop guard: an inbound message whose id is here is our own echo
1036
+ * and is dropped before all other processing. Deterministic — does not depend on
1037
+ * content markers surviving Teams' sanitizer. Capped FIFO to bound memory.
1038
+ */
1039
+ this.postedMessageIds = /* @__PURE__ */ new Set();
1040
+ this.postedOrder = [];
1041
+ this.unsubEvent = null;
1042
+ this.unsubExit = null;
1043
+ this.reconcileTimer = null;
1044
+ this.started = false;
1045
+ this.now = deps.now ?? Date.now;
1046
+ this.settleMs = deps.turnSettleMs ?? TURN_SETTLE_MS;
1047
+ this.filter = new MessageFilter(this.now);
1048
+ this.queue = new DispatchQueue((item) => this.processDispatch(item));
1049
+ }
1050
+ static {
1051
+ this.MAX_POSTED_IDS = 2e3;
1052
+ }
1053
+ // ── Lifecycle ────────────────────────────────────────────────
1054
+ async start() {
1055
+ if (this.started) return;
1056
+ this.started = true;
1057
+ tlog("Service starting\u2026");
1058
+ const state = await this.deps.store.load();
1059
+ const { kept, removed } = gcStale(state.bindings, this.now());
1060
+ this.bindings = kept.map((b) => ({ ...b, online: false }));
1061
+ this.knownThreads = state.knownThreads;
1062
+ tlog(`Loaded ${this.bindings.length} persisted binding(s), ${this.knownThreads.length} known thread(s).`);
1063
+ if (removed.length > 0) {
1064
+ await this.persist();
1065
+ tlog(`GC removed ${removed.length} stale binding(s) (>30 days).`);
1066
+ this.deps.emitToast({
1067
+ level: "info",
1068
+ message: `Teams: cleaned up ${removed.length} stale agent binding(s) (>30 days).`
1069
+ });
1070
+ }
1071
+ this.unsubEvent = this.deps.gateway.onAgentEvent((e) => this.onAgentEvent(e));
1072
+ this.unsubExit = this.deps.gateway.onSessionExit((agentId) => this.onSessionExit(agentId));
1073
+ await this.deps.source.start((m) => {
1074
+ void this.handleInbound(m);
478
1075
  });
479
- console.log("File watcher started");
1076
+ tlog(`Receive transport started (health=${this.deps.source.health}).`);
1077
+ this.reconcileTimer = setInterval(() => void this.reconcile(), RECONCILE_MS);
1078
+ void this.reconcile();
480
1079
  }
481
- }
482
- function createWindow() {
483
- mainWindow = new import_electron3.BrowserWindow({
484
- width: 2560,
485
- height: 1440,
486
- title: "Copilot Office",
487
- webPreferences: {
488
- preload: path3.join(__dirname, "terminal", "preload.js"),
489
- contextIsolation: true,
490
- nodeIntegration: false,
491
- backgroundThrottling: false
1080
+ async stop() {
1081
+ this.started = false;
1082
+ if (this.reconcileTimer) clearInterval(this.reconcileTimer);
1083
+ this.reconcileTimer = null;
1084
+ this.unsubEvent?.();
1085
+ this.unsubExit?.();
1086
+ this.unsubEvent = this.unsubExit = null;
1087
+ for (const rec of this.ambient.values()) {
1088
+ if (rec.settleTimer) clearTimeout(rec.settleTimer);
492
1089
  }
493
- });
494
- mainWindow.maximize();
495
- mainWindow.loadFile(path3.join(__dirname, "../../src/index.html"));
496
- if (OPEN_DEVTOOLS_ON_START) {
497
- mainWindow.webContents.openDevTools({ mode: "detach" });
1090
+ this.ambient.clear();
1091
+ for (const b of this.bindings) {
1092
+ if (b.online) this.deps.gateway.setForwarding(b.officeId, b.agentId, false);
1093
+ }
1094
+ for (const [agentId, rec] of this.pending) {
1095
+ this.queue.clear(rec.officeId, agentId);
1096
+ this.cancelSettle(rec);
1097
+ rec.resolve();
1098
+ this.pending.delete(agentId);
1099
+ }
1100
+ await this.deps.source.stop();
1101
+ tlog("Service stopped.");
498
1102
  }
499
- mainWindow.webContents.on("did-start-navigation", (_event, _url, isInPlace) => {
500
- if (isInPlace && pendingHardReload) {
501
- console.log("[Main] Hard reload \u2014 restarting terminal server");
502
- pendingHardReload = false;
503
- relay.shutdown().then(
504
- () => relay.spawnServer(__dirname)
505
- ).catch(
506
- (e) => console.error("[Main] Failed to respawn after reload:", e)
507
- );
508
- } else if (isInPlace) {
509
- console.log("[Main] Soft reload \u2014 keeping terminal server alive");
1103
+ // ── Public API (invoked from IPC) ────────────────────────────
1104
+ getStatuses() {
1105
+ return this.bindings.map((b) => this.toStatus(b));
1106
+ }
1107
+ getStatus(officeId, agentId) {
1108
+ const b = this.findBinding(officeId, agentId);
1109
+ return b ? this.toStatus(b) : null;
1110
+ }
1111
+ /** Bring an agent online: resolve channel, create thread, bind, start listening. */
1112
+ async register(ctx) {
1113
+ const { officeId, agentId } = ctx;
1114
+ const settings = this.deps.getSettings();
1115
+ if (!settings.enabled) return { success: false, error: "Teams remote is disabled in settings." };
1116
+ const channelUrl = resolveChannel({ teamsChannelUrl: ctx.officeChannelUrl }, settings);
1117
+ const coords = parseChannelLink(channelUrl);
1118
+ if (!coords) {
1119
+ return { success: false, error: "no-channel" };
510
1120
  }
511
- });
512
- mainWindow.on("closed", () => {
513
- if (watcherProcess) {
514
- watcherProcess.kill();
515
- watcherProcess = null;
1121
+ tlog(`Register requested: ${ctx.displayName} (${officeId}:${agentId}) \u2192 channel ${coords.channelId}`);
1122
+ const sessionId = await this.deps.gateway.getSessionId(officeId, agentId);
1123
+ if (!sessionId) {
1124
+ return { success: false, error: "No active session for this agent. Open its terminal first." };
516
1125
  }
517
- mainWindow = null;
518
- });
519
- }
520
- import_electron3.app.whenReady().then(async () => {
521
- import_electron3.Menu.setApplicationMenu(null);
522
- killOrphanedProcesses();
1126
+ const displayName = ctx.displayName || agentId;
1127
+ const workingDir = ctx.workingDir || "";
1128
+ const existing = this.findBinding(officeId, agentId);
1129
+ if (existing && existing.online) {
1130
+ return { success: true, handle: existing.handle, threadWebUrl: existing.threadWebUrl };
1131
+ }
1132
+ const meta = await this.deps.gateway.getSessionMeta(officeId, agentId);
1133
+ const sessionTitle = meta?.title?.trim() || "";
1134
+ const base = normalizeHandle(displayName);
1135
+ let handle;
1136
+ try {
1137
+ handle = assignHandle(base, this.onlineHandles());
1138
+ } catch {
1139
+ return { success: false, error: "Could not derive a valid handle from the agent name." };
1140
+ }
1141
+ const subject = sessionTitle ? `${displayName}: ${sessionTitle}` : `${displayName}: ${handle}`;
1142
+ const introHtml = this.buildIntro({ displayName, workingDir }, handle, sessionTitle);
1143
+ let thread;
1144
+ try {
1145
+ thread = await this.deps.graph.createThread({
1146
+ teamId: coords.teamId,
1147
+ channelId: coords.channelId,
1148
+ subject,
1149
+ html: introHtml
1150
+ });
1151
+ } catch (e) {
1152
+ return { success: false, error: `Failed to create Teams thread: ${e.message}` };
1153
+ }
1154
+ tlog(`Thread created (root=${thread.threadRootId}) for @${handle}.`);
1155
+ this.rememberPosted(thread.threadRootId);
1156
+ const binding = {
1157
+ agentId,
1158
+ officeId,
1159
+ sessionId,
1160
+ handle,
1161
+ displayName,
1162
+ workingDir,
1163
+ sessionTitle,
1164
+ teamId: coords.teamId,
1165
+ channelId: coords.channelId,
1166
+ tenantId: coords.tenantId,
1167
+ threadRootId: thread.threadRootId,
1168
+ threadWebUrl: thread.webUrl,
1169
+ online: true,
1170
+ lastConnected: this.now()
1171
+ };
1172
+ this.bindings = this.bindings.filter((b) => !(b.officeId === officeId && b.agentId === agentId));
1173
+ this.bindings.push(binding);
1174
+ this.rememberThread(thread.threadRootId);
1175
+ await this.persist();
1176
+ this.updateSourceChannels();
1177
+ this.deps.gateway.setForwarding(officeId, agentId, true);
1178
+ this.deps.emitStatus(this.toStatus(binding));
1179
+ tlog(`ONLINE: @${handle} (${officeId}:${agentId}). Active channels: ${activeChannelSet(this.bindings).size}.`);
1180
+ return { success: true, handle, threadWebUrl: thread.webUrl };
1181
+ }
1182
+ /** Take an agent offline (connection only; session untouched). */
1183
+ async goOffline(officeId, agentId, postNotice = true) {
1184
+ const b = this.findBinding(officeId, agentId);
1185
+ if (!b) return { success: true };
1186
+ tlog(`OFFLINE: @${b.handle} (${officeId}:${agentId}).`);
1187
+ if (postNotice && b.online && b.threadRootId) {
1188
+ await this.safeReply(b, "\u{1F50C} This agent has gone offline. Replies here will not be answered.");
1189
+ }
1190
+ this.queue.clear(officeId, agentId);
1191
+ this.deps.gateway.setForwarding(officeId, agentId, false);
1192
+ const amb = this.ambient.get(agentId);
1193
+ if (amb) {
1194
+ if (amb.settleTimer) clearTimeout(amb.settleTimer);
1195
+ this.ambient.delete(agentId);
1196
+ }
1197
+ const inFlight = this.pending.get(agentId);
1198
+ if (inFlight) {
1199
+ this.cancelSettle(inFlight);
1200
+ this.pending.delete(agentId);
1201
+ inFlight.resolve();
1202
+ }
1203
+ this.bindings = this.bindings.filter((x) => !(x.officeId === officeId && x.agentId === agentId));
1204
+ await this.persist();
1205
+ this.updateSourceChannels();
1206
+ this.deps.emitStatus({ agentId, officeId, online: false, handle: b.handle, health: "disconnected" });
1207
+ return { success: true };
1208
+ }
1209
+ // ── Inbound handling ─────────────────────────────────────────
1210
+ async handleInbound(msg) {
1211
+ if (msg.messageId && this.postedMessageIds.has(msg.messageId)) {
1212
+ return;
1213
+ }
1214
+ const result = this.filter.evaluate(msg, this.bindings, this.knownThreads);
1215
+ if (result.action === "ignore") {
1216
+ if (result.reason && result.reason !== "duplicate" && result.reason !== "root-message" && result.reason !== "inactive-channel") {
1217
+ tlog(`Ignored inbound (${result.reason}) from "${msg.senderName}".`);
1218
+ }
1219
+ return;
1220
+ }
1221
+ if (result.action === "orphaned-notice") {
1222
+ tlog(`Orphaned thread ${msg.threadRootId} messaged by "${msg.senderName}" \u2014 posting inactive notice.`);
1223
+ await this.postOrphanedNotice(msg);
1224
+ return;
1225
+ }
1226
+ const binding = result.binding;
1227
+ if (!binding) return;
1228
+ const content = msg.content.trim();
1229
+ if (content === "/stop") {
1230
+ tlog(`/stop received in @${binding.handle}'s thread \u2014 taking offline.`);
1231
+ await this.goOffline(binding.officeId, binding.agentId, true);
1232
+ return;
1233
+ }
1234
+ tlog(`Dispatch: "${msg.senderName}" \u2192 @${binding.handle} (queued=${this.queue.pending(binding.officeId, binding.agentId)}): ${truncate(msg.content, 80)}`);
1235
+ this.queue.enqueue({
1236
+ officeId: binding.officeId,
1237
+ agentId: binding.agentId,
1238
+ sessionId: binding.sessionId,
1239
+ threadRootId: binding.threadRootId,
1240
+ prompt: msg.content,
1241
+ senderName: msg.senderName
1242
+ });
1243
+ if (this.deps.getSettings().ackEnabled) {
1244
+ void this.safeReply(binding, `${this.agentLabel(binding)} \u231B ${escapeHtml(pickAckQuip())} <i>(message received)</i>`);
1245
+ }
1246
+ }
1247
+ async postOrphanedNotice(msg) {
1248
+ const known = this.knownThreads.find((t) => t.threadRootId === msg.threadRootId);
1249
+ if (!known || known.noticePosted) return;
1250
+ known.noticePosted = true;
1251
+ await this.persist();
1252
+ const coords = this.coordsForChannel(msg.channelId);
1253
+ if (!coords) return;
1254
+ try {
1255
+ const posted = await this.deps.graph.replyToThread({
1256
+ teamId: coords.teamId,
1257
+ channelId: msg.channelId,
1258
+ threadRootId: msg.threadRootId,
1259
+ html: "\u2139\uFE0F This thread is no longer active and will not receive responses."
1260
+ });
1261
+ if (posted?.messageId) this.rememberPosted(posted.messageId);
1262
+ } catch (e) {
1263
+ twarn("Failed to post orphaned-thread notice:", e.message);
1264
+ }
1265
+ }
1266
+ // ── Dispatch + response capture ──────────────────────────────
1267
+ processDispatch(item) {
1268
+ return new Promise((resolve2) => {
1269
+ const binding = this.findBinding(item.officeId, item.agentId);
1270
+ if (!this.started || !binding || !binding.online) {
1271
+ resolve2();
1272
+ return;
1273
+ }
1274
+ const record = {
1275
+ officeId: item.officeId,
1276
+ agentId: item.agentId,
1277
+ binding,
1278
+ chunks: [],
1279
+ resolve: resolve2,
1280
+ startedAt: this.now(),
1281
+ lastCheckIn: this.now(),
1282
+ settleTimer: null
1283
+ };
1284
+ this.pending.set(item.agentId, record);
1285
+ this.deps.gateway.setForwarding(item.officeId, item.agentId, true);
1286
+ const label = item.senderName ? `Teams \xB7 ${item.senderName}` : "Teams";
1287
+ this.deps.gateway.submitPrompt(item.officeId, item.agentId, item.prompt, label).catch((e) => {
1288
+ twarn("submitPrompt failed:", e.message);
1289
+ this.pending.delete(item.agentId);
1290
+ resolve2();
1291
+ });
1292
+ setTimeout(() => {
1293
+ if (this.pending.get(item.agentId) === record) {
1294
+ void this.finalizeDispatch(item.agentId);
1295
+ }
1296
+ }, 10 * 60 * 1e3);
1297
+ });
1298
+ }
1299
+ onAgentEvent(e) {
1300
+ const rec = this.pending.get(e.agentId);
1301
+ if (!rec) {
1302
+ this.onAmbientEvent(e);
1303
+ return;
1304
+ }
1305
+ if (e.kind === "message" && e.content) {
1306
+ this.cancelSettle(rec);
1307
+ rec.chunks.push(e.content);
1308
+ } else if (e.kind === "turn-start") {
1309
+ this.cancelSettle(rec);
1310
+ } else if (e.kind === "user-message") {
1311
+ return;
1312
+ } else if (e.kind === "turn-end") {
1313
+ void this.flushTurn(rec);
1314
+ this.scheduleSettle(e.agentId, rec);
1315
+ } else if (e.kind === "tool-start") {
1316
+ this.cancelSettle(rec);
1317
+ void this.maybeCheckIn(rec, e.toolName);
1318
+ }
1319
+ }
1320
+ /** Clear the pending dispatch close-out timer, if armed. */
1321
+ cancelSettle(rec) {
1322
+ if (rec.settleTimer) {
1323
+ clearTimeout(rec.settleTimer);
1324
+ rec.settleTimer = null;
1325
+ }
1326
+ }
1327
+ /** (Re)arm the debounce that closes out the dispatch once the agent goes idle. */
1328
+ scheduleSettle(agentId, rec) {
1329
+ this.cancelSettle(rec);
1330
+ rec.settleTimer = setTimeout(() => {
1331
+ rec.settleTimer = null;
1332
+ void this.finalizeDispatch(agentId);
1333
+ }, this.settleMs);
1334
+ }
1335
+ /**
1336
+ * Post everything accumulated for the current turn as a Teams reply, then clear
1337
+ * the buffer. Does NOT stop forwarding or end the dispatch — a multi-turn
1338
+ * response keeps flushing per turn. No-op when the turn produced no text.
1339
+ */
1340
+ async flushTurn(rec) {
1341
+ const text = rec.chunks.join("\n\n").trim();
1342
+ rec.chunks = [];
1343
+ if (!text) return;
1344
+ const elapsed = Math.round((this.now() - rec.startedAt) / 1e3);
1345
+ tlog(`Reply \u2192 @${rec.binding.handle} thread (${text.length} chars, ${elapsed}s): ${truncate(text, 80)}`);
1346
+ rec.lastReplyText = text;
1347
+ await this.postReply(rec.binding, text);
1348
+ }
1349
+ // ── Ambient (locally-driven) turn streaming ──────────────────
1350
+ /**
1351
+ * Handle a copilot event for an online agent that has NO in-flight Teams dispatch —
1352
+ * i.e. the agent was driven from the app's own terminal. Streams the resulting
1353
+ * request + reply into the bound thread so the channel mirrors everything the online
1354
+ * agent does. Silently ignores agents that are offline / not bound.
1355
+ */
1356
+ onAmbientEvent(e) {
1357
+ const binding = this.bindings.find((b) => b.agentId === e.agentId && b.online);
1358
+ if (!binding) return;
1359
+ if (e.kind === "user-message") {
1360
+ const text = (e.content ?? "").trim();
1361
+ if (text) void this.postLocalRequest(binding, text);
1362
+ return;
1363
+ }
1364
+ if (e.kind === "message" && e.content) {
1365
+ const rec = this.ensureAmbient(e.agentId, binding);
1366
+ this.cancelAmbientSettle(rec);
1367
+ rec.chunks.push(e.content);
1368
+ } else if (e.kind === "turn-start") {
1369
+ const rec = this.ensureAmbient(e.agentId, binding);
1370
+ this.cancelAmbientSettle(rec);
1371
+ } else if (e.kind === "turn-end") {
1372
+ const rec = this.ambient.get(e.agentId);
1373
+ if (!rec) return;
1374
+ void this.flushAmbient(rec);
1375
+ this.scheduleAmbientSettle(e.agentId, rec);
1376
+ }
1377
+ }
1378
+ ensureAmbient(agentId, binding) {
1379
+ let rec = this.ambient.get(agentId);
1380
+ if (!rec) {
1381
+ rec = {
1382
+ officeId: binding.officeId,
1383
+ agentId,
1384
+ binding,
1385
+ chunks: [],
1386
+ startedAt: this.now(),
1387
+ settleTimer: null
1388
+ };
1389
+ this.ambient.set(agentId, rec);
1390
+ }
1391
+ return rec;
1392
+ }
1393
+ cancelAmbientSettle(rec) {
1394
+ if (rec.settleTimer) {
1395
+ clearTimeout(rec.settleTimer);
1396
+ rec.settleTimer = null;
1397
+ }
1398
+ }
1399
+ scheduleAmbientSettle(agentId, rec) {
1400
+ this.cancelAmbientSettle(rec);
1401
+ rec.settleTimer = setTimeout(() => {
1402
+ rec.settleTimer = null;
1403
+ void this.finalizeAmbient(agentId);
1404
+ }, this.settleMs);
1405
+ }
1406
+ /** Post the current ambient turn's accumulated text; clears the buffer. No-op when empty. */
1407
+ async flushAmbient(rec) {
1408
+ const text = rec.chunks.join("\n\n").trim();
1409
+ rec.chunks = [];
1410
+ if (!text) return;
1411
+ const elapsed = Math.round((this.now() - rec.startedAt) / 1e3);
1412
+ tlog(`Local reply \u2192 @${rec.binding.handle} thread (${text.length} chars, ${elapsed}s): ${truncate(text, 80)}`);
1413
+ await this.postReply(rec.binding, text);
1414
+ }
1415
+ /** Close out an ambient turn once the agent goes idle: flush residual text, drop record. */
1416
+ async finalizeAmbient(agentId) {
1417
+ const rec = this.ambient.get(agentId);
1418
+ if (!rec) return;
1419
+ this.cancelAmbientSettle(rec);
1420
+ this.ambient.delete(agentId);
1421
+ await this.flushAmbient(rec);
1422
+ }
1423
+ /** Post a locally-typed user request into the thread, tagged so it's distinct from replies. */
1424
+ async postLocalRequest(binding, text) {
1425
+ const chunks = chunkReply(text, 3500);
1426
+ for (const chunk of chunks) {
1427
+ await this.safeReply(
1428
+ binding,
1429
+ `\u{1F464} <b>Human</b> \u{1F4AC} <i>local request:</i><br>${escapeHtml(chunk).replace(/\n/g, "<br>")}`
1430
+ );
1431
+ }
1432
+ }
1433
+ /**
1434
+ * Close out a dispatch once the agent has gone idle: flush any residual text,
1435
+ * drop the pending record, and resolve the dispatch promise so the per-agent
1436
+ * queue advances. Event mirroring stays on for the agent's whole online lifetime
1437
+ * (it's toggled in register/reconnect ↔ goOffline), so it is NOT disabled here.
1438
+ */
1439
+ async finalizeDispatch(agentId) {
1440
+ const rec = this.pending.get(agentId);
1441
+ if (!rec) return;
1442
+ this.cancelSettle(rec);
1443
+ this.pending.delete(agentId);
1444
+ await this.flushTurn(rec);
1445
+ await this.maybeNotifyComplete(rec);
1446
+ rec.resolve();
1447
+ }
1448
+ /**
1449
+ * Post a single end-of-response notification via the distinct-identity {@link
1450
+ * TeamsServiceDeps.notifier} (relay/Dump channel) so a Power Automate flow re-posts it
1451
+ * under the Flow-bot identity with an @mention. Active only when a notifier is wired and
1452
+ * {@link TeamsServiceDeps.isNotifyActive} is true. Skips silently when the dispatch
1453
+ * produced no text. Never throws — a notification failure must not wedge the queue.
1454
+ */
1455
+ async maybeNotifyComplete(rec) {
1456
+ const notifier = this.deps.notifier;
1457
+ if (!notifier || !(this.deps.isNotifyActive?.() ?? false)) return;
1458
+ if (!rec.lastReplyText) return;
1459
+ const title = (rec.binding.sessionTitle || "").trim();
1460
+ const where = title ? ` in \u201C${escapeHtml(title)}\u201D` : "";
1461
+ const html = `${this.agentLabel(rec.binding)} has finished responding${where}`;
1462
+ try {
1463
+ const posted = await notifier.replyToThread({
1464
+ teamId: rec.binding.teamId,
1465
+ channelId: rec.binding.channelId,
1466
+ threadRootId: rec.binding.threadRootId,
1467
+ html
1468
+ });
1469
+ if (posted?.messageId) this.rememberPosted(posted.messageId);
1470
+ } catch (e) {
1471
+ twarn("completion notify failed:", e.message);
1472
+ }
1473
+ }
1474
+ async maybeCheckIn(rec, toolName) {
1475
+ const settings = this.deps.getSettings();
1476
+ if (!settings.checkInEnabled) return;
1477
+ const t = this.now();
1478
+ if (t - rec.startedAt < settings.checkInThresholdMs) return;
1479
+ if (t - rec.lastCheckIn < settings.checkInThrottleMs) return;
1480
+ rec.lastCheckIn = t;
1481
+ const label = toolName ? ` (running: ${escapeHtml(toolName)})` : "";
1482
+ await this.safeReply(rec.binding, `${this.agentLabel(rec.binding)} \u23F3 Still working\u2026${label}`);
1483
+ }
1484
+ /**
1485
+ * Bold agent-name prefix for every app-posted message. Since replies are posted
1486
+ * under the operator's own Teams identity, this makes automated agent output
1487
+ * visually distinct from messages the operator typed by hand.
1488
+ */
1489
+ agentLabel(binding) {
1490
+ return `\u{1F916} <b>${escapeHtml(binding.displayName)}</b>`;
1491
+ }
1492
+ async postReply(binding, text) {
1493
+ const prefix = this.agentLabel(binding);
1494
+ const { text: cleaned, paths } = extractImageMarkers(text);
1495
+ let images = [];
1496
+ if (paths.length) {
1497
+ tlog(`office-image: @${binding.handle} reply has ${paths.length} sentinel(s): ${paths.join(", ")} (baseDir=${binding.workingDir})`);
1498
+ images = await loadHostedImages(paths, { baseDir: binding.workingDir, warn: (m) => twarn(m) });
1499
+ if (images.length) {
1500
+ tlog(`office-image: loaded ${images.length}/${paths.length} image(s) for @${binding.handle} \u2014 will attach inline.`);
1501
+ } else {
1502
+ twarn(`office-image: no images loaded for @${binding.handle} despite ${paths.length} sentinel(s) \u2014 all paths rejected (see warnings above).`);
1503
+ }
1504
+ }
1505
+ if (cleaned) {
1506
+ const chunks = chunkReply(cleaned, 3500);
1507
+ for (const chunk of chunks) {
1508
+ await this.safeReply(binding, `${prefix}<br>${escapeHtml(chunk).replace(/\n/g, "<br>")}`);
1509
+ }
1510
+ }
1511
+ if (images.length) {
1512
+ tlog(`office-image: posting ${images.length} inline image reply to @${binding.handle} thread.`);
1513
+ await this.safeReply(binding, `${prefix}<br>${hostedImagesHtml(images)}`, images);
1514
+ }
1515
+ }
1516
+ /** Reply to a thread, swallowing errors (logs only) so the queue keeps moving. */
1517
+ async safeReply(binding, html, hostedImages) {
1518
+ try {
1519
+ const posted = await this.deps.graph.replyToThread({
1520
+ teamId: binding.teamId,
1521
+ channelId: binding.channelId,
1522
+ threadRootId: binding.threadRootId,
1523
+ html,
1524
+ hostedImages
1525
+ });
1526
+ if (posted?.messageId) this.rememberPosted(posted.messageId);
1527
+ } catch (e) {
1528
+ twarn("replyToThread failed:", e.message);
1529
+ }
1530
+ }
1531
+ // ── Reconnect / teardown reconcile (FR-022/024) ──────────────
1532
+ /**
1533
+ * Run a reconcile pass on demand (e.g. right after the renderer re-attaches
1534
+ * terminal sessions on startup / office switch), so Teams bindings re-online
1535
+ * immediately instead of waiting for the next periodic tick. No-op until started.
1536
+ */
1537
+ async reconcileNow() {
1538
+ if (!this.started) return;
1539
+ await this.reconcile();
1540
+ }
1541
+ async reconcile() {
1542
+ let changed = false;
1543
+ for (const b of [...this.bindings]) {
1544
+ if (!this.started) return;
1545
+ let current = null;
1546
+ try {
1547
+ current = await this.deps.gateway.getSessionId(b.officeId, b.agentId);
1548
+ } catch {
1549
+ continue;
1550
+ }
1551
+ if (!this.started) return;
1552
+ if (b.online) {
1553
+ if (current !== b.sessionId) {
1554
+ tlog(`Session changed for @${b.handle} (${b.sessionId} \u2192 ${current ?? "none"}) \u2014 taking offline (FR-022).`);
1555
+ await this.goOffline(b.officeId, b.agentId, true);
1556
+ changed = true;
1557
+ } else {
1558
+ b.lastConnected = this.now();
1559
+ }
1560
+ } else {
1561
+ if (current && current === b.sessionId) {
1562
+ const ready = await this.deps.gateway.isAgentReady(b.officeId, b.agentId).catch(() => false);
1563
+ if (!ready) continue;
1564
+ if (!this.started) return;
1565
+ b.online = true;
1566
+ b.lastConnected = this.now();
1567
+ this.deps.gateway.setForwarding(b.officeId, b.agentId, true);
1568
+ this.deps.emitStatus(this.toStatus(b));
1569
+ tlog(`Reconnected @${b.handle} to its persisted thread (session ${b.sessionId}).`);
1570
+ void this.safeReply(b, `${this.agentLabel(b)} \u{1F504} Reconnected \u2014 back online and ready. Reply here to continue.`);
1571
+ changed = true;
1572
+ }
1573
+ }
1574
+ }
1575
+ if (changed) {
1576
+ await this.persist();
1577
+ this.updateSourceChannels();
1578
+ }
1579
+ }
1580
+ async onSessionExit(agentId) {
1581
+ const b = this.bindings.find((x) => x.agentId === agentId && x.online);
1582
+ if (b) {
1583
+ tlog(`Session exited for @${b.handle} \u2014 taking offline.`);
1584
+ await this.goOffline(b.officeId, b.agentId, true);
1585
+ }
1586
+ }
1587
+ // ── Helpers ──────────────────────────────────────────────────
1588
+ buildIntro(info, handle, sessionTitle) {
1589
+ const lines = [
1590
+ `<p>\u{1F7E2} <b>${escapeHtml(info.displayName)}</b> is now online via Copilot Office.</p>`,
1591
+ `<p>Reply in this thread to talk to the agent. Send <code>/stop</code> to take it offline.</p>`,
1592
+ `<ul>`,
1593
+ `<li><b>Handle:</b> ${escapeHtml(handle)}</li>`,
1594
+ `<li><b>Folder:</b> ${escapeHtml(info.workingDir)}</li>`
1595
+ ];
1596
+ if (sessionTitle) lines.push(`<li><b>Session:</b> ${escapeHtml(sessionTitle)}</li>`);
1597
+ lines.push(`</ul>`);
1598
+ return lines.join("");
1599
+ }
1600
+ toStatus(b) {
1601
+ return {
1602
+ agentId: b.agentId,
1603
+ officeId: b.officeId,
1604
+ online: b.online,
1605
+ handle: b.handle,
1606
+ threadWebUrl: b.threadWebUrl,
1607
+ health: b.online ? this.deps.source.health : "disconnected"
1608
+ };
1609
+ }
1610
+ findBinding(officeId, agentId) {
1611
+ return this.bindings.find((b) => b.officeId === officeId && b.agentId === agentId);
1612
+ }
1613
+ onlineHandles() {
1614
+ return new Set(this.bindings.filter((b) => b.online).map((b) => b.handle));
1615
+ }
1616
+ rememberThread(threadRootId) {
1617
+ if (!this.knownThreads.some((t) => t.threadRootId === threadRootId)) {
1618
+ this.knownThreads.push({ threadRootId, noticePosted: false });
1619
+ }
1620
+ }
1621
+ /** Record a message id the app posted, so its Trouter echo is dropped (D9). Capped FIFO. */
1622
+ rememberPosted(messageId) {
1623
+ if (!messageId || this.postedMessageIds.has(messageId)) return;
1624
+ this.postedMessageIds.add(messageId);
1625
+ this.postedOrder.push(messageId);
1626
+ if (this.postedOrder.length > _TeamsService.MAX_POSTED_IDS) {
1627
+ const old = this.postedOrder.shift();
1628
+ if (old) this.postedMessageIds.delete(old);
1629
+ }
1630
+ }
1631
+ coordsForChannel(channelId) {
1632
+ const b = this.bindings.find((x) => x.channelId === channelId);
1633
+ if (b) return { teamId: b.teamId, tenantId: b.tenantId };
1634
+ return null;
1635
+ }
1636
+ /** Push the current active-channel set to a pollable source (chatsvc fallback). */
1637
+ updateSourceChannels() {
1638
+ const src = this.deps.source;
1639
+ if (typeof src.setChannels === "function") {
1640
+ src.setChannels([...activeChannelSet(this.bindings)]);
1641
+ }
1642
+ }
1643
+ async persist() {
1644
+ await this.deps.store.save({ bindings: this.bindings, knownThreads: this.knownThreads });
1645
+ }
1646
+ };
1647
+ function truncate(text, max) {
1648
+ const oneLine = (text ?? "").replace(/\s+/g, " ").trim();
1649
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max)}\u2026`;
1650
+ }
1651
+
1652
+ // electron/teams/auth.ts
1653
+ var import_child_process3 = require("child_process");
1654
+ var RESOURCE_URLS = {
1655
+ graph: "https://graph.microsoft.com",
1656
+ ic3: "https://ic3.teams.office.com"
1657
+ };
1658
+ var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
1659
+ function decodeJwtExpMs(token) {
1660
+ try {
1661
+ const parts = token.split(".");
1662
+ if (parts.length < 2) return 0;
1663
+ const payload = JSON.parse(
1664
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8")
1665
+ );
1666
+ const exp = Number(payload?.exp);
1667
+ return Number.isFinite(exp) ? exp * 1e3 : 0;
1668
+ } catch {
1669
+ return 0;
1670
+ }
1671
+ }
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
+
1755
+ // electron/teams/tokenCacheStore.ts
1756
+ var fs5 = __toESM(require("fs"));
1757
+ var path6 = __toESM(require("path"));
1758
+ var RESOURCES = ["graph", "ic3"];
1759
+ function createSafeStorageTokenPersistence(filePath, safeStorage2, opts = {}) {
1760
+ const fsLike = opts.fsLike ?? fs5;
1761
+ const now = opts.now ?? Date.now;
1762
+ return {
1763
+ load() {
1764
+ try {
1765
+ if (!safeStorage2.isEncryptionAvailable()) return {};
1766
+ if (!fsLike.existsSync(filePath)) return {};
1767
+ const buf = fsLike.readFileSync(filePath);
1768
+ const json = safeStorage2.decryptString(buf);
1769
+ const parsed = JSON.parse(json);
1770
+ const out = {};
1771
+ for (const res of RESOURCES) {
1772
+ const ct = parsed?.[res];
1773
+ if (ct && typeof ct.token === "string" && ct.token && typeof ct.expiresAt === "number" && ct.expiresAt > now()) {
1774
+ out[res] = { token: ct.token, expiresAt: ct.expiresAt };
1775
+ }
1776
+ }
1777
+ return out;
1778
+ } catch {
1779
+ try {
1780
+ fsLike.rmSync(filePath, { force: true });
1781
+ } catch {
1782
+ }
1783
+ return {};
1784
+ }
1785
+ },
1786
+ save(all) {
1787
+ try {
1788
+ if (!safeStorage2.isEncryptionAvailable()) return;
1789
+ const json = JSON.stringify(all);
1790
+ const enc = safeStorage2.encryptString(json);
1791
+ fsLike.mkdirSync(path6.dirname(filePath), { recursive: true });
1792
+ fsLike.writeFileSync(filePath, enc);
1793
+ } catch {
1794
+ }
1795
+ }
1796
+ };
1797
+ }
1798
+
1799
+ // electron/teams/marker.ts
1800
+ var TEAMS_MARKER = "copilotoffice-agent-post-v1";
1801
+ var ZW_MARKER = "\u200B\u200C\u200D\u200B\u200C\u200D";
1802
+ function embedMarker(html) {
1803
+ if (hasMarker(html)) return html;
1804
+ const body = html ?? "";
1805
+ const firstTag = body.match(/^\s*<[a-zA-Z][^>]*>/);
1806
+ if (firstTag) {
1807
+ const insertAt = firstTag.index + firstTag[0].length;
1808
+ return body.slice(0, insertAt) + ZW_MARKER + body.slice(insertAt);
1809
+ }
1810
+ return ZW_MARKER + body;
1811
+ }
1812
+ function hasMarker(content) {
1813
+ if (!content) return false;
1814
+ return content.includes(ZW_MARKER) || content.includes(TEAMS_MARKER);
1815
+ }
1816
+
1817
+ // electron/teams/graphClient.ts
1818
+ function buildHostedContents(images) {
1819
+ if (!images || images.length === 0) return void 0;
1820
+ return images.map((img) => ({
1821
+ "@microsoft.graph.temporaryId": img.id,
1822
+ contentBytes: img.contentBytesBase64,
1823
+ contentType: img.contentType
1824
+ }));
1825
+ }
1826
+ var GRAPH_BASE = "https://graph.microsoft.com/v1.0";
1827
+ var GraphClient = class {
1828
+ constructor(tokens) {
1829
+ this.tokens = tokens;
1830
+ }
1831
+ async authHeaders() {
1832
+ const token = await this.tokens.getToken("graph");
1833
+ return {
1834
+ Authorization: `Bearer ${token}`,
1835
+ "Content-Type": "application/json"
1836
+ };
1837
+ }
1838
+ async createThread(p) {
1839
+ const url = `${GRAPH_BASE}/teams/${encodeURIComponent(p.teamId)}/channels/${encodeURIComponent(
1840
+ p.channelId
1841
+ )}/messages`;
1842
+ const body = {
1843
+ subject: p.subject,
1844
+ body: { contentType: "html", content: embedMarker(p.html) }
1845
+ };
1846
+ const hostedContents = buildHostedContents(p.hostedImages);
1847
+ if (hostedContents) body.hostedContents = hostedContents;
1848
+ const res = await fetch(url, {
1849
+ method: "POST",
1850
+ headers: await this.authHeaders(),
1851
+ body: JSON.stringify(body)
1852
+ });
1853
+ if (!res.ok) {
1854
+ throw new Error(`Graph createThread failed: ${res.status} ${await safeText(res)}`);
1855
+ }
1856
+ const json = await res.json();
1857
+ if (!json.id) throw new Error("Graph createThread: response missing message id");
1858
+ return { threadRootId: json.id, webUrl: json.webUrl || "" };
1859
+ }
1860
+ async replyToThread(p) {
1861
+ const url = `${GRAPH_BASE}/teams/${encodeURIComponent(p.teamId)}/channels/${encodeURIComponent(
1862
+ p.channelId
1863
+ )}/messages/${encodeURIComponent(p.threadRootId)}/replies`;
1864
+ const body = { body: { contentType: "html", content: embedMarker(p.html) } };
1865
+ const hostedContents = buildHostedContents(p.hostedImages);
1866
+ if (hostedContents) body.hostedContents = hostedContents;
1867
+ const res = await fetch(url, {
1868
+ method: "POST",
1869
+ headers: await this.authHeaders(),
1870
+ body: JSON.stringify(body)
1871
+ });
1872
+ if (!res.ok) {
1873
+ throw new Error(`Graph replyToThread failed: ${res.status} ${await safeText(res)}`);
1874
+ }
1875
+ const json = await res.json();
1876
+ return { messageId: json.id || "" };
1877
+ }
1878
+ async listChannels(teamId) {
1879
+ const url = `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/channels`;
1880
+ const res = await fetch(url, { headers: await this.authHeaders() });
1881
+ if (!res.ok) throw new Error(`Graph listChannels failed: ${res.status}`);
1882
+ const json = await res.json();
1883
+ return json.value || [];
1884
+ }
1885
+ /** List a team's tags (id + displayName) — used to resolve a tag name to its tagId. */
1886
+ async listTags(teamId) {
1887
+ const url = `${GRAPH_BASE}/teams/${encodeURIComponent(teamId)}/tags?$select=id,displayName`;
1888
+ const res = await fetch(url, { headers: await this.authHeaders() });
1889
+ if (!res.ok) throw new Error(`Graph listTags failed: ${res.status} ${await safeText(res)}`);
1890
+ const json = await res.json();
1891
+ return json.value || [];
1892
+ }
1893
+ /**
1894
+ * Resolve a user reference to an AAD object id. A UPN (contains '@') or a GUID is
1895
+ * looked up / passed through directly; anything else is treated as a display name and
1896
+ * matched (exact, case-insensitive) against Graph. Returns '' when unresolved.
1897
+ */
1898
+ async findUserId(query) {
1899
+ const q = query.trim();
1900
+ if (!q) return "";
1901
+ const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(q);
1902
+ const isUpn = q.includes("@");
1903
+ if (isGuid || isUpn) {
1904
+ const url2 = `${GRAPH_BASE}/users/${encodeURIComponent(q)}?$select=id`;
1905
+ const res2 = await fetch(url2, { headers: await this.authHeaders() });
1906
+ if (!res2.ok) return "";
1907
+ const json2 = await res2.json();
1908
+ return json2.id || "";
1909
+ }
1910
+ const filter = `displayName eq '${q.replace(/'/g, "''")}'`;
1911
+ const url = `${GRAPH_BASE}/users?$filter=${encodeURIComponent(filter)}&$select=id&$top=1`;
1912
+ const res = await fetch(url, { headers: await this.authHeaders() });
1913
+ if (!res.ok) return "";
1914
+ const json = await res.json();
1915
+ return json.value?.[0]?.id || "";
1916
+ }
1917
+ };
1918
+ async function safeText(res) {
1919
+ try {
1920
+ return (await res.text()).slice(0, 300);
1921
+ } catch {
1922
+ return "";
1923
+ }
1924
+ }
1925
+
1926
+ // electron/teams/trouterClient.ts
1927
+ var import_ws = __toESM(require("ws"));
1928
+ var crypto3 = __toESM(require("crypto"));
1929
+ var DEFAULT_GATEWAY = "go-msit.trouter.teams.microsoft.com";
1930
+ var V2_REGISTRAR = "https://teams.cloud.microsoft/registrar/prod/V2/registrations";
1931
+ var ORIGIN = "https://teams.cloud.microsoft";
1932
+ var HEARTBEAT_MS = 3e4;
1933
+ var REREGISTER_MS = 45 * 60 * 1e3;
1934
+ function extractThreadRootId(conversationLink) {
1935
+ if (!conversationLink) return "";
1936
+ const m = /;messageid=([^;&/?]+)/i.exec(conversationLink);
1937
+ return m ? m[1] : "";
1938
+ }
1939
+ function extractChannelId(conversationLink) {
1940
+ if (!conversationLink) return "";
1941
+ let convo = conversationLink;
1942
+ const idx = convo.indexOf("/conversations/");
1943
+ if (idx >= 0) convo = convo.slice(idx + "/conversations/".length);
1944
+ convo = convo.split(";")[0];
1945
+ try {
1946
+ convo = decodeURIComponent(convo);
1947
+ } catch {
1948
+ }
1949
+ return convo;
1950
+ }
1951
+ function parseFrame(raw) {
1952
+ if (raw.startsWith("1::")) return { type: "connect" };
1953
+ if (raw.startsWith("5")) {
1954
+ const m = /^5(?::[^:]*)?::([\s\S]*)$/.exec(raw);
1955
+ if (m) {
1956
+ try {
1957
+ return { type: "event", payload: JSON.parse(m[1]) };
1958
+ } catch {
1959
+ return { type: "event", payload: m[1] };
1960
+ }
1961
+ }
1962
+ }
1963
+ if (raw.startsWith("3:::")) {
1964
+ try {
1965
+ return { type: "message", payload: JSON.parse(raw.slice(4)) };
1966
+ } catch {
1967
+ return { type: "message", payload: raw.slice(4) };
1968
+ }
1969
+ }
1970
+ if (raw.startsWith("6:::")) return { type: "ack", payload: raw.slice(4) };
1971
+ return { type: "unknown", payload: raw };
1972
+ }
1973
+ function parseEventMessage(body) {
1974
+ let data = body;
1975
+ if (typeof data === "string") {
1976
+ try {
1977
+ data = JSON.parse(data);
1978
+ } catch {
1979
+ return null;
1980
+ }
1981
+ }
1982
+ const d = data;
1983
+ if (!d || d.type !== "EventMessage" || d.resourceType !== "NewMessage") return null;
1984
+ const resource = d.resource || {};
1985
+ const convLink = String(resource.conversationLink || "");
1986
+ const content = String(resource.content || "");
1987
+ return {
1988
+ messageId: String(resource.id || ""),
1989
+ channelId: extractChannelId(convLink),
1990
+ threadRootId: extractThreadRootId(convLink),
1991
+ senderName: String(resource.imdisplayname || ""),
1992
+ senderId: String(resource.from || ""),
1993
+ content: stripHtml(content),
1994
+ composeTime: String(resource.composetime || ""),
1995
+ hasMarker: hasMarker(content)
1996
+ };
1997
+ }
1998
+ var TrouterClient = class {
1999
+ constructor(tokens, gateway = DEFAULT_GATEWAY) {
2000
+ this.tokens = tokens;
2001
+ this.gateway = gateway;
2002
+ this.health = "disconnected";
2003
+ this.ws = null;
2004
+ this.running = false;
2005
+ this.frameCounter = 0;
2006
+ this.surl = "";
2007
+ this.registrarUrl = "";
2008
+ this.registrationId = "";
2009
+ this.heartbeatTimer = null;
2010
+ this.reregisterTimer = null;
2011
+ this.reconnectTimer = null;
2012
+ this.onMessage = null;
2013
+ }
2014
+ async start(onMessage) {
2015
+ this.onMessage = onMessage;
2016
+ this.running = true;
2017
+ await this.connect();
2018
+ }
2019
+ async stop() {
2020
+ this.running = false;
2021
+ this.clearTimers();
2022
+ if (this.ws) {
2023
+ try {
2024
+ this.ws.close();
2025
+ } catch {
2026
+ }
2027
+ this.ws = null;
2028
+ }
2029
+ this.health = "disconnected";
2030
+ }
2031
+ clearTimers() {
2032
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2033
+ if (this.reregisterTimer) clearInterval(this.reregisterTimer);
2034
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2035
+ this.heartbeatTimer = this.reregisterTimer = null;
2036
+ this.reconnectTimer = null;
2037
+ }
2038
+ scheduleReconnect() {
2039
+ if (!this.running || this.reconnectTimer) return;
2040
+ this.reconnectTimer = setTimeout(() => {
2041
+ this.reconnectTimer = null;
2042
+ if (this.running) this.connect().catch((e) => twarn("Trouter reconnect failed:", e.message));
2043
+ }, 5e3);
2044
+ }
2045
+ async connect() {
2046
+ const token = await this.tokens.getToken("ic3");
2047
+ const epid = crypto3.randomUUID();
2048
+ this.registrationId = epid;
2049
+ const tc = JSON.stringify({ cv: "2026.03.01.1", ua: "TeamsCDL", hr: "", v: "1415/26020101120" });
2050
+ const qs = new URLSearchParams({
2051
+ tc,
2052
+ timeout: "40",
2053
+ epid,
2054
+ ccid: "",
2055
+ dom: "teams.cloud.microsoft",
2056
+ cor_id: crypto3.randomUUID(),
2057
+ con_num: `${Date.now()}_0`
2058
+ });
2059
+ const wsUrl = `wss://${this.gateway}/v4/c?${qs.toString()}`;
2060
+ const ws = new import_ws.default(wsUrl, { headers: { Origin: ORIGIN }, maxPayload: 2 ** 22 });
2061
+ this.ws = ws;
2062
+ ws.on("open", () => {
2063
+ });
2064
+ ws.on("message", (data) => {
2065
+ this.handleRaw(String(data), token, epid).catch(
2066
+ (e) => twarn("Trouter frame handler error:", e.message)
2067
+ );
2068
+ });
2069
+ ws.on("error", (err) => {
2070
+ this.health = "error";
2071
+ twarn("Trouter socket error:", err.message);
2072
+ });
2073
+ ws.on("close", () => {
2074
+ this.health = "disconnected";
2075
+ this.clearTimers();
2076
+ this.scheduleReconnect();
2077
+ });
2078
+ }
2079
+ send(frame) {
2080
+ if (this.ws && this.ws.readyState === import_ws.default.OPEN) this.ws.send(frame);
2081
+ }
2082
+ async handleRaw(raw, token, epid) {
2083
+ const { type, payload } = parseFrame(raw);
2084
+ if (type === "connect") {
2085
+ const now = Math.floor(Date.now() / 1e3);
2086
+ const auth = JSON.stringify({
2087
+ name: "user.authenticate",
2088
+ args: [
2089
+ {
2090
+ headers: {
2091
+ "X-Ms-Test-User": "False",
2092
+ Authorization: `Bearer ${token}`,
2093
+ "X-MS-Migration": "True"
2094
+ },
2095
+ connectparams: {
2096
+ issuer: "",
2097
+ scae: "1",
2098
+ sig: "",
2099
+ sr: epid,
2100
+ sp: "",
2101
+ se: String((now + 600) * 1e3),
2102
+ st: String(now * 1e3)
2103
+ }
2104
+ }
2105
+ ]
2106
+ });
2107
+ this.send(`5:::${auth}`);
2108
+ return;
2109
+ }
2110
+ if (type === "event" && payload && typeof payload === "object") {
2111
+ const evt = payload;
2112
+ if (evt.name === "trouter.connected") {
2113
+ const info = evt.args?.[0] || {};
2114
+ this.surl = info.surl || "";
2115
+ this.registrarUrl = info.registrarUrl || V2_REGISTRAR;
2116
+ this.health = "connected";
2117
+ tlog("Trouter connected \u2014 registering for channel push\u2026");
2118
+ await this.register(token);
2119
+ this.startHeartbeat(token);
2120
+ return;
2121
+ }
2122
+ if (evt.name === "ping") {
2123
+ this.frameCounter += 1;
2124
+ this.send(`6:::${this.frameCounter}["pong"]`);
2125
+ return;
2126
+ }
2127
+ if (evt.name === "trouter.message_loss") {
2128
+ this.frameCounter += 1;
2129
+ const ack = JSON.stringify({ name: "trouter.processed_message_loss", args: evt.args || [{}] });
2130
+ this.send(`5:${this.frameCounter}+::${ack}`);
2131
+ return;
2132
+ }
2133
+ return;
2134
+ }
2135
+ if (type === "message" && payload && typeof payload === "object") {
2136
+ const msg = payload;
2137
+ if (msg.id) {
2138
+ const ack = JSON.stringify({ id: msg.id, status: 200, headers: {}, body: "" });
2139
+ this.send(`3:::${ack}`);
2140
+ }
2141
+ const inbound = parseEventMessage(msg.body);
2142
+ if (!inbound || !this.onMessage) return;
2143
+ this.onMessage(inbound);
2144
+ }
2145
+ }
2146
+ startHeartbeat(token) {
2147
+ this.clearTimers();
2148
+ this.heartbeatTimer = setInterval(() => {
2149
+ this.frameCounter += 1;
2150
+ this.send(`5:${this.frameCounter}+::{"name":"ping"}`);
2151
+ }, HEARTBEAT_MS);
2152
+ this.reregisterTimer = setInterval(() => {
2153
+ this.register(token).catch((e) => twarn("Trouter re-register failed:", e.message));
2154
+ }, REREGISTER_MS);
2155
+ }
2156
+ async register(token) {
2157
+ const payload = {
2158
+ clientDescription: {
2159
+ appId: "TeamsCDLWebWorker",
2160
+ aesKey: "",
2161
+ languageId: "en-US",
2162
+ platform: "edge",
2163
+ templateKey: "TeamsCDLWebWorker_2.6",
2164
+ platformUIVersion: "1415/26020101120"
2165
+ },
2166
+ registrationId: this.registrationId,
2167
+ nodeId: "",
2168
+ transports: { TROUTER: [{ context: "", path: this.surl, ttl: 3600 }] }
2169
+ };
2170
+ const headers = {
2171
+ Authorization: `Bearer ${token}`,
2172
+ "Content-Type": "application/json",
2173
+ Origin: ORIGIN
2174
+ };
2175
+ const urls = Array.from(new Set([this.registrarUrl, V2_REGISTRAR].filter(Boolean)));
2176
+ for (const url of urls) {
2177
+ try {
2178
+ const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
2179
+ if (!res.ok && res.status !== 202) {
2180
+ twarn(`Registrar ${url.slice(0, 60)} returned ${res.status}`);
2181
+ }
2182
+ } catch (e) {
2183
+ twarn(`Registrar ${url.slice(0, 60)} failed:`, e.message);
2184
+ }
2185
+ }
2186
+ }
2187
+ };
2188
+
2189
+ // electron/teams/sessionGateway.ts
2190
+ var RelaySessionGateway = class {
2191
+ constructor(relay2) {
2192
+ this.relay = relay2;
2193
+ }
2194
+ getSessionId(officeId, agentId) {
2195
+ return this.relay.mainGetSessionId(officeId, agentId);
2196
+ }
2197
+ getSessionMeta(officeId, agentId) {
2198
+ return this.relay.mainGetSessionMeta(officeId, agentId);
2199
+ }
2200
+ isAgentReady(officeId, agentId) {
2201
+ return this.relay.mainIsAgentReady(officeId, agentId);
2202
+ }
2203
+ async submitPrompt(officeId, agentId, prompt, label) {
2204
+ const res = await this.relay.mainSubmitPrompt(officeId, agentId, prompt, label);
2205
+ if (!res.success) {
2206
+ throw new Error(res.error || `Failed to submit prompt to ${officeId}:${agentId}`);
2207
+ }
2208
+ }
2209
+ setForwarding(officeId, agentId, enabled) {
2210
+ this.relay.mainSetAgentForwarding(officeId, agentId, enabled);
2211
+ }
2212
+ onAgentEvent(cb) {
2213
+ const onCopilotEvent = (...args) => {
2214
+ const agentId = args[0];
2215
+ const event = args[1];
2216
+ if (event?.type === "assistant.message") {
2217
+ const content = extractMessageContent(event);
2218
+ if (content) cb({ agentId, kind: "message", content });
2219
+ }
2220
+ };
2221
+ const onTurnStart = (...args) => cb({ agentId: args[0], kind: "turn-start" });
2222
+ const onTurnEnd = (...args) => cb({ agentId: args[0], kind: "turn-end" });
2223
+ const onToolStart = (...args) => cb({ agentId: args[0], kind: "tool-start", toolName: args[1] });
2224
+ const onUserMessage = (...args) => cb({ agentId: args[0], kind: "user-message", content: args[1] ?? "" });
2225
+ this.relay.mainEvents.on("copilot-event", onCopilotEvent);
2226
+ this.relay.mainEvents.on("copilot-turn-start", onTurnStart);
2227
+ this.relay.mainEvents.on("copilot-turn-end", onTurnEnd);
2228
+ this.relay.mainEvents.on("copilot-tool-start", onToolStart);
2229
+ this.relay.mainEvents.on("copilot-user-message", onUserMessage);
2230
+ return () => {
2231
+ this.relay.mainEvents.off("copilot-event", onCopilotEvent);
2232
+ this.relay.mainEvents.off("copilot-turn-start", onTurnStart);
2233
+ this.relay.mainEvents.off("copilot-turn-end", onTurnEnd);
2234
+ this.relay.mainEvents.off("copilot-tool-start", onToolStart);
2235
+ this.relay.mainEvents.off("copilot-user-message", onUserMessage);
2236
+ };
2237
+ }
2238
+ onSessionExit(cb) {
2239
+ const onExit = (...args) => cb(args[0]);
2240
+ this.relay.mainEvents.on("terminal-exit", onExit);
2241
+ return () => this.relay.mainEvents.off("terminal-exit", onExit);
2242
+ }
2243
+ };
2244
+ function extractMessageContent(event) {
2245
+ const data = event?.data ?? {};
2246
+ const content = data.content ?? data.text ?? data.message;
2247
+ if (typeof content === "string") return content;
2248
+ if (Array.isArray(content)) {
2249
+ return content.map((c) => typeof c === "string" ? c : c?.text).filter((s) => typeof s === "string").join("");
2250
+ }
2251
+ return "";
2252
+ }
2253
+
2254
+ // electron/teams/teamsSettingsStore.ts
2255
+ var fs6 = __toESM(require("fs"));
2256
+ var path7 = __toESM(require("path"));
2257
+ var DEFAULT_TEAMS_SETTINGS = {
2258
+ enabled: false,
2259
+ defaultChannelUrl: "",
2260
+ relayChannelUrl: "",
2261
+ relayMentionType: "none",
2262
+ relayMentionValue: "",
2263
+ notifyOnCompleteEnabled: true,
2264
+ ackEnabled: true,
2265
+ checkInEnabled: true,
2266
+ checkInThresholdMs: 12e4,
2267
+ checkInThrottleMs: 6e4
2268
+ };
2269
+ function normalizeTeamsSettings(partial) {
2270
+ return {
2271
+ enabled: partial?.enabled ?? DEFAULT_TEAMS_SETTINGS.enabled,
2272
+ defaultChannelUrl: partial?.defaultChannelUrl ?? DEFAULT_TEAMS_SETTINGS.defaultChannelUrl,
2273
+ relayChannelUrl: partial?.relayChannelUrl ?? DEFAULT_TEAMS_SETTINGS.relayChannelUrl,
2274
+ relayMentionType: partial?.relayMentionType ?? DEFAULT_TEAMS_SETTINGS.relayMentionType,
2275
+ relayMentionValue: partial?.relayMentionValue ?? DEFAULT_TEAMS_SETTINGS.relayMentionValue,
2276
+ notifyOnCompleteEnabled: partial?.notifyOnCompleteEnabled ?? DEFAULT_TEAMS_SETTINGS.notifyOnCompleteEnabled,
2277
+ ackEnabled: partial?.ackEnabled ?? DEFAULT_TEAMS_SETTINGS.ackEnabled,
2278
+ checkInEnabled: partial?.checkInEnabled ?? DEFAULT_TEAMS_SETTINGS.checkInEnabled,
2279
+ checkInThresholdMs: partial?.checkInThresholdMs ?? DEFAULT_TEAMS_SETTINGS.checkInThresholdMs,
2280
+ checkInThrottleMs: partial?.checkInThrottleMs ?? DEFAULT_TEAMS_SETTINGS.checkInThrottleMs
2281
+ };
2282
+ }
2283
+ function createTeamsSettingsStore(cwd = process.cwd()) {
2284
+ const dataDir = path7.join(cwd, ".data");
2285
+ const filePath = path7.join(dataDir, "teams-settings.json");
2286
+ return {
2287
+ filePath,
2288
+ load() {
2289
+ try {
2290
+ if (!fs6.existsSync(filePath)) return { ...DEFAULT_TEAMS_SETTINGS };
2291
+ return normalizeTeamsSettings(JSON.parse(fs6.readFileSync(filePath, "utf8")));
2292
+ } catch {
2293
+ return { ...DEFAULT_TEAMS_SETTINGS };
2294
+ }
2295
+ },
2296
+ save(settings) {
2297
+ fs6.mkdirSync(dataDir, { recursive: true });
2298
+ fs6.writeFileSync(filePath, JSON.stringify(normalizeTeamsSettings(settings), null, 2), "utf8");
2299
+ }
2300
+ };
2301
+ }
2302
+
2303
+ // electron/teams/channelAllowlist.ts
2304
+ function allowedChannelIdSet(defaultChannelUrl, overrideUrls = []) {
2305
+ const set = /* @__PURE__ */ new Set();
2306
+ for (const url of [defaultChannelUrl, ...overrideUrls]) {
2307
+ const trimmed = (url ?? "").trim();
2308
+ if (!trimmed) continue;
2309
+ const coords = parseChannelLink(trimmed);
2310
+ if (coords?.channelId) set.add(coords.channelId);
2311
+ }
2312
+ return set;
2313
+ }
2314
+ function createAllowlistedGraphSender(inner, getAllowed) {
2315
+ const assertAllowed = (channelId, op) => {
2316
+ if (!getAllowed().has(channelId)) {
2317
+ throw new Error(
2318
+ `Teams outbound blocked: channel ${channelId} is not in the settings/overrides allowlist (${op}).`
2319
+ );
2320
+ }
2321
+ };
2322
+ const wrapped = {
2323
+ createThread: async (p) => {
2324
+ assertAllowed(p.channelId, "createThread");
2325
+ return inner.createThread(p);
2326
+ },
2327
+ replyToThread: async (p) => {
2328
+ assertAllowed(p.channelId, "replyToThread");
2329
+ return inner.replyToThread(p);
2330
+ }
2331
+ };
2332
+ if (inner.listChannels) {
2333
+ wrapped.listChannels = (teamId) => inner.listChannels(teamId);
2334
+ }
2335
+ return wrapped;
2336
+ }
2337
+ function officeChannelOverridesFromJson(json) {
2338
+ if (!json) return [];
2339
+ try {
2340
+ const record = JSON.parse(json);
2341
+ const offices = Array.isArray(record?.offices) ? record.offices : [];
2342
+ const urls = [];
2343
+ for (const o of offices) {
2344
+ const u = o?.teamsChannelUrl;
2345
+ if (typeof u === "string" && u.trim()) urls.push(u.trim());
2346
+ }
2347
+ return urls;
2348
+ } catch {
2349
+ return [];
2350
+ }
2351
+ }
2352
+ function createCachedAllowedChannels(compute, ttlMs = 2e3, now = Date.now) {
2353
+ let cached = null;
2354
+ let computedAt = 0;
2355
+ return () => {
2356
+ const t = now();
2357
+ if (cached && t - computedAt < ttlMs) return cached;
2358
+ try {
2359
+ cached = compute();
2360
+ computedAt = t;
2361
+ } catch {
2362
+ if (!cached) cached = /* @__PURE__ */ new Set();
2363
+ }
2364
+ return cached;
2365
+ };
2366
+ }
2367
+
2368
+ // electron/teams/relaySender.ts
2369
+ var META_OPEN = "[[CO-META]]";
2370
+ var META_CLOSE = "[[/CO-META]]";
2371
+ function stripMetaMarkers(html) {
2372
+ let out = html;
2373
+ let prev;
2374
+ do {
2375
+ prev = out;
2376
+ out = out.split(META_OPEN).join("").split(META_CLOSE).join("");
2377
+ } while (out !== prev);
2378
+ return out;
2379
+ }
2380
+ function encodeMetaBlock(meta) {
2381
+ const b64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64");
2382
+ return `<p>${META_OPEN}${b64}${META_CLOSE}</p>`;
2383
+ }
2384
+ function createRelaySender(opts) {
2385
+ const resolveDump = () => {
2386
+ const url = opts.getDumpChannelUrl().trim();
2387
+ if (!url) throw new Error("Teams Dump channel URL is not configured.");
2388
+ const coords = parseChannelLink(url);
2389
+ if (!coords) throw new Error("Teams Dump channel URL could not be parsed.");
2390
+ return { teamId: coords.teamId, channelId: coords.channelId };
2391
+ };
2392
+ const postToDump = async (dest, title, html, hostedImages) => {
2393
+ if (!opts.isDestinationAllowed(dest.channelId)) {
2394
+ throw new Error(
2395
+ `Teams outbound blocked: destination channel ${dest.channelId} is not in the allowlist (relay).`
2396
+ );
2397
+ }
2398
+ const dump = resolveDump();
2399
+ let resolved = { mentionType: "none", mentionId: "" };
2400
+ const ref = opts.getMention();
2401
+ if (ref && ref.type !== "none" && ref.value.trim()) {
2402
+ try {
2403
+ resolved = await opts.resolveMention({ type: ref.type, value: ref.value.trim() }, dest.teamId);
2404
+ } catch (err) {
2405
+ tlog(`Relay mention resolution failed (${err.message}); posting without mention.`);
2406
+ resolved = { mentionType: "none", mentionId: "" };
2407
+ }
2408
+ }
2409
+ const humanHtml = embedMarker(stripMetaMarkers(html));
2410
+ const meta = {
2411
+ v: 1,
2412
+ destTeamId: dest.teamId,
2413
+ destChannelId: dest.channelId,
2414
+ threadRootId: dest.threadRootId,
2415
+ mentionType: resolved.mentionType,
2416
+ mentionId: resolved.mentionId,
2417
+ title,
2418
+ html: humanHtml
2419
+ };
2420
+ const body = `${humanHtml}${encodeMetaBlock(meta)}`;
2421
+ await opts.primary.createThread({
2422
+ teamId: dump.teamId,
2423
+ channelId: dump.channelId,
2424
+ subject: "CopilotOffice",
2425
+ html: body,
2426
+ hostedImages
2427
+ });
2428
+ tlog(`Relay post to Dump channel ok (dest=${dest.channelId}, thread=${dest.threadRootId || "root"}, mention=${resolved.mentionType}).`);
2429
+ };
2430
+ return {
2431
+ async createThread(p) {
2432
+ await postToDump({ teamId: p.teamId, channelId: p.channelId, threadRootId: "" }, p.subject, p.html, p.hostedImages);
2433
+ return { threadRootId: "", webUrl: "" };
2434
+ },
2435
+ async replyToThread(p) {
2436
+ await postToDump(
2437
+ { teamId: p.teamId, channelId: p.channelId, threadRootId: p.threadRootId },
2438
+ "",
2439
+ p.html,
2440
+ p.hostedImages
2441
+ );
2442
+ return { messageId: "" };
2443
+ }
2444
+ };
2445
+ }
2446
+
2447
+ // electron/teams/teamsIpc.ts
2448
+ var import_electron3 = require("electron");
2449
+ function registerTeamsIpc(hooks) {
2450
+ const { service, settingsStore } = hooks;
2451
+ import_electron3.ipcMain.handle("teams:status", (_e, args) => {
2452
+ if (args?.officeId && args?.agentId) {
2453
+ const status = service.getStatus(args.officeId, args.agentId);
2454
+ return { success: true, connected: !!status?.online, bindings: status ? [status] : [] };
2455
+ }
2456
+ const bindings = service.getStatuses();
2457
+ return { success: true, connected: bindings.some((b) => b.online), bindings };
2458
+ });
2459
+ import_electron3.ipcMain.handle("teams:register", (_e, ctx) => service.register(ctx));
2460
+ import_electron3.ipcMain.handle(
2461
+ "teams:stop",
2462
+ (_e, args) => service.goOffline(args.officeId, args.agentId, true)
2463
+ );
2464
+ import_electron3.ipcMain.handle("teams:reconcile", async () => {
2465
+ await service.reconcileNow();
2466
+ return { success: true };
2467
+ });
2468
+ import_electron3.ipcMain.handle("teams:getSettings", () => {
2469
+ return { success: true, settings: settingsStore.load() };
2470
+ });
2471
+ import_electron3.ipcMain.handle("teams:saveSettings", (_e, args) => {
2472
+ const settings = normalizeTeamsSettings(args?.settings);
2473
+ let parsed;
2474
+ if (settings.defaultChannelUrl.trim()) {
2475
+ const coords = parseChannelLink(settings.defaultChannelUrl);
2476
+ if (!coords) {
2477
+ return { success: false, error: "The default channel link could not be parsed." };
2478
+ }
2479
+ parsed = coords;
2480
+ }
2481
+ if (settings.relayChannelUrl.trim()) {
2482
+ if (!parseChannelLink(settings.relayChannelUrl)) {
2483
+ return { success: false, error: "The relay trigger channel link could not be parsed." };
2484
+ }
2485
+ }
2486
+ settingsStore.save(settings);
2487
+ hooks.onSettingsChanged?.(settings);
2488
+ return { success: true, parsed };
2489
+ });
2490
+ }
2491
+ function makeStatusEmitter(getWindow) {
2492
+ return (status) => {
2493
+ const win = getWindow();
2494
+ if (win && !win.isDestroyed()) win.webContents.send("teams:status:changed", status);
2495
+ };
2496
+ }
2497
+ function makeToastEmitter(getWindow) {
2498
+ return (toast) => {
2499
+ const win = getWindow();
2500
+ if (win && !win.isDestroyed()) win.webContents.send("teams:toast", toast);
2501
+ };
2502
+ }
2503
+
2504
+ // electron/main.ts
2505
+ var OPEN_DEVTOOLS_ON_START = process.env.COPILOT_OFFICE_OPEN_DEVTOOLS !== "0";
2506
+ var ENABLE_FILE_WATCHER = process.env.COPILOT_OFFICE_ENABLE_WATCHER !== "0";
2507
+ function killOrphanedProcesses() {
2508
+ try {
2509
+ const { reaped, skipped } = reapRegisteredPtys();
2510
+ if (reaped.length > 0) {
2511
+ console.log(`[Main] Reaped ${reaped.length} orphaned PTY process tree(s):`, reaped);
2512
+ }
2513
+ if (skipped.length > 0) {
2514
+ console.log(`[Main] Skipped ${skipped.length} already-dead PTY record(s) from registry`);
2515
+ }
2516
+ } catch {
2517
+ }
2518
+ }
2519
+ var mainWindow = null;
2520
+ var watcherProcess = null;
2521
+ var relay = new TerminalRelay(() => mainWindow);
2522
+ var teamsService = null;
2523
+ var pendingHardReload = false;
2524
+ function startFileWatcher() {
2525
+ const copilotOfficePath = path8.join(process.cwd(), "CopilotOffice");
2526
+ if (!fs7.existsSync(path8.join(copilotOfficePath, "package.json"))) {
2527
+ if (fs7.existsSync(path8.join(process.cwd(), "src", "main.ts"))) {
2528
+ watcherProcess = (0, import_child_process4.spawn)("npx", ["esbuild", "src/main.ts", "--bundle", "--outfile=dist/game.bundle.js", "--platform=browser", "--format=iife", "--global-name=CopilotOffice", "--watch"], {
2529
+ cwd: process.cwd(),
2530
+ shell: true,
2531
+ stdio: "pipe"
2532
+ });
2533
+ }
2534
+ } else {
2535
+ watcherProcess = (0, import_child_process4.spawn)("npx", ["esbuild", "src/main.ts", "--bundle", "--outfile=dist/game.bundle.js", "--platform=browser", "--format=iife", "--global-name=CopilotOffice", "--watch"], {
2536
+ cwd: copilotOfficePath,
2537
+ shell: true,
2538
+ stdio: "pipe"
2539
+ });
2540
+ }
2541
+ if (watcherProcess) {
2542
+ watcherProcess.stdout?.on("data", (data) => {
2543
+ const msg = data.toString();
2544
+ console.log("[Watcher]", msg);
2545
+ if (msg.includes("watching") || msg.includes("built")) {
2546
+ if (mainWindow && !mainWindow.isDestroyed()) {
2547
+ mainWindow.webContents.send("build-complete");
2548
+ }
2549
+ }
2550
+ });
2551
+ watcherProcess.stderr?.on("data", (data) => {
2552
+ console.error("[Watcher Error]", data.toString());
2553
+ });
2554
+ console.log("File watcher started");
2555
+ }
2556
+ }
2557
+ function createWindow() {
2558
+ mainWindow = new import_electron4.BrowserWindow({
2559
+ width: 2560,
2560
+ height: 1440,
2561
+ title: "Copilot Office",
2562
+ webPreferences: {
2563
+ preload: path8.join(__dirname, "terminal", "preload.js"),
2564
+ contextIsolation: true,
2565
+ nodeIntegration: false,
2566
+ backgroundThrottling: false
2567
+ }
2568
+ });
2569
+ mainWindow.maximize();
2570
+ mainWindow.loadFile(path8.join(__dirname, "../../src/index.html"));
2571
+ if (OPEN_DEVTOOLS_ON_START) {
2572
+ mainWindow.webContents.openDevTools({ mode: "detach" });
2573
+ }
2574
+ mainWindow.webContents.on("did-start-navigation", (_event, _url, isInPlace) => {
2575
+ if (isInPlace && pendingHardReload) {
2576
+ console.log("[Main] Hard reload \u2014 restarting terminal server");
2577
+ pendingHardReload = false;
2578
+ relay.shutdown().then(
2579
+ () => relay.spawnServer(__dirname)
2580
+ ).catch(
2581
+ (e) => console.error("[Main] Failed to respawn after reload:", e)
2582
+ );
2583
+ } else if (isInPlace) {
2584
+ console.log("[Main] Soft reload \u2014 keeping terminal server alive");
2585
+ }
2586
+ });
2587
+ mainWindow.on("closed", () => {
2588
+ if (watcherProcess) {
2589
+ watcherProcess.kill();
2590
+ watcherProcess = null;
2591
+ }
2592
+ mainWindow = null;
2593
+ });
2594
+ }
2595
+ import_electron4.app.whenReady().then(async () => {
2596
+ import_electron4.Menu.setApplicationMenu(null);
2597
+ killOrphanedProcesses();
523
2598
  relay.registerIpc();
524
2599
  const officeStore = createOfficeFileStore();
525
2600
  registerNonTerminalIpc({
@@ -536,22 +2611,116 @@ import_electron3.app.whenReady().then(async () => {
536
2611
  console.log("[Main] File watcher disabled");
537
2612
  }
538
2613
  createWindow();
539
- import_electron3.app.on("activate", () => {
540
- if (import_electron3.BrowserWindow.getAllWindows().length === 0) createWindow();
2614
+ import_electron4.app.on("activate", () => {
2615
+ if (import_electron4.BrowserWindow.getAllWindows().length === 0) createWindow();
541
2616
  });
2617
+ try {
2618
+ const settingsStore = createTeamsSettingsStore(process.cwd());
2619
+ const tokenPersistence = createSafeStorageTokenPersistence(
2620
+ path8.join(process.cwd(), ".data", "teams-token.enc"),
2621
+ import_electron4.safeStorage
2622
+ );
2623
+ const tokens = new AzTokenProvider(void 0, tokenPersistence);
2624
+ const getAllowedChannels = createCachedAllowedChannels(
2625
+ () => allowedChannelIdSet(
2626
+ settingsStore.load().defaultChannelUrl,
2627
+ officeChannelOverridesFromJson(officeStore.load().data)
2628
+ )
2629
+ );
2630
+ let cachedTeamsSettings = settingsStore.load();
2631
+ let teamsSettingsAt = Date.now();
2632
+ const getTeamsSettingsCached = () => {
2633
+ const now = Date.now();
2634
+ if (now - teamsSettingsAt >= 2e3) {
2635
+ cachedTeamsSettings = settingsStore.load();
2636
+ teamsSettingsAt = now;
2637
+ }
2638
+ return cachedTeamsSettings;
2639
+ };
2640
+ const rawGraph = new GraphClient(tokens);
2641
+ const allowlistedGraph = createAllowlistedGraphSender(rawGraph, getAllowedChannels);
2642
+ const resolveMention = async (ref, destTeamId) => {
2643
+ try {
2644
+ if (ref.type === "user") {
2645
+ const id = await rawGraph.findUserId(ref.value);
2646
+ return id ? { mentionType: "user", mentionId: id } : { mentionType: "none", mentionId: "" };
2647
+ }
2648
+ if (ref.type === "tag") {
2649
+ const raw = ref.value.trim();
2650
+ return raw ? { mentionType: "tag", mentionId: raw } : { mentionType: "none", mentionId: "" };
2651
+ }
2652
+ } catch {
2653
+ }
2654
+ return { mentionType: "none", mentionId: "" };
2655
+ };
2656
+ const relaySender = createRelaySender({
2657
+ primary: rawGraph,
2658
+ getDumpChannelUrl: () => getTeamsSettingsCached().relayChannelUrl,
2659
+ getMention: () => ({
2660
+ type: getTeamsSettingsCached().relayMentionType,
2661
+ value: getTeamsSettingsCached().relayMentionValue
2662
+ }),
2663
+ resolveMention,
2664
+ // The fan-out destination must satisfy the same outbound allowlist as the direct path.
2665
+ isDestinationAllowed: (channelId) => getAllowedChannels().has(channelId)
2666
+ });
2667
+ const graph = allowlistedGraph;
2668
+ const isNotifyActive = () => {
2669
+ const s = getTeamsSettingsCached();
2670
+ return s.notifyOnCompleteEnabled && !!s.relayChannelUrl.trim();
2671
+ };
2672
+ const source = new TrouterClient(tokens);
2673
+ const gateway = new RelaySessionGateway(relay);
2674
+ const store = new FileTeamsOnlineStore(
2675
+ FileTeamsOnlineStore.defaultPath(path8.join(process.cwd(), ".data"))
2676
+ );
2677
+ const emitStatus = makeStatusEmitter(() => mainWindow);
2678
+ const emitToast = makeToastEmitter(() => mainWindow);
2679
+ teamsService = new TeamsService({
2680
+ store,
2681
+ tokens,
2682
+ graph,
2683
+ notifier: relaySender,
2684
+ isNotifyActive,
2685
+ source,
2686
+ gateway,
2687
+ getSettings: () => settingsStore.load(),
2688
+ emitStatus,
2689
+ emitToast
2690
+ });
2691
+ registerTeamsIpc({
2692
+ service: teamsService,
2693
+ settingsStore,
2694
+ getMainWindow: () => mainWindow,
2695
+ onSettingsChanged: (settings) => {
2696
+ if (settings.enabled) teamsService?.start().catch((e) => console.error("[Main] Teams start failed:", e));
2697
+ else teamsService?.stop().catch((e) => console.error("[Main] Teams stop failed:", e));
2698
+ }
2699
+ });
2700
+ if (settingsStore.load().enabled) {
2701
+ teamsService.start().catch((e) => console.error("[Main] Teams start failed:", e));
2702
+ } else {
2703
+ console.log("[TeamsRemote] Feature disabled \u2014 service idle (enable it in Settings \u2192 Teams Remote).");
2704
+ }
2705
+ } catch (e) {
2706
+ console.error("[Main] Failed to initialize Teams service:", e);
2707
+ }
542
2708
  });
543
- import_electron3.app.on("window-all-closed", () => {
2709
+ import_electron4.app.on("window-all-closed", () => {
544
2710
  if (watcherProcess) watcherProcess.kill();
545
- if (process.platform !== "darwin") import_electron3.app.quit();
2711
+ if (process.platform !== "darwin") import_electron4.app.quit();
546
2712
  });
547
2713
  var isShuttingDown = false;
548
- import_electron3.app.on("before-quit", (event) => {
2714
+ import_electron4.app.on("before-quit", (event) => {
549
2715
  if (isShuttingDown) return;
550
2716
  isShuttingDown = true;
551
2717
  event.preventDefault();
552
2718
  console.log("[Main] Awaiting relay shutdown before quit\u2026");
553
- relay.shutdown().finally(() => {
554
- console.log("[Main] Relay shutdown complete \u2014 quitting");
555
- import_electron3.app.quit();
2719
+ const teamsStop = teamsService ? teamsService.stop().catch(() => void 0) : Promise.resolve();
2720
+ Promise.resolve(teamsStop).finally(() => {
2721
+ relay.shutdown().finally(() => {
2722
+ console.log("[Main] Relay shutdown complete \u2014 quitting");
2723
+ import_electron4.app.quit();
2724
+ });
556
2725
  });
557
2726
  });