@patimweb/pi-teams 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,9 +51,16 @@ into a prompt it answers — as you, in the chat it arrived in.
51
51
  /teams-listen off
52
52
  ```
53
53
 
54
+ Listen mode never starts on its own. It belongs to the session it was switched
55
+ on in and ends with it: quitting pi closes the watcher, and `/new` or `/resume`
56
+ stops it too. A stored `enabled: true` is a setting, not an instruction — set
57
+ `watch.autoStart` if a session is meant to start listening by itself, which is
58
+ only sensible for an unattended one.
59
+
54
60
  ```json
55
61
  "watch": {
56
- "enabled": true,
62
+ "enabled": false,
63
+ "autoStart": false,
57
64
  "intervalSeconds": 60,
58
65
  "chats": ["Anna*", "Vertrieb*"],
59
66
  "from": ["anna.schmidt@contoso.com"],
@@ -65,7 +72,8 @@ into a prompt it answers — as you, in the chat it arrived in.
65
72
 
66
73
  | Key | Default | Meaning |
67
74
  |-----|---------|---------|
68
- | `enabled` | `false` | whether the watcher runs |
75
+ | `enabled` | `false` | whether listen mode is switched on |
76
+ | `autoStart` | `false` | whether a pi session may start the watcher without being asked |
69
77
  | `intervalSeconds` | `60` | seconds between polls (minimum 15) |
70
78
  | `chats` | `[]` | **where** pi listens: glob patterns matched against topic, label, chat ID and participant names. Empty means every recent chat. |
71
79
  | `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail. Empty means any sender. |
@@ -603,8 +611,9 @@ teams_send_channel_message:
603
611
 
604
612
  Both the config and the token cache are written through a private temp file and
605
613
  an atomic rename, so an interrupted write cannot truncate them. The watch
606
- cursor is what makes listen mode survive a restart, and deleting it only costs
607
- one re-examination of the chats in the list — it is not silently re-answering
614
+ cursor is what keeps listen mode from re-answering what it has already seen, and
615
+ deleting it only costs one re-examination of the chats in the list — it is not
616
+ silently re-answering
608
617
  anything, because the decision also asks Teams whether the message is still
609
618
  unread. To revoke pi's access entirely, run `teams_logout` with `all: true` and
610
619
  remove the app's consent in Entra ID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -99,6 +99,14 @@ export interface TenantConfig {
99
99
  export interface WatchConfig {
100
100
  /** Whether the watcher runs at all (default: false) */
101
101
  enabled?: boolean;
102
+ /**
103
+ * Whether a pi session may start the watcher by itself (default: false).
104
+ *
105
+ * Off means listen mode only ever starts when it is switched on in that
106
+ * session. A stored setting is not a decision: an agent that answers in your
107
+ * name should not begin doing so because a file says it may.
108
+ */
109
+ autoStart?: boolean;
102
110
  /** Seconds between polls (default: 60, minimum: 15) */
103
111
  intervalSeconds?: number;
104
112
  /**
@@ -126,6 +134,7 @@ export interface WatchConfig {
126
134
  /** A watch config after every default and clamp has been applied. */
127
135
  export interface ResolvedWatchConfig {
128
136
  enabled: boolean;
137
+ autoStart: boolean;
129
138
  intervalSeconds: number;
130
139
  chats: string[];
131
140
  from: string[];
@@ -309,6 +318,7 @@ const DEFAULTS = {
309
318
  */
310
319
  export const WATCH_DEFAULTS: ResolvedWatchConfig = {
311
320
  enabled: false,
321
+ autoStart: false,
312
322
  intervalSeconds: 60,
313
323
  chats: [],
314
324
  from: [],
@@ -721,6 +731,7 @@ export function resolveWatchConfig(
721
731
 
722
732
  return {
723
733
  enabled: pick("enabled") ?? WATCH_DEFAULTS.enabled,
734
+ autoStart: pick("autoStart") ?? WATCH_DEFAULTS.autoStart,
724
735
  intervalSeconds: clamp(pick("intervalSeconds"), WATCH_BOUNDS.intervalSeconds, WATCH_DEFAULTS.intervalSeconds),
725
736
  chats: normalizePatterns(pick("chats")),
726
737
  from: normalizePatterns(pick("from")),
@@ -16,7 +16,13 @@ import {
16
16
  tryResolveConnection,
17
17
  type TeamsConnection,
18
18
  } from "../config/index.ts";
19
- import { buildConnectionCard, buildConnectionLabel, formatStatusText, type ConnectionCard } from "../status.ts";
19
+ import {
20
+ buildConnectionCard,
21
+ buildConnectionLabel,
22
+ buildStartupNotice,
23
+ formatStatusText,
24
+ type ConnectionCard,
25
+ } from "../status.ts";
20
26
  import {
21
27
  blockReason,
22
28
  formatMutationSummary,
@@ -167,12 +173,19 @@ export default function (pi: ExtensionAPI) {
167
173
  };
168
174
 
169
175
  /**
170
- * Start the watcher for the session's account, if the config asks for it.
176
+ * Start the watcher for the session's account — but only when it was asked for.
177
+ *
178
+ * `explicit` is the whole safety property. Switching listen mode on is a
179
+ * decision, so only the two places the user makes it — `/teams-listen on` and
180
+ * `teams_watch action: enable` — pass `explicit: true`. Everything else may
181
+ * only stop the loop. Without that split, "re-read the config" would be a way
182
+ * for a stored setting to switch itself back on, which is precisely how a
183
+ * listener nobody started turns up, polling and answering in your name.
171
184
  *
172
185
  * Restarting is the same call: the old loop is stopped first, so a config
173
186
  * change cannot leave two pollers running.
174
187
  */
175
- const startWatch = (ctx: any) => {
188
+ const startWatch = (ctx: any, options: { explicit?: boolean } = {}) => {
176
189
  const conn = refresh();
177
190
 
178
191
  // Already watching this exact configuration: leave the loop alone, so its
@@ -183,6 +196,7 @@ export default function (pi: ExtensionAPI) {
183
196
  stopWatch();
184
197
 
185
198
  if (!conn?.watch.enabled) return;
199
+ if (!options.explicit) return;
186
200
 
187
201
  reportedWatchError = undefined;
188
202
  const loop = startWatchLoop({
@@ -317,7 +331,7 @@ export default function (pi: ExtensionAPI) {
317
331
  // Written to the account, not globally: the watcher listens as one
318
332
  // identity, and "which account" must not be a guess.
319
333
  const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
320
- startWatch(ctx);
334
+ startWatch(ctx, { explicit: true });
321
335
  paintStatus(ctx);
322
336
  ctx.ui.notify(
323
337
  watch.enabled
@@ -407,8 +421,12 @@ export default function (pi: ExtensionAPI) {
407
421
  const card = buildConnectionCard(connection);
408
422
  if (!card) return;
409
423
 
424
+ // A stored setting is not a decision. A session starts the watcher only when
425
+ // the configuration opts into it; otherwise listen mode waits until it is
426
+ // switched on in this session with `/teams-listen on`.
427
+ if (connection.watch.autoStart) startWatch(ctx, { explicit: true });
428
+
410
429
  paintStatus(ctx);
411
- startWatch(ctx);
412
430
 
413
431
  if (!card.signedIn) {
414
432
  ctx.ui.notify(
@@ -416,10 +434,11 @@ export default function (pi: ExtensionAPI) {
416
434
  "warning",
417
435
  );
418
436
  } else {
419
- ctx.ui.notify(
420
- `@patimweb/pi-teams loaded (${card.account}${card.user ? ` as ${card.user}` : ""}, safety: ${card.safetyLevel})`,
421
- "info",
422
- );
437
+ const notice = buildStartupNotice(card, {
438
+ listening: !!watchLoop?.status().running,
439
+ intervalSeconds: connection.watch.intervalSeconds,
440
+ });
441
+ ctx.ui.notify(notice.message, notice.level);
423
442
  }
424
443
 
425
444
  // Survives /reload, so the transcript keeps saying who pi is acting as.
@@ -535,7 +554,10 @@ export default function (pi: ExtensionAPI) {
535
554
  // errors; leaving it running would be noise, not service.
536
555
  stopWatch();
537
556
  } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
538
- startWatch(ctx);
557
+ // Only `enable` is a decision. A status call, or a re-read after
558
+ // teams_setup, may stop the loop but never start it.
559
+ const action = (event.input as { action?: string } | undefined)?.action;
560
+ startWatch(ctx, { explicit: action === "enable" });
539
561
  }
540
562
 
541
563
  paintStatus(ctx);
package/src/status.ts CHANGED
@@ -90,6 +90,34 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
90
90
  return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
91
91
  }
92
92
 
93
+ /**
94
+ * The line a session starts with.
95
+ *
96
+ * It names listen mode on purpose. A session can be listening — polling, and
97
+ * answering in your name — so the transcript should never be silent about it.
98
+ *
99
+ * The level is part of the answer: being listened to is the state worth a louder
100
+ * notice, not the ordinary one of it being off.
101
+ */
102
+ export function buildStartupNotice(
103
+ card: ConnectionCard,
104
+ watch: { listening: boolean; intervalSeconds: number },
105
+ ): { message: string; level: "info" | "warning" } {
106
+ const identity = `@patimweb/pi-teams loaded (${card.account}${card.user ? ` as ${card.user}` : ""}, safety: ${card.safetyLevel})`;
107
+
108
+ // Reported from what is actually running, not from the stored setting: those
109
+ // two are no longer the same thing, and a notice that reported the file
110
+ // instead of the loop would be the very confusion it exists to prevent.
111
+ if (!watch.listening) {
112
+ return { message: `${identity} · listen mode off — /teams-listen on to start`, level: "info" };
113
+ }
114
+
115
+ return {
116
+ message: `${identity} · listen mode ON — every ${watch.intervalSeconds} s, /teams-listen off to stop`,
117
+ level: "warning",
118
+ };
119
+ }
120
+
93
121
  /** Rich markdown for `/teams-status` and the expanded card. */
94
122
  export function formatStatusText(card: ConnectionCard | undefined): string {
95
123
  if (!card) {
@@ -53,6 +53,7 @@ const CURSOR_NOTES = [
53
53
  /** Renders the effective settings the same way the config file would. */
54
54
  function describe(watch: {
55
55
  enabled: boolean;
56
+ autoStart: boolean;
56
57
  intervalSeconds: number;
57
58
  chats: string[];
58
59
  from: string[];
@@ -62,6 +63,7 @@ function describe(watch: {
62
63
  }): string {
63
64
  return [
64
65
  `- enabled: ${watch.enabled}`,
66
+ `- starts with a session: ${watch.autoStart}`,
65
67
  `- poll every: ${watch.intervalSeconds} s`,
66
68
  `- chats: ${watch.chats.length > 0 ? watch.chats.map((c) => `\`${c}\``).join(", ") : "every chat with recent activity"}`,
67
69
  `- people: ${watch.from.length > 0 ? watch.from.map((p) => `\`${p}\``).join(", ") : "any sender"}`,
@@ -82,8 +84,9 @@ function runtimeLines(runtime: WatchRuntimeStatus | undefined, conn: TeamsConnec
82
84
  return [
83
85
  "Nothing is polling in this session.",
84
86
  "",
85
- "Listen mode is read when a pi session starts. If the config says `enabled: true` and this "
86
- + "says otherwise, start a new session — or switch it with the `/teams-listen` command.",
87
+ "Listen mode starts only when it is switched on here — `/teams-listen on`, or "
88
+ + "`teams_watch action: enable`. A stored `enabled: true` does not start it by itself: "
89
+ + "set `watch.autoStart` if a session is meant to start listening on its own.",
87
90
  ];
88
91
  }
89
92