@patimweb/pi-teams 1.8.0 → 1.9.1

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/watch/loop.ts CHANGED
@@ -97,6 +97,18 @@ export interface WatchRuntimeStatus {
97
97
  // One tick
98
98
  // ---------------------------------------------------------------------------
99
99
 
100
+ /** A tick was asked to run without knowing who pi is signed in as. */
101
+ export class WatchIdentityError extends Error {
102
+ constructor(cause?: unknown) {
103
+ super(
104
+ "Listen mode is waiting: the signed-in user could not be determined, so pi's own replies " +
105
+ "could not be told apart from new messages. Retrying on the next poll." +
106
+ (cause ? ` ${formatGraphError(cause)}` : ""),
107
+ );
108
+ this.name = "WatchIdentityError";
109
+ }
110
+ }
111
+
100
112
  /**
101
113
  * Bound on how many chats one tick examines.
102
114
  *
@@ -165,8 +177,14 @@ export async function runWatchTick(
165
177
  deps: WatchTickDeps,
166
178
  state: WatchState,
167
179
  watch: ResolvedWatchConfig,
168
- me: SignedInUser | undefined,
180
+ me: SignedInUser,
169
181
  ): Promise<WatchTickResult> {
182
+ // Who pi acts as is what tells its own replies apart from everybody else's
183
+ // (`isFromMe`). Without it, the answer pi just sent looks like a new message
184
+ // from a colleague and wakes pi again — a loop that answers itself. So a tick
185
+ // without an identity examines nothing, and in particular writes no cursor.
186
+ if (!me) throw new WatchIdentityError();
187
+
170
188
  const now = deps.now();
171
189
  pruneState(state, now);
172
190
 
@@ -328,8 +346,24 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
328
346
 
329
347
  let stopped = false;
330
348
  let timer: ReturnType<typeof setTimeout> | undefined;
349
+ /**
350
+ * The tick currently running, if any.
351
+ *
352
+ * Every tick schedules the next one when it finishes, so two ticks running at
353
+ * once would leave two chains behind and quietly double the polling rate for
354
+ * the rest of the session. `poll()` therefore joins the tick in flight
355
+ * instead of starting a second one.
356
+ */
357
+ let inFlight: Promise<void> | undefined;
358
+ /**
359
+ * Who pi acts as, looked up on the first tick that can reach Graph.
360
+ *
361
+ * A failed lookup is not an answer: it is retried on every tick until it
362
+ * succeeds, and no tick runs before it has. A watcher started before sign-in
363
+ * (autoStart in a fresh container, say) would otherwise settle for "nobody"
364
+ * for the whole session and wake on its own replies.
365
+ */
331
366
  let me: SignedInUser | undefined;
332
- let meResolved = false;
333
367
  let failureCount = 0;
334
368
 
335
369
  const status: WatchRuntimeStatus = {
@@ -343,7 +377,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
343
377
 
344
378
  const schedule = (delayMs: number) => {
345
379
  if (stopped) return;
346
- timer = setTimeout(() => void tick(), delayMs);
380
+ timer = setTimeout(() => void runTick(), delayMs);
347
381
  // Do not hold the process open for a poll that can wait.
348
382
  timer.unref?.();
349
383
  };
@@ -352,9 +386,12 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
352
386
  if (stopped) return;
353
387
 
354
388
  try {
355
- if (!meResolved) {
356
- me = await getMe(connection).catch(() => undefined);
357
- meResolved = true;
389
+ if (!me) {
390
+ try {
391
+ me = await getMe(connection);
392
+ } catch (err) {
393
+ throw new WatchIdentityError(err);
394
+ }
358
395
  }
359
396
 
360
397
  const result = await runWatchTick(
@@ -406,14 +443,31 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
406
443
  options.onError?.(status.lastError);
407
444
  }
408
445
 
409
- options.onTick?.(status);
446
+ // Outside the try above on purpose, and guarded on its own: a throwing
447
+ // status callback must not become an unhandled rejection that takes the
448
+ // watcher down without a word.
449
+ try {
450
+ options.onTick?.(status);
451
+ } catch {
452
+ /* a footer that cannot render is not worth stopping the loop for */
453
+ }
454
+
410
455
  schedule(watch.intervalSeconds * 1000 * Math.min(2 ** failureCount, 10));
411
456
  }
412
457
 
458
+ /** Run a tick unless one is already running; either way, resolve with it. */
459
+ function runTick(): Promise<void> {
460
+ if (inFlight) return inFlight;
461
+ inFlight = tick().finally(() => {
462
+ inFlight = undefined;
463
+ });
464
+ return inFlight;
465
+ }
466
+
413
467
  // The first tick is immediate: it answers what is still open since the last
414
468
  // run, and a user who switched listen mode on should not wait a full interval
415
469
  // to see it alive.
416
- void tick();
470
+ void runTick();
417
471
 
418
472
  return {
419
473
  stop() {
@@ -424,9 +478,15 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
424
478
  },
425
479
  status: () => ({ ...status, wakesThisHour: wakesThisHour(state, Date.now()) }),
426
480
  async poll() {
481
+ // Join a tick already in flight rather than racing it; only start one
482
+ // when nothing is running.
483
+ if (inFlight) {
484
+ await inFlight;
485
+ return;
486
+ }
427
487
  if (timer) clearTimeout(timer);
428
488
  timer = undefined;
429
- await tick();
489
+ await runTick();
430
490
  },
431
491
  };
432
492
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Listen mode — pinning the answer to the chat that asked for it.
3
+ *
4
+ * A wake puts somebody else's words into pi's prompt, and pi holds tools that
5
+ * write in the user's name. The prompt asks it to answer only the chat it was
6
+ * woken for, but an instruction inside a message is exactly the thing that can
7
+ * talk a model out of following an instruction in a prompt. So the rule is
8
+ * enforced here instead: while an answer is pending, sending anywhere other
9
+ * than the chat that woke pi is refused by the safety interceptor.
10
+ *
11
+ * This is deliberately narrow. It gates outgoing messages only — the tools that
12
+ * speak to other people — and not reading, presence or the calendar, because a
13
+ * pin that blocks unrelated work would be a bug the user meets far more often
14
+ * than an attack.
15
+ *
16
+ * Module state rather than a parameter: the interceptor sees a tool call, not a
17
+ * turn, and one session runs one watcher.
18
+ */
19
+
20
+ /**
21
+ * How long a pin can survive without the turn ending.
22
+ *
23
+ * The pin is normally cleared when the agent settles. The timeout is the
24
+ * backstop for the case where that never arrives — a crash, a cancelled run —
25
+ * so a stuck pin cannot lock the user out of their own tools.
26
+ */
27
+ export const PIN_TTL_MS = 15 * 60 * 1000;
28
+
29
+ interface Pin {
30
+ chatId: string;
31
+ /** Chat label, for the refusal message */
32
+ label: string;
33
+ at: number;
34
+ }
35
+
36
+ let pin: Pin | undefined;
37
+
38
+ /** Pin the answer of the turn that is about to start to one chat. */
39
+ export function pinWakeTarget(chatId: string, label: string, now = Date.now()): void {
40
+ pin = { chatId, label, at: now };
41
+ }
42
+
43
+ /** Release the pin. Safe to call when there is none. */
44
+ export function clearWakeTarget(): void {
45
+ pin = undefined;
46
+ }
47
+
48
+ /** The chat an answer is currently owed to, if any. */
49
+ export function getWakeTarget(now = Date.now()): { chatId: string; label: string } | undefined {
50
+ if (!pin) return undefined;
51
+ if (now - pin.at > PIN_TTL_MS) {
52
+ pin = undefined;
53
+ return undefined;
54
+ }
55
+ return { chatId: pin.chatId, label: pin.label };
56
+ }
57
+
58
+ /**
59
+ * Tools that speak to people, and must therefore stay inside the pinned chat.
60
+ *
61
+ * Reading, presence and calendar tools are absent on purpose: they do not put
62
+ * words in anyone's inbox, and gating them would break ordinary work for no
63
+ * security gain.
64
+ */
65
+ const OUTGOING_TOOLS = new Set<string>([
66
+ "teams_send_chat_message",
67
+ "teams_send_channel_message",
68
+ "teams_reply_channel_message",
69
+ "teams_create_chat",
70
+ "teams_chat_members",
71
+ "teams_update_message",
72
+ "teams_react",
73
+ ]);
74
+
75
+ /**
76
+ * Why this call must not go through while an answer is pinned, or undefined.
77
+ *
78
+ * The chat is matched on its ID, because that is what the wake prompt hands the
79
+ * model and the only identifier that cannot be talked into meaning a different
80
+ * conversation.
81
+ */
82
+ export function pinViolation(
83
+ toolName: string,
84
+ params: Record<string, unknown>,
85
+ now = Date.now(),
86
+ ): string | undefined {
87
+ const target = getWakeTarget(now);
88
+ if (!target) return undefined;
89
+ if (!OUTGOING_TOOLS.has(toolName)) return undefined;
90
+
91
+ const explain = (what: string) =>
92
+ `Listen mode is answering "${target.label}". ${what} Only that chat may be written to until the ` +
93
+ `answer is done — a message can ask for anything, and this is the part that does not take its word for it.`;
94
+
95
+ if (toolName === "teams_send_channel_message" || toolName === "teams_reply_channel_message") {
96
+ return explain("This would post in a channel.");
97
+ }
98
+ if (toolName === "teams_create_chat") {
99
+ return explain("This would start a different conversation.");
100
+ }
101
+
102
+ const chat = typeof params.chat === "string" ? params.chat : undefined;
103
+ if (!chat) return explain("This does not name the chat it would write to.");
104
+ if (chat !== target.chatId) {
105
+ return explain(`This would write to "${chat}" instead.`);
106
+ }
107
+
108
+ return undefined;
109
+ }