@patimweb/pi-teams 1.0.0 → 1.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.
@@ -37,6 +37,7 @@ import {
37
37
  type PermissionBlock,
38
38
  type ResolvedPermissions,
39
39
  } from "./scope.ts";
40
+ import { canOpenBrowser } from "../utils/environment.ts";
40
41
 
41
42
  // ---------------------------------------------------------------------------
42
43
  // Types
@@ -45,15 +46,23 @@ import {
45
46
  /**
46
47
  * How pi obtains a token.
47
48
  *
48
- * - `device-code`: delegated. pi acts **as the signed-in user** — messages are
49
- * sent from their account and everything they can see, pi can see.
49
+ * - `interactive`: delegated. The browser opens at the Microsoft sign-in page
50
+ * and MSAL catches the redirect on a loopback port. pi then acts **as the
51
+ * signed-in user** — messages come from their account, and everything they
52
+ * can see, pi can see.
53
+ * - `device-code`: delegated as well, but the user types a short code on
54
+ * another device. For SSH sessions, containers, and anything headless.
50
55
  * - `client-credentials`: app-only. No user identity; Microsoft Graph does not
51
56
  * allow posting chat or channel messages this way (outside of migration
52
57
  * scenarios), so writes are refused with an explanation.
53
- * - `auto`: use `client-credentials` when a secret is configured, otherwise
54
- * `device-code`.
58
+ * - `auto`: `client-credentials` when a secret is configured, otherwise
59
+ * `interactive` where a browser is reachable and `device-code` where it is
60
+ * not.
55
61
  */
56
- export type AuthMode = "device-code" | "client-credentials" | "auto";
62
+ export type AuthMode = "interactive" | "device-code" | "client-credentials" | "auto";
63
+
64
+ /** An auth mode after "auto" has been resolved away. */
65
+ export type ResolvedAuthMode = "interactive" | "device-code" | "client-credentials";
57
66
 
58
67
  /** Safety level for mutation operations. */
59
68
  export type SafetyLevel = "open" | "confirm" | "readonly";
@@ -72,15 +81,62 @@ export interface TenantConfig {
72
81
  authMode?: AuthMode;
73
82
  /** Requested Graph scopes override */
74
83
  scopes?: string[];
84
+ /** Fixed loopback port for the browser sign-in redirect */
85
+ loopbackPort?: number;
75
86
  /** Safety level override — most specific wins */
76
87
  safetyLevel?: SafetyLevel;
77
88
  /** Scope rules layered on top of the account's */
78
89
  permissions?: PermissionBlock;
79
90
  }
80
91
 
92
+ /**
93
+ * Settings for "listen" mode — where pi is woken by an incoming Teams chat.
94
+ *
95
+ * Every field is optional and merges field by field down the cascade
96
+ * (global → account), so an interval can be set once while the chat filter is
97
+ * narrowed per account.
98
+ */
99
+ export interface WatchConfig {
100
+ /** Whether the watcher runs at all (default: false) */
101
+ enabled?: boolean;
102
+ /** Seconds between polls (default: 60, minimum: 15) */
103
+ intervalSeconds?: number;
104
+ /**
105
+ * Glob patterns for the chats to watch — matched against topic, label,
106
+ * chat ID and participant names, exactly like the scope rules. Empty or
107
+ * absent means every recent chat.
108
+ */
109
+ chats?: string[];
110
+ /**
111
+ * Only wake pi for messages from these people — matched against display
112
+ * name, UPN and e-mail. Empty or absent means any sender.
113
+ *
114
+ * This is the "listen to these people" switch: `chats` decides *where* pi
115
+ * listens, `from` decides *to whom*.
116
+ */
117
+ from?: string[];
118
+ /** Only wake pi where the signed-in user is mentioned (default: false) */
119
+ mentionOnly?: boolean;
120
+ /** Seconds to stay quiet in a chat after waking pi for it (default: 300) */
121
+ cooldownSeconds?: number;
122
+ /** Hard cap on wakes per hour, across all chats (default: 10) */
123
+ maxTriggersPerHour?: number;
124
+ }
125
+
126
+ /** A watch config after every default and clamp has been applied. */
127
+ export interface ResolvedWatchConfig {
128
+ enabled: boolean;
129
+ intervalSeconds: number;
130
+ chats: string[];
131
+ from: string[];
132
+ mentionOnly: boolean;
133
+ cooldownSeconds: number;
134
+ maxTriggersPerHour: number;
135
+ }
136
+
81
137
  /** A Teams identity pi can sign in as. */
82
138
  export interface AccountConfig {
83
- /** Short label used in tool parameters, e.g. "neoimpulse" */
139
+ /** Short label used in tool parameters, e.g. "work" */
84
140
  name: string;
85
141
  /** Friendly name for status output */
86
142
  displayName?: string;
@@ -94,10 +150,20 @@ export interface AccountConfig {
94
150
  authMode?: AuthMode;
95
151
  /** Requested Graph scopes (default: DEFAULT_SCOPES) */
96
152
  scopes?: string[];
153
+ /**
154
+ * Fixed loopback port for the browser sign-in redirect.
155
+ *
156
+ * Only needed where the app registration lists an exact redirect URI such
157
+ * as `http://localhost:3000`; by default MSAL picks a free port and Entra
158
+ * accepts any port on localhost for a public client.
159
+ */
160
+ loopbackPort?: number;
97
161
  /** Safety level override */
98
162
  safetyLevel?: SafetyLevel;
99
163
  /** Scope rules layered on top of the global ones */
100
164
  permissions?: PermissionBlock;
165
+ /** Listen-mode overrides for this identity */
166
+ watch?: WatchConfig;
101
167
  /** Additional tenants this identity reaches */
102
168
  tenants?: TenantConfig[];
103
169
  }
@@ -113,6 +179,8 @@ export interface TeamsRootConfig {
113
179
  safetyLevel?: SafetyLevel;
114
180
  /** Global scope rules */
115
181
  permissions?: PermissionBlock;
182
+ /** Global listen-mode settings */
183
+ watch?: WatchConfig;
116
184
  /** Default page size for message listings (default: 25) */
117
185
  maxMessages?: number;
118
186
  /** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
@@ -136,10 +204,14 @@ export interface TeamsConnection {
136
204
  clientId: string;
137
205
  clientSecret?: string;
138
206
  /** Effective auth mode — never "auto" after resolution */
139
- authMode: "device-code" | "client-credentials";
207
+ authMode: ResolvedAuthMode;
140
208
  scopes: string[];
209
+ /** Fixed loopback port for the browser sign-in, when one is configured */
210
+ loopbackPort?: number;
141
211
  safetyLevel: SafetyLevel;
142
212
  permissions: ResolvedPermissions;
213
+ /** Listen-mode settings in force for this account+tenant */
214
+ watch: ResolvedWatchConfig;
143
215
  maxMessages: number;
144
216
  audit: boolean;
145
217
  graphBaseUrl: string;
@@ -165,13 +237,11 @@ export class ConfigError extends Error {
165
237
  /**
166
238
  * Delegated scopes requested by default.
167
239
  *
168
- * `offline_access` is what makes the sign-in last: without it pi would have to
169
- * ask for a device code again every hour.
240
+ * `openid`, `profile` and `offline_access` are absent on purpose: MSAL always
241
+ * requests them and rejects them in an explicit scope list. The lasting
242
+ * sign-in they buy is therefore automatic.
170
243
  */
171
244
  export const DEFAULT_SCOPES: string[] = [
172
- "offline_access",
173
- "openid",
174
- "profile",
175
245
  "User.Read",
176
246
  "User.ReadBasic.All",
177
247
  "Team.ReadBasic.All",
@@ -201,7 +271,35 @@ const DEFAULTS = {
201
271
  authorityHost: "https://login.microsoftonline.com",
202
272
  };
203
273
 
204
- const VALID_AUTH_MODES = new Set<string>(["device-code", "client-credentials", "auto"]);
274
+ /**
275
+ * Listen-mode defaults and the bounds they are clamped to.
276
+ *
277
+ * The lower bound on `intervalSeconds` is what keeps a watcher from turning
278
+ * into a Graph-throttling loop: every tick costs at least one `/me/chats`
279
+ * round-trip, and each wake costs a full model turn.
280
+ */
281
+ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
282
+ enabled: false,
283
+ intervalSeconds: 60,
284
+ chats: [],
285
+ from: [],
286
+ mentionOnly: false,
287
+ cooldownSeconds: 300,
288
+ maxTriggersPerHour: 10,
289
+ };
290
+
291
+ const WATCH_BOUNDS = {
292
+ intervalSeconds: { min: 15, max: 3600 },
293
+ cooldownSeconds: { min: 0, max: 86400 },
294
+ maxTriggersPerHour: { min: 0, max: 1000 },
295
+ };
296
+
297
+ const VALID_AUTH_MODES = new Set<string>([
298
+ "interactive",
299
+ "device-code",
300
+ "client-credentials",
301
+ "auto",
302
+ ]);
205
303
  const VALID_SAFETY_LEVELS = new Set<string>(["open", "confirm", "readonly"]);
206
304
 
207
305
  // ---------------------------------------------------------------------------
@@ -299,18 +397,27 @@ export function resolveEffectiveSafetyLevel(
299
397
  );
300
398
  }
301
399
 
302
- /** Effective auth mode, resolving "auto" against the available credentials. */
400
+ /**
401
+ * Effective auth mode, resolving "auto" against the available credentials and
402
+ * the environment.
403
+ *
404
+ * `browserAvailable` is injected rather than detected here so the cascade stays
405
+ * a pure function — the detection itself lives in ../auth/msal.ts.
406
+ */
303
407
  export function resolveAuthMode(
304
408
  account: AccountConfig | undefined,
305
409
  tenant: TenantConfig | undefined,
306
410
  hasSecret: boolean,
307
- ): "device-code" | "client-credentials" {
411
+ browserAvailable = true,
412
+ ): ResolvedAuthMode {
308
413
  const configured =
309
414
  validateAuthMode(tenant?.authMode) ??
310
415
  validateAuthMode(account?.authMode) ??
311
416
  DEFAULTS.authMode;
312
- if (configured === "auto") return hasSecret ? "client-credentials" : "device-code";
313
- return configured;
417
+
418
+ if (configured !== "auto") return configured;
419
+ if (hasSecret) return "client-credentials";
420
+ return browserAvailable ? "interactive" : "device-code";
314
421
  }
315
422
 
316
423
  // ---------------------------------------------------------------------------
@@ -395,7 +502,7 @@ export function resolveConnection(
395
502
  if (missing.length > 0) throw new ConfigError(missing);
396
503
 
397
504
  const clientSecret = tenant?.clientSecret ?? account.clientSecret ?? undefined;
398
- const authMode = resolveAuthMode(account, tenant, !!clientSecret);
505
+ const authMode = resolveAuthMode(account, tenant, !!clientSecret, canOpenBrowser());
399
506
 
400
507
  if (authMode === "client-credentials" && !clientSecret) {
401
508
  throw new ConfigError(
@@ -417,9 +524,11 @@ export function resolveConnection(
417
524
  clientId,
418
525
  clientSecret,
419
526
  authMode,
527
+ loopbackPort: tenant?.loopbackPort ?? account.loopbackPort,
420
528
  scopes,
421
529
  safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
422
530
  permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
531
+ watch: resolveWatchConfig(config.watch, account),
423
532
  maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
424
533
  audit: config.audit ?? DEFAULTS.audit,
425
534
  graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
@@ -559,6 +668,75 @@ export function setDefaultAccount(name: string, tenantName?: string): void {
559
668
  writeRootConfig(config);
560
669
  }
561
670
 
671
+ /**
672
+ * Effective listen-mode settings: account > global > defaults.
673
+ *
674
+ * Exported for tests — every field is clamped here, so the watcher itself can
675
+ * assume sane numbers instead of defending against a hand-edited config file.
676
+ */
677
+ export function resolveWatchConfig(
678
+ global: WatchConfig | undefined,
679
+ account: AccountConfig | undefined,
680
+ ): ResolvedWatchConfig {
681
+ const local = account?.watch;
682
+ const pick = <K extends keyof WatchConfig>(key: K): WatchConfig[K] => local?.[key] ?? global?.[key];
683
+
684
+ return {
685
+ enabled: pick("enabled") ?? WATCH_DEFAULTS.enabled,
686
+ intervalSeconds: clamp(pick("intervalSeconds"), WATCH_BOUNDS.intervalSeconds, WATCH_DEFAULTS.intervalSeconds),
687
+ chats: normalizePatterns(pick("chats")),
688
+ from: normalizePatterns(pick("from")),
689
+ mentionOnly: pick("mentionOnly") ?? WATCH_DEFAULTS.mentionOnly,
690
+ cooldownSeconds: clamp(pick("cooldownSeconds"), WATCH_BOUNDS.cooldownSeconds, WATCH_DEFAULTS.cooldownSeconds),
691
+ maxTriggersPerHour: clamp(
692
+ pick("maxTriggersPerHour"),
693
+ WATCH_BOUNDS.maxTriggersPerHour,
694
+ WATCH_DEFAULTS.maxTriggersPerHour,
695
+ ),
696
+ };
697
+ }
698
+
699
+ function clamp(value: unknown, bounds: { min: number; max: number }, fallback: number): number {
700
+ const number = typeof value === "number" && Number.isFinite(value) ? value : fallback;
701
+ return Math.min(bounds.max, Math.max(bounds.min, Math.round(number)));
702
+ }
703
+
704
+ function normalizePatterns(patterns: string[] | undefined): string[] {
705
+ if (!Array.isArray(patterns)) return [];
706
+ const cleaned = patterns
707
+ .filter((pattern): pattern is string => typeof pattern === "string")
708
+ .map((pattern) => pattern.trim())
709
+ .filter((pattern) => pattern.length > 0);
710
+ return [...new Set(cleaned)];
711
+ }
712
+
713
+ /**
714
+ * Merge a watch patch into the config file and report what is now in force.
715
+ *
716
+ * Writes to the account block when an account is named and to the global block
717
+ * otherwise — matching how `watch` itself is resolved. The resolved settings
718
+ * are returned rather than the patch, so a caller reports the effect and not
719
+ * the request.
720
+ */
721
+ export function setWatchConfig(patch: WatchConfig, accountName?: string): ResolvedWatchConfig {
722
+ const config = readRootConfig();
723
+
724
+ if (!accountName) {
725
+ config.watch = { ...config.watch, ...patch };
726
+ writeRootConfig(config);
727
+ return resolveWatchConfig(config.watch, undefined);
728
+ }
729
+
730
+ const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
731
+ if (!account) {
732
+ throw new ConfigError([`account "${accountName}"`], `Account "${accountName}" not found.`);
733
+ }
734
+
735
+ account.watch = { ...account.watch, ...patch };
736
+ writeRootConfig(config);
737
+ return resolveWatchConfig(config.watch, account);
738
+ }
739
+
562
740
  /** Set the safety level globally, on an account, or on a tenant. */
563
741
  export function setSafetyLevel(
564
742
  level: SafetyLevel,
@@ -644,7 +822,7 @@ const TEMPLATE_JSON = `{
644
822
  "displayName": "Work account",
645
823
  "tenantId": "contoso.onmicrosoft.com",
646
824
  "clientId": "00000000-0000-0000-0000-000000000000",
647
- "authMode": "device-code",
825
+ "authMode": "interactive",
648
826
  "safetyLevel": "confirm",
649
827
  "permissions": {
650
828
  "read": {
@@ -12,6 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
12
12
  import {
13
13
  ensureConfigTemplate,
14
14
  getConfigPath,
15
+ setWatchConfig,
15
16
  tryResolveConnection,
16
17
  type TeamsConnection,
17
18
  } from "../config/index.ts";
@@ -22,6 +23,13 @@ import {
22
23
  isMutationTool,
23
24
  LOCAL_CONFIG_TOOLS,
24
25
  } from "../safety/index.ts";
26
+ import {
27
+ setActiveWatchLoop,
28
+ startWatchLoop,
29
+ stopActiveWatchLoop,
30
+ type WatchLoop,
31
+ } from "../watch/loop.ts";
32
+ import { composeWatchPrompt } from "../watch/prompt.ts";
25
33
 
26
34
  import { teamsSetupTool } from "../tools/teams-setup.ts";
27
35
  import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
@@ -49,6 +57,7 @@ import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
49
57
  import { teamsReactTool } from "../tools/teams-react.ts";
50
58
  import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
51
59
  import { teamsInboxTool } from "../tools/teams-inbox.ts";
60
+ import { teamsWatchTool } from "../tools/teams-watch.ts";
52
61
  import {
53
62
  teamsGetPresenceTool,
54
63
  teamsSetPresenceTool,
@@ -73,6 +82,7 @@ const tools = [
73
82
  teamsAccountsTool,
74
83
  teamsStatusTool,
75
84
  teamsPermissionsTool,
85
+ teamsWatchTool,
76
86
  teamsDoctorTool,
77
87
  // Discovery
78
88
  teamsListTeamsTool,
@@ -118,6 +128,74 @@ export default function (pi: ExtensionAPI) {
118
128
  return connection;
119
129
  };
120
130
 
131
+ // -----------------------------------------------------------------------
132
+ // Listen mode
133
+ // -----------------------------------------------------------------------
134
+
135
+ /** The watcher running in this session, when listen mode is on. */
136
+ let watchLoop: WatchLoop | undefined;
137
+
138
+ /** The last polling error the user was told about, so it is said once. */
139
+ let reportedWatchError: string | undefined;
140
+
141
+ const stopWatch = () => {
142
+ watchLoop?.stop();
143
+ watchLoop = undefined;
144
+ setActiveWatchLoop(undefined);
145
+ };
146
+
147
+ /**
148
+ * Start the watcher for the session's account, if the config asks for it.
149
+ *
150
+ * Restarting is the same call: the old loop is stopped first, so a config
151
+ * change cannot leave two pollers running.
152
+ */
153
+ const startWatch = (ctx: any) => {
154
+ stopWatch();
155
+
156
+ const conn = refresh();
157
+ if (!conn?.watch.enabled) return;
158
+
159
+ reportedWatchError = undefined;
160
+ const loop = startWatchLoop({
161
+ connection: conn,
162
+ onWake: (event) => {
163
+ pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
164
+ },
165
+ onTick: (status) => {
166
+ if (!status.lastError) reportedWatchError = undefined;
167
+ },
168
+ onError: (message) => {
169
+ // A watcher that fails every tick would otherwise say so every minute.
170
+ if (message === reportedWatchError) return;
171
+ reportedWatchError = message;
172
+ ctx.ui.notify(`Teams listen mode: ${message}`, "warning");
173
+ },
174
+ });
175
+
176
+ watchLoop = loop;
177
+ setActiveWatchLoop(loop);
178
+ };
179
+
180
+ /**
181
+ * Paint the footer status line from the current connection.
182
+ *
183
+ * The only place the status line is written, so the three surfaces —
184
+ * session start, `/teams-status`, and a tool result — cannot drift apart.
185
+ */
186
+ const paintStatus = (ctx: any) => {
187
+ const card = buildConnectionCard(refresh());
188
+ if (!card) return;
189
+ const watching = !!watchLoop?.status().running;
190
+ ctx.ui.setStatus(
191
+ "teams",
192
+ ctx.ui.theme.fg(
193
+ card.signedIn ? "success" : "warning",
194
+ buildConnectionLabel({ ...card, watching }),
195
+ ),
196
+ );
197
+ };
198
+
121
199
  // -----------------------------------------------------------------------
122
200
  // Status card — rendered in the transcript, never sent to the model
123
201
  // -----------------------------------------------------------------------
@@ -162,7 +240,7 @@ export default function (pi: ExtensionAPI) {
162
240
  return;
163
241
  }
164
242
  pi.appendEntry<ConnectionCard>("teams-connection", card);
165
- ctx.ui.setStatus("teams", ctx.ui.theme.fg("success", buildConnectionLabel(card)));
243
+ paintStatus(ctx);
166
244
  ctx.ui.notify(buildConnectionLabel(card), "info");
167
245
  },
168
246
  });
@@ -188,6 +266,56 @@ export default function (pi: ExtensionAPI) {
188
266
  },
189
267
  });
190
268
 
269
+ pi.registerCommand("teams-listen", {
270
+ description: "Let pi listen to Teams: on, off, or status (default)",
271
+ handler: async (args, ctx) => {
272
+ const conn = refresh();
273
+ if (!conn) {
274
+ ctx.ui.notify(
275
+ `Teams: no account configured yet. Use the teams_setup tool, or edit ${getConfigPath()}.`,
276
+ "warning",
277
+ );
278
+ return;
279
+ }
280
+
281
+ const action = args.trim().toLowerCase() || "status";
282
+
283
+ if (action === "on" || action === "off") {
284
+ // Written to the account, not globally: the watcher listens as one
285
+ // identity, and "which account" must not be a guess.
286
+ const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
287
+ startWatch(ctx);
288
+ paintStatus(ctx);
289
+ ctx.ui.notify(
290
+ watch.enabled
291
+ ? `Teams listen mode on — polling every ${watch.intervalSeconds} s, max ${watch.maxTriggersPerHour} wakes/hour.`
292
+ : "Teams listen mode off.",
293
+ "info",
294
+ );
295
+ return;
296
+ }
297
+
298
+ if (action !== "status") {
299
+ ctx.ui.notify(`Unknown argument "${action}". Use on, off, or status.`, "warning");
300
+ return;
301
+ }
302
+
303
+ const runtime = watchLoop?.status();
304
+ pi.appendEntry<ConnectionCard>("teams-connection", { ...buildConnectionCard(conn)!, watching: !!runtime?.running });
305
+ ctx.ui.notify(
306
+ [
307
+ `Listen mode: ${conn.watch.enabled ? "on" : "off"}`,
308
+ `every ${conn.watch.intervalSeconds} s`,
309
+ conn.watch.chats.length > 0 ? `chats: ${conn.watch.chats.join(", ")}` : "all recent chats",
310
+ conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
311
+ conn.watch.mentionOnly ? "mentions only" : "every message",
312
+ runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
313
+ ].join(" · "),
314
+ "info",
315
+ );
316
+ },
317
+ });
318
+
191
319
  pi.registerCommand("teams-inbox", {
192
320
  description: "Show what needs your attention in Microsoft Teams",
193
321
  handler: async (_args, ctx) => {
@@ -246,7 +374,8 @@ export default function (pi: ExtensionAPI) {
246
374
  const card = buildConnectionCard(connection);
247
375
  if (!card) return;
248
376
 
249
- ctx.ui.setStatus("teams", ctx.ui.theme.fg(card.signedIn ? "success" : "warning", buildConnectionLabel(card)));
377
+ paintStatus(ctx);
378
+ startWatch(ctx);
250
379
 
251
380
  if (!card.signedIn) {
252
381
  ctx.ui.notify(
@@ -294,6 +423,16 @@ export default function (pi: ExtensionAPI) {
294
423
  });
295
424
  }
296
425
 
426
+ // -----------------------------------------------------------------------
427
+ // Listen mode lifecycle
428
+ // -----------------------------------------------------------------------
429
+
430
+ pi.on("session_shutdown", () => {
431
+ // The timer would be unref'd anyway, but a poll in flight during shutdown
432
+ // would still be a Graph call for a session that is gone.
433
+ stopWatch();
434
+ });
435
+
297
436
  // -----------------------------------------------------------------------
298
437
  // Safety interceptor
299
438
  // -----------------------------------------------------------------------
@@ -333,4 +472,39 @@ export default function (pi: ExtensionAPI) {
333
472
  if (!approved) return { block: true, reason: `User declined: ${summary}` };
334
473
  }
335
474
  });
475
+
476
+ // -----------------------------------------------------------------------
477
+ // Status after a tool ran
478
+ // -----------------------------------------------------------------------
479
+
480
+ /**
481
+ * The status line is a snapshot of session start — but every change to the
482
+ * connection happens afterwards, in a tool, and tools have no UI context of
483
+ * their own. Without this the footer would still read "not signed in" after a
484
+ * successful login, and "listening" after listen mode was switched off.
485
+ */
486
+ const STATUS_TOOLS = new Set([
487
+ "teams_login",
488
+ "teams_logout",
489
+ "teams_setup",
490
+ "teams_accounts",
491
+ "teams_watch",
492
+ ]);
493
+
494
+ /** Tools after which the watcher has to be re-read from the config. */
495
+ const WATCH_CONFIG_TOOLS = new Set(["teams_watch", "teams_setup", "teams_accounts"]);
496
+
497
+ pi.on("tool_result", async (event, ctx) => {
498
+ if (!STATUS_TOOLS.has(event.toolName) || event.isError) return;
499
+
500
+ if (event.toolName === "teams_logout") {
501
+ // A watcher polling a session that was just removed only produces
502
+ // errors; leaving it running would be noise, not service.
503
+ stopWatch();
504
+ } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
505
+ startWatch(ctx);
506
+ }
507
+
508
+ paintStatus(ctx);
509
+ });
336
510
  }
@@ -11,7 +11,7 @@ import type { TeamsConnection } from "../config/index.ts";
11
11
  import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from "../types.ts";
12
12
  import { graphGetOptional, graphList, graphPost } from "./client.ts";
13
13
  import { mapMessage } from "./mappers.ts";
14
- import { textToHtml } from "../utils/formatting.ts";
14
+ import { markdownToTeamsHtml } from "../utils/richtext.ts";
15
15
 
16
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
17
17
  type Raw = Record<string, any>;
@@ -21,9 +21,15 @@ type Raw = Record<string, any>;
21
21
  // ---------------------------------------------------------------------------
22
22
 
23
23
  export interface MessageInput {
24
- /** Message text — plain text unless `html` is true */
24
+ /**
25
+ * Message text.
26
+ *
27
+ * Treated as lightweight markdown by default — bold, italic, code, bullets,
28
+ * numbered lists, links and `# headings` are converted to the HTML Teams
29
+ * renders. Set `html` to true to send raw HTML instead.
30
+ */
25
31
  body: string;
26
- /** Treat `body` as raw HTML instead of escaping it */
32
+ /** Treat `body` as raw HTML instead of converting markdown */
27
33
  html?: boolean;
28
34
  /** Channel messages only */
29
35
  subject?: string;
@@ -39,10 +45,14 @@ export interface MessageInput {
39
45
  * Mentions are the fiddly part: Teams requires an `<at id="N">` span in the
40
46
  * HTML *and* a matching entry in the `mentions` array. The display name is
41
47
  * replaced in the text so the caller can just write "@Anna Schmidt".
48
+ *
49
+ * Markdown is converted unless the caller says the body is already HTML: what
50
+ * comes back from a model is markdown, and markdown sent raw arrives as
51
+ * asterisks and dashes.
42
52
  */
43
53
  export function buildMessageBody(input: MessageInput): Record<string, unknown> {
44
54
  const mentions = input.mentions ?? [];
45
- let content = input.html ? input.body : textToHtml(input.body);
55
+ let content = input.html ? input.body : markdownToTeamsHtml(input.body);
46
56
 
47
57
  const mentionEntries: Record<string, unknown>[] = [];
48
58
  mentions.forEach((person, index) => {
@@ -18,7 +18,7 @@
18
18
  * which channel a message is actually headed for.
19
19
  */
20
20
 
21
- import type { SafetyLevel, TeamsConnection } from "../config/index.ts";
21
+ import type { ResolvedAuthMode, SafetyLevel, TeamsConnection } from "../config/index.ts";
22
22
  import { checkScope, type ScopeCategory, type ScopeMode } from "../config/scope.ts";
23
23
  import { ScopeDeniedError } from "../utils/errors.ts";
24
24
  import { MUTATION_TOOLS } from "../tools/tool-names.ts";
@@ -39,7 +39,7 @@ export function isMutationTool(toolName: string): boolean {
39
39
  * needs a way to add an account or sign out. They also work before anything is
40
40
  * configured at all, which is the whole point of `teams_setup`.
41
41
  */
42
- export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"]);
42
+ export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout", "teams_watch"]);
43
43
 
44
44
  /**
45
45
  * Why a mutation must be refused outright, or undefined when it may proceed
@@ -47,7 +47,7 @@ export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"
47
47
  */
48
48
  export function blockReason(
49
49
  safetyLevel: SafetyLevel,
50
- authMode: "device-code" | "client-credentials",
50
+ authMode: ResolvedAuthMode,
51
51
  toolName: string,
52
52
  ): string | undefined {
53
53
  if (!isMutationTool(toolName)) return undefined;
@@ -64,7 +64,7 @@ export function blockReason(
64
64
  return (
65
65
  `"${toolName}" cannot run on an app-only token. Microsoft Graph only permits app-only writes to ` +
66
66
  `chats and channels for migration scenarios, so pi cannot post as a person this way. ` +
67
- `Set authMode to "device-code" for this account and run teams_login.`
67
+ `Set authMode to "interactive" for this account and run teams_login.`
68
68
  );
69
69
  }
70
70