@patimweb/pi-teams 1.7.3 → 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.3",
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")),
@@ -173,12 +173,19 @@ export default function (pi: ExtensionAPI) {
173
173
  };
174
174
 
175
175
  /**
176
- * 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.
177
184
  *
178
185
  * Restarting is the same call: the old loop is stopped first, so a config
179
186
  * change cannot leave two pollers running.
180
187
  */
181
- const startWatch = (ctx: any) => {
188
+ const startWatch = (ctx: any, options: { explicit?: boolean } = {}) => {
182
189
  const conn = refresh();
183
190
 
184
191
  // Already watching this exact configuration: leave the loop alone, so its
@@ -189,6 +196,7 @@ export default function (pi: ExtensionAPI) {
189
196
  stopWatch();
190
197
 
191
198
  if (!conn?.watch.enabled) return;
199
+ if (!options.explicit) return;
192
200
 
193
201
  reportedWatchError = undefined;
194
202
  const loop = startWatchLoop({
@@ -323,7 +331,7 @@ export default function (pi: ExtensionAPI) {
323
331
  // Written to the account, not globally: the watcher listens as one
324
332
  // identity, and "which account" must not be a guess.
325
333
  const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
326
- startWatch(ctx);
334
+ startWatch(ctx, { explicit: true });
327
335
  paintStatus(ctx);
328
336
  ctx.ui.notify(
329
337
  watch.enabled
@@ -413,8 +421,12 @@ export default function (pi: ExtensionAPI) {
413
421
  const card = buildConnectionCard(connection);
414
422
  if (!card) return;
415
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
+
416
429
  paintStatus(ctx);
417
- startWatch(ctx);
418
430
 
419
431
  if (!card.signedIn) {
420
432
  ctx.ui.notify(
@@ -422,7 +434,10 @@ export default function (pi: ExtensionAPI) {
422
434
  "warning",
423
435
  );
424
436
  } else {
425
- const notice = buildStartupNotice(card, connection.watch);
437
+ const notice = buildStartupNotice(card, {
438
+ listening: !!watchLoop?.status().running,
439
+ intervalSeconds: connection.watch.intervalSeconds,
440
+ });
426
441
  ctx.ui.notify(notice.message, notice.level);
427
442
  }
428
443
 
@@ -539,7 +554,10 @@ export default function (pi: ExtensionAPI) {
539
554
  // errors; leaving it running would be noise, not service.
540
555
  stopWatch();
541
556
  } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
542
- 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" });
543
561
  }
544
562
 
545
563
  paintStatus(ctx);
package/src/status.ts CHANGED
@@ -93,21 +93,24 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
93
93
  /**
94
94
  * The line a session starts with.
95
95
  *
96
- * It names listen mode on purpose. The watcher starts from the saved setting, so
97
- * a session can begin polling — and answering in your name — without the
98
- * transcript ever saying so, which is how a listener nobody remembers starting
99
- * turns up, and how it gets to answer before anyone notices.
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.
100
98
  *
101
99
  * The level is part of the answer: being listened to is the state worth a louder
102
100
  * notice, not the ordinary one of it being off.
103
101
  */
104
102
  export function buildStartupNotice(
105
103
  card: ConnectionCard,
106
- watch: { enabled: boolean; intervalSeconds: number },
104
+ watch: { listening: boolean; intervalSeconds: number },
107
105
  ): { message: string; level: "info" | "warning" } {
108
106
  const identity = `@patimweb/pi-teams loaded (${card.account}${card.user ? ` as ${card.user}` : ""}, safety: ${card.safetyLevel})`;
109
107
 
110
- if (!watch.enabled) return { message: `${identity} · listen mode off`, level: "info" };
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
+ }
111
114
 
112
115
  return {
113
116
  message: `${identity} · listen mode ON — every ${watch.intervalSeconds} s, /teams-listen off to stop`,
@@ -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