@patimweb/pi-teams 1.13.0 → 1.15.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.
@@ -46,10 +46,11 @@ never sees.
46
46
  | `autoStart` | `false` | whether a session with a UI may start the watcher without being asked |
47
47
  | `intervalSeconds` | `60` | seconds between polls (minimum 15) |
48
48
  | `chats` | `[]` | **where** pi listens: glob patterns matched against topic, label, chat ID and participant names. Empty means every recent chat. |
49
- | `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail. Empty means any sender. |
49
+ | `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail — so `*@contoso.com` covers a whole domain. Graph sends a sender without address; pi fills it in from the chat's member list or the directory. Empty means any sender. |
50
50
  | `mentionOnly` | `false` | whether a message has to address you: `true`/`false`, or `{ default, chats, people }` for overrides. See [below](#who-has-to-address-pi). |
51
51
  | `cooldownSeconds` | `300` | stay quiet in a chat after waking pi for it |
52
52
  | `maxTriggersPerHour` | `10` | hard cap on wakes per hour |
53
+ | `dispatch` | `{ "mode": "session" }` | where a wake is answered: in this session, or by one pi process per chat. See [below](#one-pi-process-per-chat). |
53
54
 
54
55
  Set it globally, or per account — the account level wins field by field.
55
56
  `teams_watch` writes the same keys; `/teams-listen` writes to the session's
@@ -152,6 +153,87 @@ untouched, because a gate that blocked ordinary work would be met far more often
152
153
  than an attack. It is released when the turn ends, and expires by itself after
153
154
  15 minutes so a turn that never ends cannot lock you out of your own tools.
154
155
 
156
+ The pin is taken when the wake's prompt actually **enters the transcript**, not
157
+ when the message is picked up. A wake that arrives while pi is still answering
158
+ another chat waits in the queue; pinning it right away would take the pin away
159
+ from the answer that is still being written, and that answer would be refused.
160
+
161
+ In [dispatch mode](#one-pi-process-per-chat) every worker is pinned to its chat
162
+ for its whole life: it exists to answer that one conversation.
163
+
164
+ ## One pi process per chat
165
+
166
+ By default every wake is a follow-up in the session that runs the watcher. That
167
+ session works on one chat at a time: while it answers Anna, Bob waits, and when
168
+ Anna asked for something that takes twenty minutes, Bob waits twenty minutes.
169
+
170
+ `dispatch.mode: "process"` hands every chat a pi process of its own:
171
+
172
+ ```json
173
+ "watch": {
174
+ "dispatch": {
175
+ "mode": "process",
176
+ "maxConcurrent": 3,
177
+ "maxWorkers": 6,
178
+ "idleMinutes": 30,
179
+ "freshAfterHours": 72,
180
+ "args": ["--model", "provider/model-id"],
181
+ "stopWords": ["stop", "stopp", "abbrechen"],
182
+ "resetWords": ["neues thema", "new topic"],
183
+ "historyReaders": ["you@contoso.com"]
184
+ }
185
+ }
186
+ ```
187
+
188
+ | Key | Default | Meaning |
189
+ |-----|---------|---------|
190
+ | `mode` | `"session"` | `"process"` switches the worker pool on |
191
+ | `maxConcurrent` | `3` | chats worked on at the same time (1-16); more wait in line |
192
+ | `maxWorkers` | `6` | processes kept alive, busy or idle; the longest-idle one makes room |
193
+ | `idleMinutes` | `30` | an idle worker exits after this; its session stays on disk |
194
+ | `freshAfterHours` | `72` | a chat quiet for longer starts a fresh session (`0` = always continue) |
195
+ | `command` | `"pi"` | the pi executable |
196
+ | `args` | `[]` | extra arguments for every worker, e.g. the model |
197
+ | `stopWords` | `stop, stopp, abbrechen, abbruch, cancel` | a message that is only this word aborts the running work |
198
+ | `resetWords` | `neues thema, new topic, reset` | a message that is only this word starts the chat over |
199
+ | `historyReaders` | `[]` | who may use `teams_history` from their one-to-one chat with pi |
200
+
201
+ How it behaves:
202
+
203
+ | In Teams | What happens |
204
+ |----------|--------------|
205
+ | Messages in different chats | answered in parallel, each by its chat's worker |
206
+ | Next message in the same chat | the chat's session continues, with the whole conversation so far |
207
+ | A message while the worker is busy | steered into the running turn: read after the current step, not after the task |
208
+ | `stopp` | the running turn is aborted; the model confirms in one line |
209
+ | `neues Thema` | the worker ends, the chat starts a fresh session; the old one stays on disk |
210
+ | More busy chats than `maxConcurrent` | the next chat waits for a free slot; a newer message replaces its waiting one |
211
+
212
+ Each worker runs `pi --mode rpc --session-dir <agent dir>/pi-teams-chats/<hash>`
213
+ with `--continue` while the chat's last session is recent. It loads the same
214
+ extensions and config as any pi, with three differences set through its
215
+ environment (`PI_TEAMS_WORKER_CHAT` and friends): it never starts a watcher, it
216
+ may write to its own chat only, and `teams_watch`, `teams_setup` and
217
+ `teams_logout` are closed to it. Dialogs a worker would show are cancelled,
218
+ because nobody sits in front of it, so run it with `safetyLevel: "open"` and
219
+ keep the guardrails in `from`, the pin and the permission rules.
220
+
221
+ The watching session only routes. `/teams-dispatch` shows which chats are being
222
+ worked on, which are idle and which wait.
223
+
224
+ ### What pi said in other chats
225
+
226
+ Separate sessions mean that no single context knows what pi told somebody else.
227
+ Two logs do, and `teams_history` reads them:
228
+
229
+ - `kind: "sent"`: every message pi sent, from the audit log (`audit: true`).
230
+ - `kind: "dispatch"`: every request a worker picked up and a short form of its
231
+ answer, from `pi-teams-dispatch.jsonl` in the agent directory.
232
+
233
+ Nothing from one chat reaches another chat's context by itself. Inside a worker
234
+ the tool answers only in a one-to-one chat with a person listed in
235
+ `historyReaders`; the watching session is never restricted.
236
+
155
237
  ## Cost
156
238
 
157
239
  Each wake costs a model turn. That is what the filters and the hourly cap are
package/docs/tools.md CHANGED
@@ -21,6 +21,7 @@ the default account. See [configuration.md](configuration.md).
21
21
  | Tool | Description |
22
22
  |------|-------------|
23
23
  | `teams_inbox` | Chats with new messages plus recent mentions of you |
24
+ | `teams_history` | What pi sent across chats (audit log), or what the per-chat workers were asked and answered (dispatch journal). Read-only; see [listen mode](listen-mode.md#what-pi-said-in-other-chats) |
24
25
  | `teams_list_chats` | Recent chats with a preview of the last message |
25
26
  | `teams_read_chat` | Messages of one chat |
26
27
  | `teams_list_teams` | Teams you belong to |
@@ -74,6 +75,7 @@ usual; correcting a channel post stays in the Teams client.
74
75
  | `/teams-login` | Sign in to Teams as yourself |
75
76
  | `/teams-inbox` | What needs your attention right now |
76
77
  | `/teams-listen` | Listen mode: `on`, `off`, or `status` |
78
+ | `/teams-dispatch` | Listen mode with one pi process per chat: who is being worked on, who is idle, who waits |
77
79
  | `/teams-permissions` | What pi may and may not do |
78
80
  | `/teams-doctor` | Diagnose the setup |
79
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",
@@ -135,6 +135,70 @@ export interface WatchConfig {
135
135
  cooldownSeconds?: number;
136
136
  /** Hard cap on wakes per hour, across all chats (default: 10) */
137
137
  maxTriggersPerHour?: number;
138
+ /**
139
+ * How a wake reaches a model. See `DispatchConfig`. Absent means the
140
+ * classic behaviour: every wake is a turn in this pi session.
141
+ */
142
+ dispatch?: DispatchConfig;
143
+ }
144
+
145
+ /**
146
+ * Where the answer to a wake is worked out.
147
+ *
148
+ * `session` (default) queues every wake as a follow-up in the pi session that
149
+ * runs the watcher. One chat at a time: while pi works on one request, every
150
+ * other chat waits.
151
+ *
152
+ * `process` gives every chat a pi process of its own (`pi --mode rpc`), with a
153
+ * session of its own that carries on from message to message. Different chats
154
+ * are answered in parallel; messages in the same chat stay in order, and one
155
+ * that arrives while the worker is busy is steered into the running turn. The
156
+ * watching session itself only routes.
157
+ */
158
+ export interface DispatchConfig {
159
+ /** "session" (default) or "process" */
160
+ mode?: DispatchMode;
161
+ /** Chats worked on at the same time (default: 3, 1-16) */
162
+ maxConcurrent?: number;
163
+ /** Worker processes kept alive, busy or idle (default: 6, at least maxConcurrent, max 32) */
164
+ maxWorkers?: number;
165
+ /** An idle worker exits after this many minutes; its session stays (default: 30) */
166
+ idleMinutes?: number;
167
+ /**
168
+ * A chat quiet for longer than this starts a fresh session instead of
169
+ * continuing the last one (default: 72, 0 = always continue).
170
+ */
171
+ freshAfterHours?: number;
172
+ /** The pi executable (default: "pi") */
173
+ command?: string;
174
+ /** Extra arguments for every worker, e.g. ["--model", "provider/id"] */
175
+ args?: string[];
176
+ /** A message consisting of one of these aborts the running work (case-insensitive) */
177
+ stopWords?: string[];
178
+ /** A message consisting of one of these starts a fresh session for the chat */
179
+ resetWords?: string[];
180
+ /**
181
+ * People who may, from their one-to-one chat with pi, read what pi wrote in
182
+ * other chats (`teams_history`). Matched like `from`. Empty means nobody;
183
+ * the watching session itself is never restricted.
184
+ */
185
+ historyReaders?: string[];
186
+ }
187
+
188
+ export type DispatchMode = "session" | "process";
189
+
190
+ /** A dispatch config after every default and clamp has been applied. */
191
+ export interface ResolvedDispatchConfig {
192
+ mode: DispatchMode;
193
+ maxConcurrent: number;
194
+ maxWorkers: number;
195
+ idleMinutes: number;
196
+ freshAfterHours: number;
197
+ command: string;
198
+ args: string[];
199
+ stopWords: string[];
200
+ resetWords: string[];
201
+ historyReaders: string[];
138
202
  }
139
203
 
140
204
  /**
@@ -189,6 +253,7 @@ export interface ResolvedWatchConfig {
189
253
  mentionOnly: ResolvedMentionOnly;
190
254
  cooldownSeconds: number;
191
255
  maxTriggersPerHour: number;
256
+ dispatch: ResolvedDispatchConfig;
192
257
  }
193
258
 
194
259
  /**
@@ -384,6 +449,25 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
384
449
  mentionOnly: { default: false, chats: [], people: [] },
385
450
  cooldownSeconds: 300,
386
451
  maxTriggersPerHour: 10,
452
+ dispatch: {
453
+ mode: "session",
454
+ maxConcurrent: 3,
455
+ maxWorkers: 6,
456
+ idleMinutes: 30,
457
+ freshAfterHours: 72,
458
+ command: "pi",
459
+ args: [],
460
+ stopWords: ["stop", "stopp", "abbrechen", "abbruch", "cancel"],
461
+ resetWords: ["neues thema", "new topic", "reset"],
462
+ historyReaders: [],
463
+ },
464
+ };
465
+
466
+ const DISPATCH_BOUNDS = {
467
+ maxConcurrent: { min: 1, max: 16 },
468
+ maxWorkers: { min: 1, max: 32 },
469
+ idleMinutes: { min: 1, max: 1440 },
470
+ freshAfterHours: { min: 0, max: 8760 },
387
471
  };
388
472
 
389
473
  /**
@@ -802,6 +886,36 @@ export function resolveWatchConfig(
802
886
  WATCH_BOUNDS.maxTriggersPerHour,
803
887
  WATCH_DEFAULTS.maxTriggersPerHour,
804
888
  ),
889
+ dispatch: resolveDispatchConfig(pick("dispatch")),
890
+ };
891
+ }
892
+
893
+ /**
894
+ * Normalise the dispatch settings. Exported for tests.
895
+ *
896
+ * `maxWorkers` is raised to `maxConcurrent` when set lower: a worker that is
897
+ * busy is alive, so fewer live workers than parallel chats is not a limit but
898
+ * a contradiction.
899
+ */
900
+ export function resolveDispatchConfig(value: DispatchConfig | undefined): ResolvedDispatchConfig {
901
+ const d = WATCH_DEFAULTS.dispatch;
902
+ const v = value && typeof value === "object" && !Array.isArray(value) ? value : {};
903
+ const words = (list: unknown, fallback: string[]): string[] =>
904
+ Array.isArray(list)
905
+ ? list.filter((w): w is string => typeof w === "string" && w.trim() !== "").map((w) => w.trim().toLowerCase())
906
+ : fallback;
907
+ const maxConcurrent = clamp(v.maxConcurrent, DISPATCH_BOUNDS.maxConcurrent, d.maxConcurrent);
908
+ return {
909
+ mode: v.mode === "process" ? "process" : "session",
910
+ maxConcurrent,
911
+ maxWorkers: Math.max(maxConcurrent, clamp(v.maxWorkers, DISPATCH_BOUNDS.maxWorkers, d.maxWorkers)),
912
+ idleMinutes: clamp(v.idleMinutes, DISPATCH_BOUNDS.idleMinutes, d.idleMinutes),
913
+ freshAfterHours: clamp(v.freshAfterHours, DISPATCH_BOUNDS.freshAfterHours, d.freshAfterHours),
914
+ command: typeof v.command === "string" && v.command.trim() ? v.command.trim() : d.command,
915
+ args: Array.isArray(v.args) ? v.args.filter((a): a is string => typeof a === "string") : [...d.args],
916
+ stopWords: words(v.stopWords, d.stopWords),
917
+ resetWords: words(v.resetWords, d.resetWords),
918
+ historyReaders: normalizePatterns(v.historyReaders),
805
919
  };
806
920
  }
807
921
 
@@ -11,6 +11,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
11
11
 
12
12
  import {
13
13
  ensureConfigTemplate,
14
+ getAgentDir,
14
15
  getConfigPath,
15
16
  setWatchConfig,
16
17
  type ResolvedMentionOnly,
@@ -38,7 +39,11 @@ import {
38
39
  } from "../watch/loop.ts";
39
40
  import { composeWatchPrompt } from "../watch/prompt.ts";
40
41
  import { setChatReadState } from "../graph/chats.ts";
41
- import { clearWakeTarget, pinViolation, pinWakeTarget } from "../watch/pin.ts";
42
+ import { claimWakePin, clearWakeTarget, pinViolation, queueWakePin } from "../watch/pin.ts";
43
+ import { ChatDispatcher, excerpt } from "../watch/dispatch.ts";
44
+ import { controlAction } from "../watch/control.ts";
45
+ import { workerIdentity, workerViolation } from "../watch/worker.ts";
46
+ import { withSenderAddresses } from "../watch/index.ts";
42
47
  import { readWatchCursor, writeWatchCursor } from "../watch/cursor.ts";
43
48
 
44
49
  import { teamsSetupTool } from "../tools/teams-setup.ts";
@@ -71,6 +76,7 @@ import { teamsChatMembersTool } from "../tools/teams-chat-members.ts";
71
76
  import { teamsAvailabilityTool } from "../tools/teams-availability.ts";
72
77
  import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
73
78
  import { teamsInboxTool } from "../tools/teams-inbox.ts";
79
+ import { teamsHistoryTool } from "../tools/teams-history.ts";
74
80
  import { teamsWatchTool } from "../tools/teams-watch.ts";
75
81
  import {
76
82
  teamsGetPresenceTool,
@@ -137,6 +143,7 @@ const tools = [
137
143
  teamsUpdateMessageTool,
138
144
  teamsDeleteMessageTool,
139
145
  teamsInboxTool,
146
+ teamsHistoryTool,
140
147
  // Presence
141
148
  teamsGetPresenceTool,
142
149
  teamsSetPresenceTool,
@@ -178,6 +185,37 @@ export default function (pi: ExtensionAPI) {
178
185
  */
179
186
  let watchSignature: string | undefined;
180
187
 
188
+ /**
189
+ * Set when this process is a chat worker started by a dispatcher: it answers
190
+ * one chat, never watches, and may write nowhere else (see ../watch/worker.ts).
191
+ */
192
+ const worker = workerIdentity();
193
+
194
+ /** The per-chat worker pool, in dispatch mode `process`. Created on the first wake. */
195
+ let dispatcher: ChatDispatcher | undefined;
196
+ /** The context the dispatcher reports problems to. */
197
+ let dispatchCtx: any;
198
+
199
+ const dispatcherFor = (conn: TeamsConnection, ctx: any): ChatDispatcher => {
200
+ dispatchCtx = ctx;
201
+ if (dispatcher) {
202
+ dispatcher.configure(conn.watch.dispatch);
203
+ return dispatcher;
204
+ }
205
+ dispatcher = new ChatDispatcher({
206
+ config: conn.watch.dispatch,
207
+ agentDir: getAgentDir(),
208
+ cwd: ctx?.cwd ?? process.cwd(),
209
+ onError: (message) => dispatchCtx?.ui?.notify?.(message, "warning"),
210
+ });
211
+ return dispatcher;
212
+ };
213
+
214
+ const stopDispatcher = () => {
215
+ dispatcher?.stopAll();
216
+ dispatcher = undefined;
217
+ };
218
+
181
219
  /** The last polling error the user was told about, so it is said once. */
182
220
  let reportedWatchError: string | undefined;
183
221
 
@@ -222,10 +260,6 @@ export default function (pi: ExtensionAPI) {
222
260
  cursor: readWatchCursor(conn.account, conn.tenantId),
223
261
  persistCursor: (seen) => writeWatchCursor(conn.account, conn.tenantId, seen),
224
262
  onWake: (event) => {
225
- // Pin before the prompt is delivered: the turn it starts may only
226
- // answer the chat that woke pi, and the message it is about to read
227
- // was written by somebody else.
228
- pinWakeTarget(event.chat.id, event.chat.label);
229
263
  // Mark the chat read the moment pi picks it up: with read receipts on,
230
264
  // the sender sees the "seen" eye right away — the closest thing to a
231
265
  // typing indicator Graph offers (there is no typing API for users).
@@ -233,7 +267,30 @@ export default function (pi: ExtensionAPI) {
233
267
  if (event.me?.id) {
234
268
  void setChatReadState(conn, event.chat.id, event.me, true).catch(() => undefined);
235
269
  }
236
- pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
270
+ const prompt = composeWatchPrompt(event, event.me);
271
+
272
+ // Dispatch mode: the chat's own worker answers, in parallel with other chats.
273
+ if (conn.watch.dispatch.mode === "process") {
274
+ const message = withSenderAddresses(event.message, event.chat);
275
+ dispatcherFor(conn, ctx).deliver({
276
+ chatId: event.chat.id,
277
+ label: event.chat.label,
278
+ chatType: event.chat.chatType,
279
+ from: message.from?.displayName,
280
+ peer: message.from?.mail ?? message.from?.upn,
281
+ prompt,
282
+ request: excerpt(message.text),
283
+ control: controlAction(message, conn.watch.dispatch, event.me?.displayName),
284
+ });
285
+ return;
286
+ }
287
+
288
+ // Session mode: the turn this prompt starts may only answer the chat
289
+ // that woke pi. The pin is taken when the prompt enters the transcript,
290
+ // not now: while pi is busy the prompt waits in the queue, and pinning
291
+ // here would take the pin away from the answer still being written.
292
+ queueWakePin(prompt, event.chat.id, event.chat.label);
293
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
237
294
  },
238
295
  onTick: (status) => {
239
296
  if (!status.lastError) reportedWatchError = undefined;
@@ -384,12 +441,40 @@ export default function (pi: ExtensionAPI) {
384
441
  conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
385
442
  mentionSummary(conn.watch.mentionOnly),
386
443
  runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
444
+ conn.watch.dispatch.mode === "process"
445
+ ? `one process per chat (${dispatcher?.status().busy ?? 0}/${conn.watch.dispatch.maxConcurrent} busy)`
446
+ : "answers in this session",
387
447
  ].join(" · "),
388
448
  "info",
389
449
  );
390
450
  },
391
451
  });
392
452
 
453
+ pi.registerCommand("teams-dispatch", {
454
+ description: "Show the per-chat workers of listen mode (dispatch mode process)",
455
+ handler: async (_args, ctx) => {
456
+ const conn = refresh();
457
+ if (!conn || conn.watch.dispatch.mode !== "process") {
458
+ ctx.ui.notify("Teams dispatch: off. Every wake is a turn in this session (watch.dispatch.mode).", "info");
459
+ return;
460
+ }
461
+ const status = dispatcher?.status();
462
+ if (!status || status.alive + status.queued === 0) {
463
+ ctx.ui.notify(`Teams dispatch: no worker running (max ${conn.watch.dispatch.maxConcurrent} in parallel).`, "info");
464
+ return;
465
+ }
466
+ const minutes = (ms: number) => `${Math.max(0, Math.round(ms / 60_000))} min`;
467
+ ctx.ui.notify(
468
+ [
469
+ `Teams dispatch: ${status.busy}/${status.maxConcurrent} busy, ${status.alive} alive, ${status.queued} waiting`,
470
+ ...status.chats.map((c) => `- ${c.label}: ${c.busy ? `working for ${minutes(c.sinceMs)}` : `idle for ${minutes(c.sinceMs)}`}`),
471
+ ...status.waiting.map((c) => `- ${c.label}: waiting for a slot`),
472
+ ].join("\n"),
473
+ "info",
474
+ );
475
+ },
476
+ });
477
+
393
478
  pi.registerCommand("teams-inbox", {
394
479
  description: "Show what needs your attention in Microsoft Teams",
395
480
  handler: async (_args, ctx) => {
@@ -456,7 +541,9 @@ export default function (pi: ExtensionAPI) {
456
541
  // and the same config: autoStart there would run a second watcher that
457
542
  // answers chats inside a throwaway process and advances the shared cursor,
458
543
  // so the real session never sees those messages.
459
- if (connection.watch.autoStart && ctx.hasUI) startWatch(ctx, { explicit: true });
544
+ // A chat worker (--mode rpc, so hasUI is true) is excluded by name: it is
545
+ // started by the watcher and must never become one.
546
+ if (connection.watch.autoStart && ctx.hasUI && !worker) startWatch(ctx, { explicit: true });
460
547
 
461
548
  paintStatus(ctx);
462
549
 
@@ -515,6 +602,26 @@ export default function (pi: ExtensionAPI) {
515
602
  // The timer would be unref'd anyway, but a poll in flight during shutdown
516
603
  // would still be a Graph call for a session that is gone.
517
604
  stopWatch();
605
+ // Workers end with the session that started them; their sessions stay on disk.
606
+ stopDispatcher();
607
+ });
608
+
609
+ // A queued wake prompt has entered the transcript: its turn starts now, so
610
+ // this is when the answer gets pinned to its chat.
611
+ pi.on("message_start", async (event: any) => {
612
+ const message = event?.message;
613
+ if (message?.role !== "user") return;
614
+ const content = message.content;
615
+ const text =
616
+ typeof content === "string"
617
+ ? content
618
+ : Array.isArray(content)
619
+ ? content
620
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
621
+ .map((part: any) => part.text)
622
+ .join("")
623
+ : "";
624
+ if (text) claimWakePin(text);
518
625
  });
519
626
 
520
627
  // -----------------------------------------------------------------------
@@ -528,6 +635,9 @@ export default function (pi: ExtensionAPI) {
528
635
  });
529
636
 
530
637
  pi.on("tool_call", async (event, ctx) => {
638
+ const workerBlock = workerViolation(event.toolName, worker);
639
+ if (workerBlock) return { block: true, reason: workerBlock };
640
+
531
641
  if (!isMutationTool(event.toolName)) return;
532
642
 
533
643
  // A tool may target a different account than the session default, so the
@@ -596,6 +706,7 @@ export default function (pi: ExtensionAPI) {
596
706
  // A watcher polling a session that was just removed only produces
597
707
  // errors; leaving it running would be noise, not service.
598
708
  stopWatch();
709
+ stopDispatcher();
599
710
  } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
600
711
  // Only `enable` is a decision. A status call, or a re-read after
601
712
  // teams_setup, may stop the loop but never start it.
@@ -52,7 +52,8 @@ export function mapPerson(raw: Raw | undefined): PersonRef | undefined {
52
52
  const name = identity.displayName ?? identity.userPrincipalName ?? identity.email;
53
53
  if (!name && !identity.id) return undefined;
54
54
  return {
55
- id: identity.id ?? undefined,
55
+ // A chat member's `id` is the *membership* id; the person is `userId`.
56
+ id: identity.userId ?? identity.id ?? undefined,
56
57
  displayName: name ?? "(unknown)",
57
58
  upn: identity.userPrincipalName ?? undefined,
58
59
  mail: identity.email ?? identity.mail ?? undefined,
package/src/graph/me.ts CHANGED
@@ -20,6 +20,26 @@ export async function getMe(conn: TeamsConnection, signal?: AbortSignal): Promis
20
20
  return { ...user, tenantId: conn.tenantId };
21
21
  }
22
22
 
23
+ /**
24
+ * UPN and mail of one user, by object id.
25
+ *
26
+ * A chat message's sender carries only id and display name, so a listen rule
27
+ * like `*@contoso.com` has nothing to match against without this lookup.
28
+ */
29
+ export async function getUserAddresses(
30
+ conn: TeamsConnection,
31
+ userId: string,
32
+ signal?: AbortSignal,
33
+ ): Promise<{ upn?: string; mail?: string }> {
34
+ const raw = await graphRequest<Record<string, string | null | undefined>>(
35
+ conn,
36
+ "GET",
37
+ `/users/${encodeURIComponent(userId)}`,
38
+ { query: { $select: "userPrincipalName,mail" }, signal },
39
+ );
40
+ return { upn: raw?.userPrincipalName ?? undefined, mail: raw?.mail ?? undefined };
41
+ }
42
+
23
43
  /**
24
44
  * Find people by name, UPN or e-mail.
25
45
  *
@@ -0,0 +1,152 @@
1
+ /**
2
+ * teams_history — what pi wrote in Teams, and what the chat workers were asked.
3
+ *
4
+ * In dispatch mode every chat has a session of its own, so no single context
5
+ * knows what pi said elsewhere. This tool answers "what did you tell Anna
6
+ * today?" from the two logs that do know: the audit log (every message pi
7
+ * sent, see ../safety/audit.ts) and the dispatch journal (every request a chat
8
+ * worker picked up, and its answer, see ../watch/dispatch.ts).
9
+ *
10
+ * Reading across chats is exactly what the per-chat sessions exist to prevent
11
+ * by default, so inside a chat worker the tool answers only in a one-to-one
12
+ * chat with somebody listed in `watch.dispatch.historyReaders`. The watching
13
+ * session — the user at the keyboard — is never restricted.
14
+ */
15
+
16
+ import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
17
+ import { Type } from "typebox";
18
+ import { getAgentDir } from "../config/index.ts";
19
+ import { matchesPattern } from "../config/scope.ts";
20
+ import { getAuditPath } from "../safety/audit.ts";
21
+ import { getDispatchJournalPath } from "../watch/dispatch.ts";
22
+ import { workerIdentity, type WorkerIdentity } from "../watch/worker.ts";
23
+ import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
24
+
25
+ /** How much of the end of a log is read. Old entries are the least asked for. */
26
+ const TAIL_BYTES = 2 * 1024 * 1024;
27
+
28
+ /** The last `TAIL_BYTES` of a JSONL file, parsed. Broken lines are skipped. */
29
+ export function readJsonlTail(path: string, maxBytes = TAIL_BYTES): Record<string, any>[] {
30
+ if (!existsSync(path)) return [];
31
+ const size = statSync(path).size;
32
+ const start = Math.max(0, size - maxBytes);
33
+ const buffer = Buffer.alloc(size - start);
34
+ const fd = openSync(path, "r");
35
+ try {
36
+ readSync(fd, buffer, 0, buffer.length, start);
37
+ } finally {
38
+ closeSync(fd);
39
+ }
40
+ const lines = buffer.toString("utf-8").split("\n");
41
+ if (start > 0) lines.shift(); // first line is cut
42
+ const entries: Record<string, any>[] = [];
43
+ for (const line of lines) {
44
+ if (!line.trim()) continue;
45
+ try {
46
+ entries.push(JSON.parse(line));
47
+ } catch {
48
+ /* skip */
49
+ }
50
+ }
51
+ return entries;
52
+ }
53
+
54
+ /**
55
+ * Why this process may not read across chats, or undefined when it may.
56
+ * Exported for tests.
57
+ */
58
+ export function historyRefusal(identity: WorkerIdentity | undefined, readers: string[]): string | undefined {
59
+ if (!identity) return undefined;
60
+ const allowed =
61
+ identity.chatType === "oneOnOne" &&
62
+ !!identity.peer &&
63
+ readers.some((pattern) => matchesPattern(pattern, identity.peer!));
64
+ if (allowed) return undefined;
65
+ return (
66
+ "What pi wrote in other chats is not available in this chat. Each chat keeps its own context, and only " +
67
+ "the people listed in watch.dispatch.historyReaders may look across chats, from their one-to-one chat with pi."
68
+ );
69
+ }
70
+
71
+ interface HistoryParams {
72
+ kind?: "sent" | "dispatch";
73
+ chat?: string;
74
+ sinceHours?: number;
75
+ limit?: number;
76
+ }
77
+
78
+ /** Filter and render log entries. Exported for tests. */
79
+ export function renderHistory(entries: Record<string, any>[], params: HistoryParams, now = Date.now()): string {
80
+ const kind = params.kind ?? "sent";
81
+ const limit = Math.min(Math.max(params.limit ?? 20, 1), 200);
82
+ const since = now - Math.min(Math.max(params.sinceHours ?? 24, 1), 24 * 90) * 3_600_000;
83
+ const needle = params.chat?.trim().toLowerCase();
84
+
85
+ const hits = entries.filter((entry) => {
86
+ const at = Date.parse(entry.at ?? "");
87
+ if (!Number.isFinite(at) || at < since) return false;
88
+ if (!needle) return true;
89
+ const haystack =
90
+ kind === "sent"
91
+ ? `${entry.target ?? ""} ${entry.summary ?? ""}`
92
+ : `${entry.chat ?? ""} ${entry.chatId ?? ""} ${entry.from ?? ""}`;
93
+ return haystack.toLowerCase().includes(needle);
94
+ });
95
+
96
+ const shown = hits.slice(-limit);
97
+ if (shown.length === 0) return kind === "sent" ? "No messages sent in that window." : "No dispatch entries in that window.";
98
+
99
+ const lines = shown.map((entry) => {
100
+ const at = new Date(entry.at).toLocaleString();
101
+ if (kind === "sent") {
102
+ const failed = entry.error ? ` (failed: ${entry.error})` : "";
103
+ return `- ${at} · ${entry.tool} → ${entry.target}: ${entry.summary}${failed}`;
104
+ }
105
+ const parts = [`- ${at} · ${entry.chat} · ${entry.event}`];
106
+ if (entry.from) parts.push(`from ${entry.from}`);
107
+ if (entry.request) parts.push(`asked: ${entry.request}`);
108
+ if (entry.result) parts.push(`answered: ${entry.result}`);
109
+ if (entry.durationMs) parts.push(`${Math.round(entry.durationMs / 1000)} s`);
110
+ if (entry.detail) parts.push(entry.detail);
111
+ return parts.join(" · ");
112
+ });
113
+ const more = hits.length > shown.length ? `\n\n(${hits.length - shown.length} older entries not shown)` : "";
114
+ return lines.join("\n") + more;
115
+ }
116
+
117
+ export const teamsHistoryTool = {
118
+ name: "teams_history",
119
+ description:
120
+ "Look up what pi did in Teams across chats: kind 'sent' lists messages pi sent (from the audit log), kind " +
121
+ "'dispatch' lists requests the per-chat workers picked up and what they answered. Use it when someone asks " +
122
+ "what pi told another person or chat. Read-only.",
123
+ parameters: Type.Object({
124
+ kind: Type.Optional(
125
+ Type.Union([Type.Literal("sent"), Type.Literal("dispatch")], {
126
+ description: "'sent' (default): messages pi sent. 'dispatch': requests and answers of the chat workers.",
127
+ }),
128
+ ),
129
+ chat: Type.Optional(Type.String({ description: "Only entries whose chat or person contains this text" })),
130
+ sinceHours: Type.Optional(Type.Number({ description: "How far back, in hours (default 24, max 2160)" })),
131
+ limit: Type.Optional(Type.Number({ description: "Most recent entries to show (default 20, max 200)" })),
132
+ }),
133
+ promptSnippet: "Look up what pi wrote in other Teams chats",
134
+
135
+ async execute(
136
+ _toolCallId: string,
137
+ params: HistoryParams,
138
+ _signal: AbortSignal | undefined,
139
+ _onUpdate: undefined,
140
+ ctx: ToolContext,
141
+ ): Promise<ToolResult> {
142
+ return run(async () => {
143
+ const readers = ctx.connection?.watch.dispatch.historyReaders ?? [];
144
+ const refusal = historyRefusal(workerIdentity(), readers);
145
+ if (refusal) return errorResult(refusal);
146
+
147
+ const path = (params.kind ?? "sent") === "sent" ? getAuditPath() : getDispatchJournalPath(getAgentDir());
148
+ const text = renderHistory(readJsonlTail(path), params);
149
+ return textResult(text, { path });
150
+ });
151
+ },
152
+ };