@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.
package/src/status.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { TeamsConnection } from "./config/index.ts";
9
- import { getToken, isFresh } from "./auth/token-store.ts";
9
+ import { readCacheSummary } from "./auth/cache-plugin.ts";
10
10
  import { formatPermissions } from "./config/scope.ts";
11
11
 
12
12
  /** Immutable snapshot of the active Teams connection. */
@@ -27,6 +27,10 @@ export interface ConnectionCard {
27
27
  otherAccounts: string[];
28
28
  /** Rendered scope rules */
29
29
  permissionSummary: string;
30
+ /** Listen mode is switched on */
31
+ watching?: boolean;
32
+ /** One line on what the watcher is doing, when it runs */
33
+ watchSummary?: string;
30
34
  }
31
35
 
32
36
  export function buildConnectionCard(
@@ -34,7 +38,7 @@ export function buildConnectionCard(
34
38
  ): ConnectionCard | undefined {
35
39
  if (!conn) return undefined;
36
40
 
37
- const token = getToken(conn.account, conn.tenantId);
41
+ const cache = readCacheSummary(conn.account, conn.tenantId);
38
42
 
39
43
  return {
40
44
  account: conn.account,
@@ -43,11 +47,26 @@ export function buildConnectionCard(
43
47
  tenantId: conn.tenantId,
44
48
  authMode: conn.authMode,
45
49
  safetyLevel: conn.safetyLevel,
46
- user: token?.user?.upn ?? token?.user?.displayName,
47
- signedIn: isFresh(token) || !!token?.refreshToken,
48
- expiresAt: token ? new Date(token.expiresAt).toISOString() : undefined,
50
+ user: cache.username ?? cache.name,
51
+ // A cached session counts as signed in even when the access token has
52
+ // expired: MSAL renews it silently on the next call.
53
+ signedIn: cache.present,
54
+ expiresAt: cache.expiresAt ? new Date(cache.expiresAt).toISOString() : undefined,
49
55
  otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
50
56
  permissionSummary: formatPermissions(conn.permissions),
57
+ // What the configuration asks for; a caller that knows whether the watcher
58
+ // is actually running overrides this with the runtime truth.
59
+ watching: conn.watch.enabled,
60
+ watchSummary: conn.watch.enabled
61
+ ? [
62
+ `every ${conn.watch.intervalSeconds} s`,
63
+ conn.watch.chats.length > 0 ? conn.watch.chats.join(", ") : "all recent chats",
64
+ conn.watch.from.length > 0 ? `from ${conn.watch.from.join(", ")}` : undefined,
65
+ `max ${conn.watch.maxTriggersPerHour}/h`,
66
+ ]
67
+ .filter(Boolean)
68
+ .join(" · ")
69
+ : undefined,
51
70
  };
52
71
  }
53
72
 
@@ -57,7 +76,8 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
57
76
  if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
58
77
 
59
78
  const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
60
- return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}`;
79
+ const listening = card.watching ? " · 👂 listening" : "";
80
+ return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
61
81
  }
62
82
 
63
83
  /** Rich markdown for `/teams-status` and the expanded card. */
@@ -78,12 +98,27 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
78
98
  if (card.tenant !== card.account) lines.push(`- **Tenant:** ${card.tenant}`);
79
99
  lines.push(`- **Tenant ID:** ${card.tenantId}`);
80
100
  lines.push(`- **Acting as:** ${card.signedIn ? (card.user ?? "(signed in)") : "not signed in — run teams_login"}`);
81
- lines.push(`- **Auth mode:** ${card.authMode}${card.authMode === "client-credentials" ? " (app-only — cannot post as a person)" : ""}`);
101
+ lines.push(
102
+ `- **Auth mode:** ${card.authMode}${
103
+ card.authMode === "client-credentials"
104
+ ? " (app-only — cannot post as a person)"
105
+ : card.authMode === "interactive"
106
+ ? " (browser sign-in)"
107
+ : " (device code)"
108
+ }`,
109
+ );
82
110
  lines.push(`- **Safety level:** ${card.safetyLevel}`);
83
- if (card.expiresAt) lines.push(`- **Token expires:** ${new Date(card.expiresAt).toLocaleString()}`);
111
+ if (card.expiresAt) {
112
+ lines.push(
113
+ `- **Access token expires:** ${new Date(card.expiresAt).toLocaleString()} (renewed silently)`,
114
+ );
115
+ }
84
116
  if (card.otherAccounts.length > 0) {
85
117
  lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
86
118
  }
119
+ lines.push(
120
+ `- **Listen mode:** ${card.watching ? `on — ${card.watchSummary ?? "running"}` : "off"}`,
121
+ );
87
122
 
88
123
  lines.push("", "### What pi may do", "", card.permissionSummary);
89
124
  return lines.join("\n");
@@ -12,7 +12,8 @@ import {
12
12
  setSafetyLevel,
13
13
  validateSafetyLevel,
14
14
  } from "../config/index.ts";
15
- import { deleteToken, getToken, isFresh } from "../auth/token-store.ts";
15
+ import { readCacheSummary, removeCache } from "../auth/cache-plugin.ts";
16
+ import { resetApps } from "../auth/index.ts";
16
17
  import { clearUserCache, errorResult, run, textResult, type ToolResult } from "./shared.ts";
17
18
 
18
19
  interface AccountsParams {
@@ -69,12 +70,12 @@ export const teamsAccountsTool = {
69
70
  const lines = ["## Configured Teams accounts", ""];
70
71
  for (const account of config.accounts) {
71
72
  const isDefault = config.defaultAccount === account.name;
72
- const token = getToken(account.name, account.tenantId);
73
- const state = isFresh(token)
74
- ? "signed in"
75
- : token?.refreshToken
76
- ? "session saved"
77
- : "not signed in";
73
+ const cache = readCacheSummary(account.name, account.tenantId);
74
+ const state = !cache.present
75
+ ? "not signed in"
76
+ : cache.fresh
77
+ ? "signed in"
78
+ : "session saved";
78
79
 
79
80
  lines.push(
80
81
  `### ${account.displayName ?? account.name}${isDefault ? " (default)" : ""}`,
@@ -83,16 +84,16 @@ export const teamsAccountsTool = {
83
84
  `- tenant: ${account.tenantId}`,
84
85
  `- auth mode: ${account.authMode ?? "device-code"}`,
85
86
  `- safety: ${account.safetyLevel ?? config.safetyLevel ?? "confirm"}`,
86
- `- status: ${state}${token?.user?.upn ? ` as ${token.user.upn}` : ""}`,
87
+ `- status: ${state}${cache.username ? ` as ${cache.username}` : ""}`,
87
88
  );
88
89
 
89
90
  for (const tenant of account.tenants ?? []) {
90
- const tenantToken = getToken(account.name, tenant.tenantId);
91
- const tenantState = isFresh(tenantToken)
92
- ? "signed in"
93
- : tenantToken?.refreshToken
94
- ? "session saved"
95
- : "not signed in";
91
+ const tenantCache = readCacheSummary(account.name, tenant.tenantId);
92
+ const tenantState = !tenantCache.present
93
+ ? "not signed in"
94
+ : tenantCache.fresh
95
+ ? "signed in"
96
+ : "session saved";
96
97
  lines.push(
97
98
  ` - tenant \`${tenant.name}\` (${tenant.tenantId}) — ${tenantState}` +
98
99
  `${tenant.safetyLevel ? `, safety: ${tenant.safetyLevel}` : ""}`,
@@ -125,7 +126,10 @@ export const teamsAccountsTool = {
125
126
  .find((a) => a.name === params.account)
126
127
  ?.tenants?.find((t) => t.name === params.tenant);
127
128
  const removed = removeTenant(params.account, params.tenant);
128
- if (removed && tenantConfig) deleteToken(params.account, tenantConfig.tenantId);
129
+ if (removed && tenantConfig) {
130
+ removeCache(params.account, tenantConfig.tenantId);
131
+ resetApps();
132
+ }
129
133
  return removed
130
134
  ? textResult(`✅ Removed tenant "${params.tenant}" from account "${params.account}".`, {})
131
135
  : errorResult(`Tenant "${params.tenant}" not found in account "${params.account}".`);
@@ -134,10 +138,11 @@ export const teamsAccountsTool = {
134
138
  const accountConfig = config.accounts.find((a) => a.name === params.account);
135
139
  const removed = removeAccount(params.account);
136
140
  if (removed && accountConfig) {
137
- deleteToken(params.account, accountConfig.tenantId);
141
+ removeCache(params.account, accountConfig.tenantId);
138
142
  for (const tenant of accountConfig.tenants ?? []) {
139
- deleteToken(params.account, tenant.tenantId);
143
+ removeCache(params.account, tenant.tenantId);
140
144
  }
145
+ resetApps();
141
146
  }
142
147
  clearUserCache();
143
148
  return removed
@@ -13,9 +13,9 @@ import {
13
13
  DEFAULT_SCOPES,
14
14
  type TeamsConnection,
15
15
  } from "../config/index.ts";
16
- import { getToken, getTokenPath, isFresh } from "../auth/token-store.ts";
16
+ import { getCacheDir, readCacheSummary } from "../auth/cache-plugin.ts";
17
17
  import { tokenScopes } from "../auth/jwt.ts";
18
- import { getAccessToken } from "../auth/index.ts";
18
+ import { browserUnavailableReason, canOpenBrowser, getAccessToken } from "../auth/index.ts";
19
19
  import { getMe } from "../graph/me.ts";
20
20
  import { listJoinedTeams } from "../graph/teams.ts";
21
21
  import { formatGraphError, describeScope } from "../utils/errors.ts";
@@ -47,28 +47,46 @@ async function checkConnection(
47
47
  "- ⚠️ app-only: pi cannot post messages as a person with this mode " +
48
48
  "(Microsoft Graph restricts app-only message writes to migration scenarios)",
49
49
  );
50
+ } else if (conn.authMode === "interactive" && !canOpenBrowser()) {
51
+ lines.push(
52
+ `- ⚠️ browser sign-in is configured but unavailable here (${browserUnavailableReason()}); ` +
53
+ "`teams_login` will use the device code flow",
54
+ );
50
55
  }
51
56
 
52
- const token = getToken(conn.account, conn.tenantId);
53
- if (!token) {
57
+ const cache = readCacheSummary(conn.account, conn.tenantId);
58
+ if (!cache.present) {
54
59
  lines.push("- ❌ not signed in — run `teams_login`");
55
60
  return lines;
56
61
  }
57
62
 
58
63
  lines.push(
59
- isFresh(token)
60
- ? `- ✅ token valid until ${new Date(token.expiresAt).toLocaleString()}`
61
- : token.refreshToken
62
- ? "- ⚠️ access token expired, refresh token present (will renew on the next call)"
63
- : "- ❌ token expired and no refresh token — run `teams_login`",
64
+ cache.fresh
65
+ ? `- ✅ access token valid until ${new Date(cache.expiresAt!).toLocaleString()}`
66
+ : "- ⚠️ access token expired; MSAL renews it silently on the next call",
64
67
  );
68
+ if (cache.username) lines.push(`- cached identity: ${cache.username}`);
69
+
70
+ if (offline) {
71
+ lines.push("- _offline check: scopes and connectivity not verified_");
72
+ return lines;
73
+ }
74
+
75
+ // Everything below needs a live token. Acquiring it also proves that silent
76
+ // renewal works, which is the failure users actually hit.
77
+ let accessToken: string;
78
+ try {
79
+ const token = await getAccessToken(conn, signal);
80
+ accessToken = token.accessToken;
81
+ } catch (err) {
82
+ lines.push(`- ❌ could not obtain a token: ${formatGraphError(err)}`);
83
+ return lines;
84
+ }
65
85
 
66
- // Consent check works offline: the scopes are inside the token.
67
- const granted = tokenScopes(token.accessToken);
86
+ // The granted scopes are inside the token, so this needs no extra call.
87
+ const granted = tokenScopes(accessToken);
68
88
  if (granted.length > 0) {
69
- const wanted = (conn.scopes.length > 0 ? conn.scopes : DEFAULT_SCOPES).filter(
70
- (scope) => !["openid", "profile", "offline_access"].includes(scope),
71
- );
89
+ const wanted = conn.scopes.length > 0 ? conn.scopes : DEFAULT_SCOPES;
72
90
  const missing = wanted.filter(
73
91
  (scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
74
92
  );
@@ -86,15 +104,6 @@ async function checkConnection(
86
104
  }
87
105
  }
88
106
 
89
- if (offline) return lines;
90
-
91
- try {
92
- await getAccessToken(conn, signal);
93
- } catch (err) {
94
- lines.push(`- ❌ could not obtain a token: ${formatGraphError(err)}`);
95
- return lines;
96
- }
97
-
98
107
  try {
99
108
  const me = await getMe(conn, signal);
100
109
  lines.push(`- ✅ acting as **${me.displayName}** <${me.upn}>`);
@@ -146,8 +155,11 @@ export const teamsDoctorTool = {
146
155
 
147
156
  const lines = ["## Teams doctor", ""];
148
157
  lines.push(`- config: \`${getConfigPath()}\``);
149
- lines.push(`- tokens: \`${getTokenPath()}\``);
158
+ lines.push(`- tokens: \`${getCacheDir()}/\``);
150
159
  lines.push(`- audit log: \`${getAuditPath()}\``);
160
+ lines.push(
161
+ `- browser sign-in: ${canOpenBrowser() ? "available" : `unavailable — ${browserUnavailableReason()}`}`,
162
+ );
151
163
  lines.push("");
152
164
 
153
165
  if (errors.length > 0) {
@@ -1,17 +1,25 @@
1
1
  /**
2
- * teams_login — sign in as the user via the device code flow.
2
+ * teams_login — sign in as the user.
3
+ * teams_logout — forget a saved session.
3
4
  *
4
- * The tool streams the code and URL through `onUpdate` as soon as Microsoft
5
- * issues them, then keeps polling. That ordering matters: the user has a
6
- * couple of minutes to act, and they cannot act on a code that only appears
7
- * after the tool returns.
5
+ * The browser flow is the default: pi opens the Microsoft sign-in page, the
6
+ * user signs in with their own credentials and MFA, and MSAL catches the
7
+ * redirect on a loopback port. Where no browser can be reached — SSH, a
8
+ * container, a headless box — it falls back to the device code flow and says
9
+ * why.
8
10
  */
9
11
 
10
12
  import { Type } from "typebox";
11
- import { pollForToken, requestDeviceCode } from "../auth/device-code.ts";
12
- import { acquireAppToken } from "../auth/client-credentials.ts";
13
- import { clearMemoryCache, forgetToken, storeToken } from "../auth/index.ts";
14
- import { clearAllTokens, deleteToken } from "../auth/token-store.ts";
13
+ import {
14
+ browserUnavailableReason,
15
+ canOpenBrowser,
16
+ defaultSignInMode,
17
+ signIn,
18
+ signOut,
19
+ signOutAll,
20
+ type DeviceCodeInfo,
21
+ type SignInMode,
22
+ } from "../auth/index.ts";
15
23
  import { tokenScopes } from "../auth/jwt.ts";
16
24
  import { getMe } from "../graph/me.ts";
17
25
  import { formatGraphError } from "../utils/errors.ts";
@@ -31,30 +39,64 @@ import {
31
39
  interface LoginParams {
32
40
  account?: string;
33
41
  tenant?: string;
42
+ mode?: string;
43
+ port?: number;
44
+ }
45
+
46
+ type Update = (update: { content: Array<{ type: "text"; text: string }> }) => void;
47
+
48
+ /** What the user has to do, rendered the moment MSAL tells us. */
49
+ function deviceCodeInstructions(label: string, info: DeviceCodeInfo, why?: string): string {
50
+ return [
51
+ `## Sign in to Teams (${label})`,
52
+ "",
53
+ why ? `_Using the device code flow because ${why}._` : "",
54
+ "",
55
+ `1. Open **${info.verificationUri}**`,
56
+ `2. Enter the code: **${info.userCode}**`,
57
+ "3. Sign in with the account pi should act as.",
58
+ "",
59
+ `The code expires in ${Math.round(info.expiresIn / 60)} minutes. Waiting…`,
60
+ ]
61
+ .filter((line) => line !== "")
62
+ .join("\n");
34
63
  }
35
64
 
36
65
  export const teamsLoginTool = {
37
66
  name: "teams_login",
38
67
  description:
39
- "Sign in to Microsoft Teams as yourself using the device code flow. Shows a short code and a URL; " +
40
- "open the URL, enter the code, and sign in with your normal credentials. The session is saved " +
41
- "(including a refresh token) so this is only needed once per account/tenant. " +
42
- "Use the 'account' and 'tenant' parameters to sign in to a second company or a guest tenant.",
68
+ "Sign in to Microsoft Teams as yourself. By default this opens your browser at the Microsoft sign-in " +
69
+ "page and completes automatically — nothing to type. On a machine with no browser (SSH, a container) " +
70
+ "it falls back to the device code flow and shows a short code to enter on another device. " +
71
+ "The session is saved and renewed silently, so this is normally needed once per account. " +
72
+ "Use 'account' and 'tenant' to sign in to a second company or a guest tenant.",
43
73
  parameters: Type.Object({
44
74
  account: AccountParam,
45
75
  tenant: TenantParam,
76
+ mode: Type.Optional(
77
+ Type.String({
78
+ description:
79
+ "Force a flow: 'interactive' (browser) or 'device-code'. Omit to let pi choose for this machine.",
80
+ }),
81
+ ),
82
+ port: Type.Optional(
83
+ Type.Number({
84
+ description:
85
+ "Fixed loopback port for the browser redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
86
+ }),
87
+ ),
46
88
  }),
47
89
  promptSnippet: "Sign in to Microsoft Teams as the user",
48
90
  promptGuidelines: [
49
- "Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account.",
50
- "Show the user code and the verification URL to the user verbatim — they cannot complete sign-in without them.",
91
+ "Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account or tenant.",
92
+ "If the device code flow is used, show the user code and URL to the user verbatim — they cannot finish signing in without them.",
51
93
  ],
52
94
 
53
95
  async execute(
54
96
  _toolCallId: string,
55
97
  params: LoginParams,
56
98
  signal: AbortSignal | undefined,
57
- onUpdate: ((update: { content: Array<{ type: "text"; text: string }> }) => void) | undefined,
99
+ onUpdate: Update | undefined,
58
100
  ctx: ToolContext,
59
101
  ): Promise<ToolResult> {
60
102
  return run(async () => {
@@ -63,8 +105,7 @@ export const teamsLoginTool = {
63
105
 
64
106
  // App-only needs no user interaction at all.
65
107
  if (conn.authMode === "client-credentials") {
66
- const token = await acquireAppToken(conn, signal);
67
- storeToken(conn, token);
108
+ const token = await signIn(conn, { signal });
68
109
  return textResult(
69
110
  [
70
111
  `✅ Acquired an app-only token for ${label}.`,
@@ -72,50 +113,60 @@ export const teamsLoginTool = {
72
113
  `Granted app roles: ${tokenScopes(token.accessToken).join(", ") || "(none visible in the token)"}`,
73
114
  "",
74
115
  "Note: app-only tokens cannot post messages as a person. Switch this account to " +
75
- 'authMode "device-code" if pi should act as you.',
116
+ 'authMode "interactive" if pi should act as you.',
76
117
  ].join("\n"),
77
118
  { account: conn.account, tenant: conn.tenant, authMode: "client-credentials" },
78
119
  );
79
120
  }
80
121
 
81
- const challenge = await requestDeviceCode(conn, signal);
122
+ const requested = params.mode?.toLowerCase();
123
+ if (requested && requested !== "interactive" && requested !== "device-code") {
124
+ return errorResult(`mode must be 'interactive' or 'device-code', not "${params.mode}".`);
125
+ }
82
126
 
83
- const instructions = [
84
- `## Sign in to Teams (${label})`,
85
- "",
86
- `1. Open **${challenge.verificationUri}**`,
87
- `2. Enter the code: **${challenge.userCode}**`,
88
- "3. Sign in with the account pi should act as.",
89
- "",
90
- `The code expires at ${new Date(challenge.expiresAt).toLocaleTimeString()}. Waiting…`,
91
- ].join("\n");
127
+ let mode: SignInMode = (requested as SignInMode | undefined) ?? defaultSignInMode();
128
+ let fallbackReason: string | undefined;
92
129
 
93
- onUpdate?.({ content: [{ type: "text", text: instructions }] });
130
+ // An explicit request for the browser on a machine that has none would
131
+ // hang until the timeout; say so and use the code flow instead.
132
+ if (mode === "interactive" && !canOpenBrowser()) {
133
+ fallbackReason = browserUnavailableReason();
134
+ mode = "device-code";
135
+ } else if (mode === "device-code" && !requested && !canOpenBrowser()) {
136
+ fallbackReason = browserUnavailableReason();
137
+ }
138
+
139
+ if (mode === "interactive") {
140
+ onUpdate?.({
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: [
145
+ `## Signing in to Teams (${label})`,
146
+ "",
147
+ "Opening your browser. Sign in there with the account pi should act as —",
148
+ "the page closes itself when you are done.",
149
+ ].join("\n"),
150
+ },
151
+ ],
152
+ });
153
+ }
94
154
 
95
- let lastNotice = 0;
96
- const token = await pollForToken(conn, challenge, {
155
+ const result = await signIn(conn, {
156
+ mode,
97
157
  signal,
98
- onPending: (secondsLeft) => {
99
- // One nudge every 30s — enough to show progress, not enough to spam.
100
- if (secondsLeft > 0 && Date.now() - lastNotice > 30_000) {
101
- lastNotice = Date.now();
102
- onUpdate?.({
103
- content: [
104
- {
105
- type: "text",
106
- text: `${instructions}\n\nStill waiting for sign-in… (${secondsLeft}s left)`,
107
- },
108
- ],
109
- });
110
- }
158
+ loopbackPort: params.port ?? conn.loopbackPort,
159
+ onDeviceCode: (info) => {
160
+ onUpdate?.({
161
+ content: [{ type: "text", text: deviceCodeInstructions(label, info, fallbackReason) }],
162
+ });
111
163
  },
112
164
  });
113
165
 
114
- storeToken(conn, token);
115
166
  clearUserCache();
116
167
 
117
- // Confirm the token actually works and report who pi now is.
118
- let identity = token.user?.upn ?? token.user?.displayName ?? "(unknown)";
168
+ // Confirm the token actually works, and report who pi now is.
169
+ let identity = result.account?.username ?? result.account?.name ?? "(unknown)";
119
170
  let jobTitle: string | undefined;
120
171
  try {
121
172
  const me = await getMe(conn, signal);
@@ -132,16 +183,17 @@ export const teamsLoginTool = {
132
183
  );
133
184
  }
134
185
 
135
- const granted = tokenScopes(token.accessToken);
136
- const requested = conn.scopes.filter((s) => !["openid", "profile", "offline_access"].includes(s));
137
- const missing = requested.filter(
138
- (scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
186
+ const granted = tokenScopes(result.accessToken);
187
+ const missing = conn.scopes.filter(
188
+ (scope) => !granted.some((entry) => entry.toLowerCase() === scope.toLowerCase()),
139
189
  );
140
190
 
141
191
  return textResult(
142
192
  [
143
193
  `✅ Signed in to ${label} as **${identity}**${jobTitle ? ` (${jobTitle})` : ""}.`,
144
194
  "",
195
+ `Flow: ${result.mode === "interactive" ? "browser sign-in" : "device code"}` +
196
+ `${fallbackReason ? ` (${fallbackReason})` : ""}`,
145
197
  `pi now acts as this user in Teams. Safety level: **${conn.safetyLevel}**.`,
146
198
  missing.length > 0
147
199
  ? `\n⚠️ Not granted: ${missing.join(", ")}\nSome tools will fail until an admin consents to these scopes.`
@@ -151,6 +203,7 @@ export const teamsLoginTool = {
151
203
  account: conn.account,
152
204
  tenant: conn.tenant,
153
205
  user: identity,
206
+ mode: result.mode,
154
207
  grantedScopes: granted,
155
208
  missingScopes: missing,
156
209
  },
@@ -162,12 +215,12 @@ export const teamsLoginTool = {
162
215
  export const teamsLogoutTool = {
163
216
  name: "teams_logout",
164
217
  description:
165
- "Sign out of a Teams account: removes the saved access and refresh tokens from " +
166
- "~/.pi/agent/pi-teams-tokens.json. The account stays configured, so teams_login can sign in again.",
218
+ "Sign out of a Teams account: removes the saved session from ~/.pi/agent/pi-teams-tokens/. " +
219
+ "The account stays configured, so teams_login can sign in again.",
167
220
  parameters: Type.Object({
168
221
  account: AccountParam,
169
222
  tenant: TenantParam,
170
- all: Type.Optional(Type.Boolean({ description: "Remove the cached tokens of every account" })),
223
+ all: Type.Optional(Type.Boolean({ description: "Remove the saved sessions of every account" })),
171
224
  }),
172
225
  promptSnippet: "Sign out of a Teams account",
173
226
 
@@ -182,14 +235,12 @@ export const teamsLogoutTool = {
182
235
  clearUserCache();
183
236
 
184
237
  if (params.all) {
185
- const count = clearAllTokens();
186
- clearMemoryCache();
187
- return textResult(`✅ Removed ${count} cached Teams session(s).`, { removed: count });
238
+ const count = signOutAll();
239
+ return textResult(`✅ Removed ${count} saved Teams session(s).`, { removed: count });
188
240
  }
189
241
 
190
242
  const conn = connectionFor(ctx, params.account, params.tenant);
191
- const removed = deleteToken(conn.account, conn.tenantId);
192
- forgetToken(conn);
243
+ const removed = await signOut(conn);
193
244
 
194
245
  return removed
195
246
  ? textResult(`✅ Signed out of ${connectionLabel(conn)}.`, { account: conn.account })
@@ -57,14 +57,17 @@ async function resolveMentions(
57
57
  const sharedParams = {
58
58
  channel: Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" }),
59
59
  team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
60
- body: Type.String({ description: "Message text. Plain text unless 'html' is true." }),
60
+ body: Type.String({
61
+ description:
62
+ "Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
63
+ }),
61
64
  account: AccountParam,
62
65
  tenant: TenantParam,
63
66
  mentions: Type.Optional(
64
67
  Type.Array(Type.String(), { description: "People to @-mention, by name, UPN or e-mail" }),
65
68
  ),
66
69
  importance: Type.Optional(Type.String({ description: "'normal' (default), 'high' or 'urgent'" })),
67
- html: Type.Optional(Type.Boolean({ description: "Treat 'body' as raw HTML instead of plain text" })),
70
+ html: Type.Optional(Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" })),
68
71
  };
69
72
 
70
73
  export const teamsSendChannelMessageTool = {
@@ -81,6 +84,8 @@ export const teamsSendChannelMessageTool = {
81
84
  promptGuidelines: [
82
85
  "Write in the user's voice — the post is attributed to them, not to an assistant.",
83
86
  "Show the exact text and the target channel before posting when there is any ambiguity.",
87
+ "Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
88
+ "Answer in the language of the conversation you are writing into, and match its register.",
84
89
  ],
85
90
 
86
91
  async execute(
@@ -46,7 +46,10 @@ export const teamsSendChatMessageTool = {
46
46
  "where the mention should appear.",
47
47
  parameters: Type.Object({
48
48
  chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
49
- body: Type.String({ description: "Message text. Plain text unless 'html' is true." }),
49
+ body: Type.String({
50
+ description:
51
+ "Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
52
+ }),
50
53
  account: AccountParam,
51
54
  tenant: TenantParam,
52
55
  mentions: Type.Optional(
@@ -58,7 +61,7 @@ export const teamsSendChatMessageTool = {
58
61
  Type.String({ description: "'normal' (default), 'high' or 'urgent'" }),
59
62
  ),
60
63
  html: Type.Optional(
61
- Type.Boolean({ description: "Treat 'body' as raw HTML instead of plain text" }),
64
+ Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" }),
62
65
  ),
63
66
  }),
64
67
  promptSnippet: "Send a message in a Teams chat as the user",
@@ -66,6 +69,8 @@ export const teamsSendChatMessageTool = {
66
69
  "Write the message in the user's voice — it is sent from their account, not from an assistant.",
67
70
  "Show the user the exact text before sending when the intent is even slightly ambiguous.",
68
71
  "Do not add signatures, disclaimers, or a note that the message was written by an AI unless the user asks.",
72
+ "Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
73
+ "Answer in the language of the conversation you are writing into, and match its register.",
69
74
  ],
70
75
 
71
76
  async execute(
@@ -27,6 +27,7 @@ interface SetupParams {
27
27
  displayName?: string;
28
28
  clientSecret?: string;
29
29
  authMode?: string;
30
+ loopbackPort?: number;
30
31
  safetyLevel?: string;
31
32
  parentAccount?: string;
32
33
  setDefault?: boolean;
@@ -116,7 +117,14 @@ export const teamsSetupTool = {
116
117
  authMode: Type.Optional(
117
118
  Type.String({
118
119
  description:
119
- "'device-code' (act as the signed-in user, default), 'client-credentials' (app-only, cannot post as a person), or 'auto'",
120
+ "'interactive' (browser sign-in, acts as the user — default), 'device-code' (same, but for machines with no browser), " +
121
+ "'client-credentials' (app-only, cannot post as a person), or 'auto' to decide per machine",
122
+ }),
123
+ ),
124
+ loopbackPort: Type.Optional(
125
+ Type.Number({
126
+ description:
127
+ "Fixed port for the browser sign-in redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
120
128
  }),
121
129
  ),
122
130
  safetyLevel: Type.Optional(
@@ -179,6 +187,7 @@ export const teamsSetupTool = {
179
187
  clientSecret: params.clientSecret,
180
188
  authMode: params.authMode as TenantConfig["authMode"],
181
189
  scopes: params.scopes,
190
+ loopbackPort: params.loopbackPort,
182
191
  safetyLevel,
183
192
  permissions,
184
193
  };
@@ -211,8 +220,9 @@ export const teamsSetupTool = {
211
220
  tenantId: params.tenantId,
212
221
  clientId: params.clientId,
213
222
  clientSecret: params.clientSecret,
214
- authMode: (params.authMode as AccountConfig["authMode"]) ?? "device-code",
223
+ authMode: (params.authMode as AccountConfig["authMode"]) ?? "interactive",
215
224
  scopes: params.scopes,
225
+ loopbackPort: params.loopbackPort,
216
226
  safetyLevel: safetyLevel ?? "confirm",
217
227
  permissions,
218
228
  };