@patimweb/pi-teams 1.14.0 → 2.0.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.
@@ -50,6 +50,7 @@ never sees.
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
+ | `historyReaders` | `[]` | who may use `teams_history` from their one-to-one chat, where each chat has a process of its own. See [below](#parallel-chats-with-pi-lanes). |
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,48 @@ 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
+ A process that answers one chat only (see [below](#parallel-chats-with-pi-lanes))
162
+ is pinned to it for its whole life.
163
+
164
+ ## Parallel chats with pi-lanes
165
+
166
+ Every wake is a follow-up in the session that runs the watcher. That session
167
+ works on one chat at a time: while it answers Anna, Bob waits.
168
+
169
+ With [pi-lanes](https://github.com/Smotherer007/pi-lanes) installed, every
170
+ chat gets a pi process of its own, with a session that carries on from message
171
+ to message; different chats are answered in parallel. pi-teams does not do the
172
+ routing itself. Before each wake prompt it emits a hint on pi's event bus
173
+ (`pi-lanes:route`), and a router takes it from there. Without a router nobody
174
+ listens, and everything stays as described above.
175
+
176
+ The hint carries:
177
+
178
+ | Field | Value |
179
+ |-------|-------|
180
+ | `lane` | `teams:<account>:<chat id>`: same chat, same process and session |
181
+ | `env` | `PI_TEAMS_WORKER_CHAT` and friends (below) |
182
+ | `command` | the newest message without mention and punctuation, so "Neo, stopp!" reads as `stopp` |
183
+ | `trusted` | `true` only in a one-to-one chat with a person in `historyReaders` |
184
+
185
+ A process started with `PI_TEAMS_WORKER_CHAT` answers that chat only: it is
186
+ pinned to it for good, never starts a watcher of its own, and `teams_watch`,
187
+ `teams_setup` and `teams_logout` are closed to it. Listen mode also starts on
188
+ its own only in the terminal session (`ctx.mode === "tui"`), never in a pi
189
+ driven over RPC.
190
+
191
+ ### What pi said in other chats
192
+
193
+ Separate processes mean that no single context knows what pi told somebody
194
+ else. The audit log does (`audit: true`), and `teams_history` reads it. In a
195
+ process that answers one chat the tool works only in a one-to-one chat with a
196
+ person listed in `historyReaders`; the terminal session is never restricted.
197
+
155
198
  ## Cost
156
199
 
157
200
  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, from the audit log. 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.14.0",
3
+ "version": "2.0.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,13 @@ 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
+ * People who may, from their one-to-one chat with pi, read what pi wrote in
140
+ * other chats (`teams_history`). Matched like `from`. Empty means nobody.
141
+ * Only matters where a pi process answers a single chat, e.g. a lane run by
142
+ * pi-lanes; the session a person types in is never restricted.
143
+ */
144
+ historyReaders?: string[];
138
145
  }
139
146
 
140
147
  /**
@@ -189,6 +196,7 @@ export interface ResolvedWatchConfig {
189
196
  mentionOnly: ResolvedMentionOnly;
190
197
  cooldownSeconds: number;
191
198
  maxTriggersPerHour: number;
199
+ historyReaders: string[];
192
200
  }
193
201
 
194
202
  /**
@@ -384,6 +392,7 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
384
392
  mentionOnly: { default: false, chats: [], people: [] },
385
393
  cooldownSeconds: 300,
386
394
  maxTriggersPerHour: 10,
395
+ historyReaders: [],
387
396
  };
388
397
 
389
398
  /**
@@ -802,6 +811,7 @@ export function resolveWatchConfig(
802
811
  WATCH_BOUNDS.maxTriggersPerHour,
803
812
  WATCH_DEFAULTS.maxTriggersPerHour,
804
813
  ),
814
+ historyReaders: normalizePatterns(pick("historyReaders")),
805
815
  };
806
816
  }
807
817
 
@@ -34,11 +34,16 @@ import {
34
34
  setActiveWatchLoop,
35
35
  startWatchLoop,
36
36
  stopActiveWatchLoop,
37
+ type WatchEvent as WakeEvent,
37
38
  type WatchLoop,
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 { commandText } from "../watch/control.ts";
44
+ import { workerEnv, workerIdentity, workerViolation } from "../watch/worker.ts";
45
+ import { matchesPattern } from "../config/scope.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,
@@ -101,6 +107,48 @@ function mentionSummary(mentionOnly: ResolvedMentionOnly): string {
101
107
  return overrides > 0 ? `${base} (${overrides} override${overrides === 1 ? "" : "s"})` : base;
102
108
  }
103
109
 
110
+ /**
111
+ * The event-bus channel a lane router listens on (pi-lanes). Emitting on it is
112
+ * free when nobody listens, so pi-teams needs no dependency on the router.
113
+ */
114
+ const LANE_HINT_CHANNEL = "pi-lanes:route";
115
+
116
+ /** One line, at most `max` characters. */
117
+ function oneLine(text: string | undefined, max = 160): string | undefined {
118
+ const flat = text?.replace(/\s+/g, " ").trim();
119
+ if (!flat) return undefined;
120
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
121
+ }
122
+
123
+ /**
124
+ * The routing hint for one wake: which conversation it belongs to, and what
125
+ * the process that answers it must know about itself.
126
+ *
127
+ * - `lane` is the chat, per account: same chat, same lane and session.
128
+ * - `env` pins that process to the chat (../watch/worker.ts).
129
+ * - `command` is the newest message without mention and punctuation, so the
130
+ * router can recognise "stopp" or "neues Thema".
131
+ * - `trusted` is true only in a one-to-one chat with a `historyReaders` entry:
132
+ * the only place where looking at other conversations is allowed.
133
+ */
134
+ export function laneHint(conn: TeamsConnection, event: WakeEvent, prompt: string) {
135
+ const message = withSenderAddresses(event.message, event.chat);
136
+ const oneOnOne = event.chat.chatType === "oneOnOne";
137
+ const address = message.from?.mail ?? message.from?.upn;
138
+ const peer = oneOnOne ? address : undefined;
139
+ const trusted = !!peer && conn.watch.historyReaders.some((pattern) => matchesPattern(pattern, peer));
140
+ return {
141
+ text: prompt,
142
+ lane: `teams:${conn.account}:${event.chat.id}`,
143
+ label: `Teams: ${event.chat.label}`,
144
+ env: workerEnv({ chatId: event.chat.id, label: event.chat.label, chatType: event.chat.chatType, peer }),
145
+ command: commandText(message, event.me?.displayName),
146
+ request: oneLine(message.text),
147
+ from: message.from?.displayName,
148
+ trusted,
149
+ };
150
+ }
151
+
104
152
  /** Every tool, in the order they appear in the documentation. */
105
153
  const tools = [
106
154
  // Setup & session
@@ -137,6 +185,7 @@ const tools = [
137
185
  teamsUpdateMessageTool,
138
186
  teamsDeleteMessageTool,
139
187
  teamsInboxTool,
188
+ teamsHistoryTool,
140
189
  // Presence
141
190
  teamsGetPresenceTool,
142
191
  teamsSetPresenceTool,
@@ -178,6 +227,12 @@ export default function (pi: ExtensionAPI) {
178
227
  */
179
228
  let watchSignature: string | undefined;
180
229
 
230
+ /**
231
+ * Set when this process answers one chat only (a lane started for it, see
232
+ * ../watch/worker.ts): it never watches, and may write nowhere else.
233
+ */
234
+ const worker = workerIdentity();
235
+
181
236
  /** The last polling error the user was told about, so it is said once. */
182
237
  let reportedWatchError: string | undefined;
183
238
 
@@ -222,10 +277,6 @@ export default function (pi: ExtensionAPI) {
222
277
  cursor: readWatchCursor(conn.account, conn.tenantId),
223
278
  persistCursor: (seen) => writeWatchCursor(conn.account, conn.tenantId, seen),
224
279
  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
280
  // Mark the chat read the moment pi picks it up: with read receipts on,
230
281
  // the sender sees the "seen" eye right away — the closest thing to a
231
282
  // typing indicator Graph offers (there is no typing API for users).
@@ -233,7 +284,20 @@ export default function (pi: ExtensionAPI) {
233
284
  if (event.me?.id) {
234
285
  void setChatReadState(conn, event.chat.id, event.me, true).catch(() => undefined);
235
286
  }
236
- pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
287
+ const prompt = composeWatchPrompt(event, event.me);
288
+
289
+ // Tell a router which conversation this prompt belongs to. With
290
+ // pi-lanes installed, every chat is answered by a pi process of its
291
+ // own, in parallel with the others; without it nobody listens and the
292
+ // prompt takes the usual way into this session.
293
+ pi.events.emit(LANE_HINT_CHANNEL, laneHint(conn, event, prompt));
294
+
295
+ // The turn this prompt starts may only answer the chat that woke pi.
296
+ // The pin is taken when the prompt enters the transcript, not now:
297
+ // while pi is busy the prompt waits in the queue, and pinning here
298
+ // would take the pin away from the answer still being written.
299
+ queueWakePin(prompt, event.chat.id, event.chat.label);
300
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
237
301
  },
238
302
  onTick: (status) => {
239
303
  if (!status.lastError) reportedWatchError = undefined;
@@ -456,7 +520,10 @@ export default function (pi: ExtensionAPI) {
456
520
  // and the same config: autoStart there would run a second watcher that
457
521
  // answers chats inside a throwaway process and advances the shared cursor,
458
522
  // so the real session never sees those messages.
459
- if (connection.watch.autoStart && ctx.hasUI) startWatch(ctx, { explicit: true });
523
+ // A pi driven over RPC (a lane, an IDE) has a UI too, but nobody is
524
+ // typing in it: only the terminal session listens on its own.
525
+ const interactive = (ctx.mode ?? (ctx.hasUI ? "tui" : "print")) === "tui";
526
+ if (connection.watch.autoStart && interactive && !worker) startWatch(ctx, { explicit: true });
460
527
 
461
528
  paintStatus(ctx);
462
529
 
@@ -517,6 +584,24 @@ export default function (pi: ExtensionAPI) {
517
584
  stopWatch();
518
585
  });
519
586
 
587
+ // A queued wake prompt has entered the transcript: its turn starts now, so
588
+ // this is when the answer gets pinned to its chat.
589
+ pi.on("message_start", async (event: any) => {
590
+ const message = event?.message;
591
+ if (message?.role !== "user") return;
592
+ const content = message.content;
593
+ const text =
594
+ typeof content === "string"
595
+ ? content
596
+ : Array.isArray(content)
597
+ ? content
598
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
599
+ .map((part: any) => part.text)
600
+ .join("")
601
+ : "";
602
+ if (text) claimWakePin(text);
603
+ });
604
+
520
605
  // -----------------------------------------------------------------------
521
606
  // Safety interceptor
522
607
  // -----------------------------------------------------------------------
@@ -528,6 +613,9 @@ export default function (pi: ExtensionAPI) {
528
613
  });
529
614
 
530
615
  pi.on("tool_call", async (event, ctx) => {
616
+ const workerBlock = workerViolation(event.toolName, worker);
617
+ if (workerBlock) return { block: true, reason: workerBlock };
618
+
531
619
  if (!isMutationTool(event.toolName)) return;
532
620
 
533
621
  // A tool may target a different account than the session default, so the
@@ -0,0 +1,128 @@
1
+ /**
2
+ * teams_history — what pi wrote in Teams, across chats.
3
+ *
4
+ * When every chat is answered by a pi process of its own (a lane, e.g. with
5
+ * pi-lanes), no single context knows what pi said elsewhere. The audit log
6
+ * does (every message pi sent, see ../safety/audit.ts), and this tool reads it
7
+ * to answer "what did you tell Anna today?".
8
+ *
9
+ * Reading across chats is exactly what separate processes prevent by default,
10
+ * so in a process that answers one chat the tool works only in a one-to-one
11
+ * chat with somebody listed in `watch.historyReaders`. The session a person
12
+ * types in is never restricted.
13
+ */
14
+
15
+ import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
16
+ import { Type } from "typebox";
17
+ import { matchesPattern } from "../config/scope.ts";
18
+ import { getAuditPath } from "../safety/audit.ts";
19
+ import { workerIdentity, type WorkerIdentity } from "../watch/worker.ts";
20
+ import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
21
+
22
+ /** How much of the end of a log is read. Old entries are the least asked for. */
23
+ const TAIL_BYTES = 2 * 1024 * 1024;
24
+
25
+ /** The last `TAIL_BYTES` of a JSONL file, parsed. Broken lines are skipped. */
26
+ export function readJsonlTail(path: string, maxBytes = TAIL_BYTES): Record<string, any>[] {
27
+ if (!existsSync(path)) return [];
28
+ const size = statSync(path).size;
29
+ const start = Math.max(0, size - maxBytes);
30
+ const buffer = Buffer.alloc(size - start);
31
+ const fd = openSync(path, "r");
32
+ try {
33
+ readSync(fd, buffer, 0, buffer.length, start);
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ const lines = buffer.toString("utf-8").split("\n");
38
+ if (start > 0) lines.shift(); // first line is cut
39
+ const entries: Record<string, any>[] = [];
40
+ for (const line of lines) {
41
+ if (!line.trim()) continue;
42
+ try {
43
+ entries.push(JSON.parse(line));
44
+ } catch {
45
+ /* skip */
46
+ }
47
+ }
48
+ return entries;
49
+ }
50
+
51
+ /**
52
+ * Why this process may not read across chats, or undefined when it may.
53
+ * Exported for tests.
54
+ */
55
+ export function historyRefusal(identity: WorkerIdentity | undefined, readers: string[]): string | undefined {
56
+ if (!identity) return undefined;
57
+ const allowed =
58
+ identity.chatType === "oneOnOne" &&
59
+ !!identity.peer &&
60
+ readers.some((pattern) => matchesPattern(pattern, identity.peer!));
61
+ if (allowed) return undefined;
62
+ return (
63
+ "What pi wrote in other chats is not available in this chat. Each chat keeps its own context, and only " +
64
+ "the people listed in watch.historyReaders may look across chats, from their one-to-one chat with pi."
65
+ );
66
+ }
67
+
68
+ interface HistoryParams {
69
+ chat?: string;
70
+ sinceHours?: number;
71
+ limit?: number;
72
+ }
73
+
74
+ /** Filter and render log entries. Exported for tests. */
75
+ export function renderHistory(entries: Record<string, any>[], params: HistoryParams, now = Date.now()): string {
76
+ const limit = Math.min(Math.max(params.limit ?? 20, 1), 200);
77
+ const since = now - Math.min(Math.max(params.sinceHours ?? 24, 1), 24 * 90) * 3_600_000;
78
+ const needle = params.chat?.trim().toLowerCase();
79
+
80
+ const hits = entries.filter((entry) => {
81
+ const at = Date.parse(entry.at ?? "");
82
+ if (!Number.isFinite(at) || at < since) return false;
83
+ if (!needle) return true;
84
+ return `${entry.target ?? ""} ${entry.summary ?? ""}`.toLowerCase().includes(needle);
85
+ });
86
+
87
+ const shown = hits.slice(-limit);
88
+ if (shown.length === 0) return "No messages sent in that window.";
89
+
90
+ const lines = shown.map((entry) => {
91
+ const at = new Date(entry.at).toLocaleString();
92
+ const failed = entry.error ? ` (failed: ${entry.error})` : "";
93
+ return `- ${at} · ${entry.tool} → ${entry.target}: ${entry.summary}${failed}`;
94
+ });
95
+ const more = hits.length > shown.length ? `\n\n(${hits.length - shown.length} older entries not shown)` : "";
96
+ return lines.join("\n") + more;
97
+ }
98
+
99
+ export const teamsHistoryTool = {
100
+ name: "teams_history",
101
+ description:
102
+ "Look up what pi sent in Teams across chats (from the audit log). Use it when someone asks what pi told " +
103
+ "another person or chat. Read-only.",
104
+ parameters: Type.Object({
105
+ chat: Type.Optional(Type.String({ description: "Only entries whose chat or person contains this text" })),
106
+ sinceHours: Type.Optional(Type.Number({ description: "How far back, in hours (default 24, max 2160)" })),
107
+ limit: Type.Optional(Type.Number({ description: "Most recent entries to show (default 20, max 200)" })),
108
+ }),
109
+ promptSnippet: "Look up what pi wrote in other Teams chats",
110
+
111
+ async execute(
112
+ _toolCallId: string,
113
+ params: HistoryParams,
114
+ _signal: AbortSignal | undefined,
115
+ _onUpdate: undefined,
116
+ ctx: ToolContext,
117
+ ): Promise<ToolResult> {
118
+ return run(async () => {
119
+ const readers = ctx.connection?.watch.historyReaders ?? [];
120
+ const refusal = historyRefusal(workerIdentity(), readers);
121
+ if (refusal) return errorResult(refusal);
122
+
123
+ const path = getAuditPath();
124
+ const text = renderHistory(readJsonlTail(path), params);
125
+ return textResult(text, { path });
126
+ });
127
+ },
128
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Listen mode — the text of a trigger, as a command.
3
+ *
4
+ * A router such as pi-lanes can treat a short message as a control word
5
+ * ("stopp", "neues Thema") instead of a request. It needs the message the way
6
+ * the person meant it: without the mention of pi in front, without
7
+ * punctuation. This module produces exactly that, and nothing more; which
8
+ * words mean what is the router's business.
9
+ */
10
+
11
+ import type { MessageSummary } from "../types.ts";
12
+
13
+ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+
15
+ /** Lower-case the text and drop mentions, punctuation and extra spaces. */
16
+ export function normalizeCommand(text: string, names: string[] = []): string {
17
+ let result = ` ${text.toLowerCase()} `;
18
+ const sorted = [...new Set(names.map((n) => n.trim().toLowerCase()).filter(Boolean))].sort((a, b) => b.length - a.length);
19
+ for (const name of sorted) {
20
+ result = result.replace(new RegExp(`(^|[\\s@])${escapeRegExp(name)}(?=[\\s,.:;!?]|$)`, "g"), " ");
21
+ }
22
+ return result
23
+ .replace(/@/g, " ")
24
+ .replace(/[.,;:!?"'„“”«»()[\]]/g, " ")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ }
28
+
29
+ /**
30
+ * The names pi can be addressed by, for stripping a leading mention.
31
+ *
32
+ * "Neo (neoimpulse)" is mentioned as "Neo" in Teams, so the part before the
33
+ * first space or bracket counts as well.
34
+ */
35
+ export function selfNames(displayName: string | undefined): string[] {
36
+ if (!displayName) return [];
37
+ const short = displayName.split(/[\s(]/)[0] ?? "";
38
+ return [displayName, short].filter(Boolean);
39
+ }
40
+
41
+ /** The newest message as a command: no mentions, no punctuation, lower case. */
42
+ export function commandText(message: Pick<MessageSummary, "text" | "mentions">, myDisplayName?: string): string {
43
+ const names = [...(message.mentions ?? []).map((m) => m.displayName), ...selfNames(myDisplayName)];
44
+ return normalizeCommand(message.text ?? "", names);
45
+ }
package/src/watch/pin.ts CHANGED
@@ -17,6 +17,8 @@
17
17
  * turn, and one session runs one watcher.
18
18
  */
19
19
 
20
+ import { workerIdentity, type WorkerIdentity } from "./worker.ts";
21
+
20
22
  /**
21
23
  * How long a pin can survive without the turn ending.
22
24
  *
@@ -35,6 +37,57 @@ interface Pin {
35
37
 
36
38
  let pin: Pin | undefined;
37
39
 
40
+ /**
41
+ * Wakes whose prompt is queued but whose turn has not started yet.
42
+ *
43
+ * A wake is delivered as a follow-up: when pi is busy it waits in the queue.
44
+ * Pinning at wake time would therefore move the pin away from the answer that
45
+ * is still being written — the reply to chat A would be refused because chat B
46
+ * had just arrived. The pin is taken instead when the queued prompt actually
47
+ * enters the transcript (see `claimWakePin`), keyed by its exact text.
48
+ */
49
+ const pending = new Map<string, Pin>();
50
+
51
+ /** The worker identity of this process, read once. Overridable for tests. */
52
+ let worker: WorkerIdentity | undefined = workerIdentity();
53
+
54
+ /** Tests only: pretend this process is (or is not) a chat worker. */
55
+ export function setWorkerIdentityForTests(identity: WorkerIdentity | undefined): void {
56
+ worker = identity;
57
+ }
58
+
59
+ /**
60
+ * Remember a queued wake, so its pin can be taken when its turn starts.
61
+ *
62
+ * Entries older than the pin TTL are dropped on the way: a prompt that never
63
+ * made it into a turn (cancelled queue, restart) must not pin a later one.
64
+ */
65
+ export function queueWakePin(prompt: string, chatId: string, label: string, now = Date.now()): void {
66
+ for (const [text, entry] of pending) {
67
+ if (now - entry.at > PIN_TTL_MS) pending.delete(text);
68
+ }
69
+ pending.set(prompt, { chatId, label, at: now });
70
+ }
71
+
72
+ /**
73
+ * A user message entered the transcript: if it is a queued wake, pin it now.
74
+ *
75
+ * Returns whether a pin was taken. Anything else — typed input, other
76
+ * extensions' prompts — leaves the pin alone.
77
+ */
78
+ export function claimWakePin(text: string, now = Date.now()): boolean {
79
+ const entry = pending.get(text);
80
+ if (!entry) return false;
81
+ pending.delete(text);
82
+ pin = { chatId: entry.chatId, label: entry.label, at: now };
83
+ return true;
84
+ }
85
+
86
+ /** How many wakes are queued but not yet pinned. */
87
+ export function pendingWakePins(): number {
88
+ return pending.size;
89
+ }
90
+
38
91
  /** Pin the answer of the turn that is about to start to one chat. */
39
92
  export function pinWakeTarget(chatId: string, label: string, now = Date.now()): void {
40
93
  pin = { chatId, label, at: now };
@@ -45,8 +98,17 @@ export function clearWakeTarget(): void {
45
98
  pin = undefined;
46
99
  }
47
100
 
101
+ /** Tests only: forget every queued wake as well. */
102
+ export function resetWakePinsForTests(): void {
103
+ pin = undefined;
104
+ pending.clear();
105
+ }
106
+
48
107
  /** The chat an answer is currently owed to, if any. */
49
108
  export function getWakeTarget(now = Date.now()): { chatId: string; label: string } | undefined {
109
+ // A chat worker is pinned for its whole life: it exists to answer one chat,
110
+ // so there is no turn after which it may write anywhere else.
111
+ if (worker) return { chatId: worker.chatId, label: worker.label };
50
112
  if (!pin) return undefined;
51
113
  if (now - pin.at > PIN_TTL_MS) {
52
114
  pin = undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Listen mode — who a chat worker is.
3
+ *
4
+ * Where a chat is answered by a pi process of its own (a lane, e.g. with
5
+ * pi-lanes), that process loads this extension like any other, so it has to
6
+ * know two things the moment it starts: that it must never run a watcher of
7
+ * its own, and which single chat it may write to.
8
+ *
9
+ * Both travel in the environment, set by the router that starts the lane (see
10
+ * `laneHint` in ../extension/index.ts). An environment variable is the one
11
+ * channel that exists before the first line of the extension runs, and the
12
+ * model cannot change it.
13
+ */
14
+
15
+ /** Environment variables a chat worker is started with. */
16
+ export const WORKER_ENV = {
17
+ /** The chat this worker answers, and the only one it may write to */
18
+ chat: "PI_TEAMS_WORKER_CHAT",
19
+ /** Chat label, for refusal messages and the session name */
20
+ label: "PI_TEAMS_WORKER_LABEL",
21
+ /** "oneOnOne" | "group" | "meeting" */
22
+ chatType: "PI_TEAMS_WORKER_CHAT_TYPE",
23
+ /**
24
+ * Address of the other person, set for one-to-one chats only.
25
+ *
26
+ * Used to decide whether this chat may look at what pi wrote elsewhere
27
+ * (`teams_history`). In a group chat there is no single person to ask, so
28
+ * the variable is absent and the answer is no.
29
+ */
30
+ peer: "PI_TEAMS_WORKER_PEER",
31
+ } as const;
32
+
33
+ export interface WorkerIdentity {
34
+ chatId: string;
35
+ label: string;
36
+ chatType?: string;
37
+ peer?: string;
38
+ }
39
+
40
+ /** The chat this process works for, or undefined in an ordinary pi session. */
41
+ export function workerIdentity(env: NodeJS.ProcessEnv = process.env): WorkerIdentity | undefined {
42
+ const chatId = env[WORKER_ENV.chat]?.trim();
43
+ if (!chatId) return undefined;
44
+ return {
45
+ chatId,
46
+ label: env[WORKER_ENV.label]?.trim() || chatId,
47
+ chatType: env[WORKER_ENV.chatType]?.trim() || undefined,
48
+ peer: env[WORKER_ENV.peer]?.trim() || undefined,
49
+ };
50
+ }
51
+
52
+ /** The environment a worker for this chat is started with. */
53
+ export function workerEnv(identity: WorkerIdentity): Record<string, string> {
54
+ const env: Record<string, string> = {
55
+ [WORKER_ENV.chat]: identity.chatId,
56
+ [WORKER_ENV.label]: identity.label,
57
+ };
58
+ if (identity.chatType) env[WORKER_ENV.chatType] = identity.chatType;
59
+ if (identity.peer) env[WORKER_ENV.peer] = identity.peer;
60
+ return env;
61
+ }
62
+
63
+ /**
64
+ * Tools a chat worker may not call at all.
65
+ *
66
+ * A worker answers one conversation. Switching listen mode, rewriting the
67
+ * config or signing the account out would change what every other worker and
68
+ * the watcher itself do, which is not a decision one chat gets to make.
69
+ */
70
+ export const WORKER_FORBIDDEN_TOOLS = new Set<string>(["teams_watch", "teams_setup", "teams_logout"]);
71
+
72
+ export function workerViolation(toolName: string, identity: WorkerIdentity | undefined): string | undefined {
73
+ if (!identity) return undefined;
74
+ if (!WORKER_FORBIDDEN_TOOLS.has(toolName)) return undefined;
75
+ return (
76
+ `This pi process answers the Teams chat "${identity.label}" only. ${toolName} changes listen mode or the ` +
77
+ "account for every chat, so it is not available here. Ask the user to do it in the main session."
78
+ );
79
+ }