@patimweb/pi-teams 1.15.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,7 +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
- | `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
+ | `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). |
54
54
 
55
55
  Set it globally, or per account — the account level wins field by field.
56
56
  `teams_watch` writes the same keys; `/teams-listen` writes to the session's
@@ -158,81 +158,42 @@ when the message is picked up. A wake that arrives while pi is still answering
158
158
  another chat waits in the queue; pinning it right away would take the pin away
159
159
  from the answer that is still being written, and that answer would be refused.
160
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.
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
163
 
164
- ## One pi process per chat
164
+ ## Parallel chats with pi-lanes
165
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.
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.
169
168
 
170
- `dispatch.mode: "process"` hands every chat a pi process of its own:
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.
171
175
 
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
- ```
176
+ The hint carries:
187
177
 
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
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` |
225
184
 
226
- Separate sessions mean that no single context knows what pi told somebody else.
227
- Two logs do, and `teams_history` reads them:
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.
228
190
 
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.
191
+ ### What pi said in other chats
232
192
 
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.
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.
236
197
 
237
198
  ## Cost
238
199
 
package/docs/tools.md CHANGED
@@ -21,7 +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
+ | `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) |
25
25
  | `teams_list_chats` | Recent chats with a preview of the last message |
26
26
  | `teams_read_chat` | Messages of one chat |
27
27
  | `teams_list_teams` | Teams you belong to |
@@ -75,7 +75,6 @@ usual; correcting a channel post stays in the Teams client.
75
75
  | `/teams-login` | Sign in to Teams as yourself |
76
76
  | `/teams-inbox` | What needs your attention right now |
77
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 |
79
78
  | `/teams-permissions` | What pi may and may not do |
80
79
  | `/teams-doctor` | Diagnose the setup |
81
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.15.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,72 +135,15 @@ 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
138
  /**
181
139
  * 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.
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.
184
143
  */
185
144
  historyReaders?: string[];
186
145
  }
187
146
 
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[];
202
- }
203
-
204
147
  /**
205
148
  * Where a mention is required, and where it is not.
206
149
  *
@@ -253,7 +196,7 @@ export interface ResolvedWatchConfig {
253
196
  mentionOnly: ResolvedMentionOnly;
254
197
  cooldownSeconds: number;
255
198
  maxTriggersPerHour: number;
256
- dispatch: ResolvedDispatchConfig;
199
+ historyReaders: string[];
257
200
  }
258
201
 
259
202
  /**
@@ -449,25 +392,7 @@ export const WATCH_DEFAULTS: ResolvedWatchConfig = {
449
392
  mentionOnly: { default: false, chats: [], people: [] },
450
393
  cooldownSeconds: 300,
451
394
  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 },
395
+ historyReaders: [],
471
396
  };
472
397
 
473
398
  /**
@@ -886,36 +811,7 @@ export function resolveWatchConfig(
886
811
  WATCH_BOUNDS.maxTriggersPerHour,
887
812
  WATCH_DEFAULTS.maxTriggersPerHour,
888
813
  ),
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),
814
+ historyReaders: normalizePatterns(pick("historyReaders")),
919
815
  };
920
816
  }
921
817
 
@@ -11,7 +11,6 @@ import { Box, Text } from "@earendil-works/pi-tui";
11
11
 
12
12
  import {
13
13
  ensureConfigTemplate,
14
- getAgentDir,
15
14
  getConfigPath,
16
15
  setWatchConfig,
17
16
  type ResolvedMentionOnly,
@@ -35,14 +34,15 @@ import {
35
34
  setActiveWatchLoop,
36
35
  startWatchLoop,
37
36
  stopActiveWatchLoop,
37
+ type WatchEvent as WakeEvent,
38
38
  type WatchLoop,
39
39
  } from "../watch/loop.ts";
40
40
  import { composeWatchPrompt } from "../watch/prompt.ts";
41
41
  import { setChatReadState } from "../graph/chats.ts";
42
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";
43
+ import { commandText } from "../watch/control.ts";
44
+ import { workerEnv, workerIdentity, workerViolation } from "../watch/worker.ts";
45
+ import { matchesPattern } from "../config/scope.ts";
46
46
  import { withSenderAddresses } from "../watch/index.ts";
47
47
  import { readWatchCursor, writeWatchCursor } from "../watch/cursor.ts";
48
48
 
@@ -107,6 +107,48 @@ function mentionSummary(mentionOnly: ResolvedMentionOnly): string {
107
107
  return overrides > 0 ? `${base} (${overrides} override${overrides === 1 ? "" : "s"})` : base;
108
108
  }
109
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
+
110
152
  /** Every tool, in the order they appear in the documentation. */
111
153
  const tools = [
112
154
  // Setup & session
@@ -186,36 +228,11 @@ export default function (pi: ExtensionAPI) {
186
228
  let watchSignature: string | undefined;
187
229
 
188
230
  /**
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).
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.
191
233
  */
192
234
  const worker = workerIdentity();
193
235
 
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
-
219
236
  /** The last polling error the user was told about, so it is said once. */
220
237
  let reportedWatchError: string | undefined;
221
238
 
@@ -269,26 +286,16 @@ export default function (pi: ExtensionAPI) {
269
286
  }
270
287
  const prompt = composeWatchPrompt(event, event.me);
271
288
 
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
- }
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));
287
294
 
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.
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.
292
299
  queueWakePin(prompt, event.chat.id, event.chat.label);
293
300
  pi.sendUserMessage(prompt, { deliverAs: "followUp" });
294
301
  },
@@ -441,40 +448,12 @@ export default function (pi: ExtensionAPI) {
441
448
  conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
442
449
  mentionSummary(conn.watch.mentionOnly),
443
450
  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",
447
451
  ].join(" · "),
448
452
  "info",
449
453
  );
450
454
  },
451
455
  });
452
456
 
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
-
478
457
  pi.registerCommand("teams-inbox", {
479
458
  description: "Show what needs your attention in Microsoft Teams",
480
459
  handler: async (_args, ctx) => {
@@ -541,9 +520,10 @@ export default function (pi: ExtensionAPI) {
541
520
  // and the same config: autoStart there would run a second watcher that
542
521
  // answers chats inside a throwaway process and advances the shared cursor,
543
522
  // so the real session never sees those messages.
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 });
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 });
547
527
 
548
528
  paintStatus(ctx);
549
529
 
@@ -602,8 +582,6 @@ export default function (pi: ExtensionAPI) {
602
582
  // The timer would be unref'd anyway, but a poll in flight during shutdown
603
583
  // would still be a Graph call for a session that is gone.
604
584
  stopWatch();
605
- // Workers end with the session that started them; their sessions stay on disk.
606
- stopDispatcher();
607
585
  });
608
586
 
609
587
  // A queued wake prompt has entered the transcript: its turn starts now, so
@@ -706,7 +684,6 @@ export default function (pi: ExtensionAPI) {
706
684
  // A watcher polling a session that was just removed only produces
707
685
  // errors; leaving it running would be noise, not service.
708
686
  stopWatch();
709
- stopDispatcher();
710
687
  } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
711
688
  // Only `enable` is a decision. A status call, or a re-read after
712
689
  // teams_setup, may stop the loop but never start it.
@@ -1,24 +1,21 @@
1
1
  /**
2
- * teams_history — what pi wrote in Teams, and what the chat workers were asked.
2
+ * teams_history — what pi wrote in Teams, across chats.
3
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).
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?".
9
8
  *
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.
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.
14
13
  */
15
14
 
16
15
  import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
17
16
  import { Type } from "typebox";
18
- import { getAgentDir } from "../config/index.ts";
19
17
  import { matchesPattern } from "../config/scope.ts";
20
18
  import { getAuditPath } from "../safety/audit.ts";
21
- import { getDispatchJournalPath } from "../watch/dispatch.ts";
22
19
  import { workerIdentity, type WorkerIdentity } from "../watch/worker.ts";
23
20
  import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
24
21
 
@@ -64,12 +61,11 @@ export function historyRefusal(identity: WorkerIdentity | undefined, readers: st
64
61
  if (allowed) return undefined;
65
62
  return (
66
63
  "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."
64
+ "the people listed in watch.historyReaders may look across chats, from their one-to-one chat with pi."
68
65
  );
69
66
  }
70
67
 
71
68
  interface HistoryParams {
72
- kind?: "sent" | "dispatch";
73
69
  chat?: string;
74
70
  sinceHours?: number;
75
71
  limit?: number;
@@ -77,7 +73,6 @@ interface HistoryParams {
77
73
 
78
74
  /** Filter and render log entries. Exported for tests. */
79
75
  export function renderHistory(entries: Record<string, any>[], params: HistoryParams, now = Date.now()): string {
80
- const kind = params.kind ?? "sent";
81
76
  const limit = Math.min(Math.max(params.limit ?? 20, 1), 200);
82
77
  const since = now - Math.min(Math.max(params.sinceHours ?? 24, 1), 24 * 90) * 3_600_000;
83
78
  const needle = params.chat?.trim().toLowerCase();
@@ -86,29 +81,16 @@ export function renderHistory(entries: Record<string, any>[], params: HistoryPar
86
81
  const at = Date.parse(entry.at ?? "");
87
82
  if (!Number.isFinite(at) || at < since) return false;
88
83
  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);
84
+ return `${entry.target ?? ""} ${entry.summary ?? ""}`.toLowerCase().includes(needle);
94
85
  });
95
86
 
96
87
  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.";
88
+ if (shown.length === 0) return "No messages sent in that window.";
98
89
 
99
90
  const lines = shown.map((entry) => {
100
91
  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(" · ");
92
+ const failed = entry.error ? ` (failed: ${entry.error})` : "";
93
+ return `- ${at} · ${entry.tool} → ${entry.target}: ${entry.summary}${failed}`;
112
94
  });
113
95
  const more = hits.length > shown.length ? `\n\n(${hits.length - shown.length} older entries not shown)` : "";
114
96
  return lines.join("\n") + more;
@@ -117,15 +99,9 @@ export function renderHistory(entries: Record<string, any>[], params: HistoryPar
117
99
  export const teamsHistoryTool = {
118
100
  name: "teams_history",
119
101
  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.",
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.",
123
104
  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
105
  chat: Type.Optional(Type.String({ description: "Only entries whose chat or person contains this text" })),
130
106
  sinceHours: Type.Optional(Type.Number({ description: "How far back, in hours (default 24, max 2160)" })),
131
107
  limit: Type.Optional(Type.Number({ description: "Most recent entries to show (default 20, max 200)" })),
@@ -140,11 +116,11 @@ export const teamsHistoryTool = {
140
116
  ctx: ToolContext,
141
117
  ): Promise<ToolResult> {
142
118
  return run(async () => {
143
- const readers = ctx.connection?.watch.dispatch.historyReaders ?? [];
119
+ const readers = ctx.connection?.watch.historyReaders ?? [];
144
120
  const refusal = historyRefusal(workerIdentity(), readers);
145
121
  if (refusal) return errorResult(refusal);
146
122
 
147
- const path = (params.kind ?? "sent") === "sent" ? getAuditPath() : getDispatchJournalPath(getAgentDir());
123
+ const path = getAuditPath();
148
124
  const text = renderHistory(readJsonlTail(path), params);
149
125
  return textResult(text, { path });
150
126
  });
@@ -16,7 +16,6 @@ import {
16
16
  type ResolvedMentionOnly,
17
17
  type TeamsConnection,
18
18
  type WatchConfig,
19
- type ResolvedDispatchConfig,
20
19
  } from "../config/index.ts";
21
20
  import { getActiveWatchLoop, type WatchRuntimeStatus } from "../watch/loop.ts";
22
21
  import { getWatchCursorPath } from "../watch/cursor.ts";
@@ -73,7 +72,6 @@ function describe(watch: {
73
72
  mentionOnly: ResolvedMentionOnly;
74
73
  cooldownSeconds: number;
75
74
  maxTriggersPerHour: number;
76
- dispatch?: ResolvedDispatchConfig;
77
75
  }): string {
78
76
  return [
79
77
  `- enabled: ${watch.enabled}`,
@@ -84,9 +82,6 @@ function describe(watch: {
84
82
  describeMentionOnly(watch.mentionOnly),
85
83
  `- cooldown per chat: ${watch.cooldownSeconds} s`,
86
84
  `- wake limit: ${watch.maxTriggersPerHour} per hour`,
87
- watch.dispatch?.mode === "process"
88
- ? `- dispatch: one pi process per chat, ${watch.dispatch.maxConcurrent} in parallel`
89
- : "- dispatch: every wake is a turn in this session",
90
85
  ].join("\n");
91
86
  }
92
87
 
@@ -1,22 +1,15 @@
1
1
  /**
2
- * Listen mode — control words in a chat.
2
+ * Listen mode — the text of a trigger, as a command.
3
3
  *
4
- * In dispatch mode a chat has a pi process that may be in the middle of
5
- * something. Two short messages steer that process instead of adding to its
6
- * work: a stop word aborts what it is doing, a reset word starts the chat over
7
- * with a fresh session. Both still reach the model afterwards, so the person
8
- * gets a one-line confirmation in the chat instead of silence.
9
- *
10
- * Deliberately strict: the whole message has to be the word (a mention of pi
11
- * and punctuation aside). "Stop the deployment on hera" is a request, not a
12
- * control word, and must be read by the model like any other.
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.
13
9
  */
14
10
 
15
- import type { ResolvedDispatchConfig } from "../config/index.ts";
16
11
  import type { MessageSummary } from "../types.ts";
17
12
 
18
- export type ControlAction = "stop" | "reset";
19
-
20
13
  const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
14
 
22
15
  /** Lower-case the text and drop mentions, punctuation and extra spaces. */
@@ -45,16 +38,8 @@ export function selfNames(displayName: string | undefined): string[] {
45
38
  return [displayName, short].filter(Boolean);
46
39
  }
47
40
 
48
- /** Whether the newest message is a control word, and which. */
49
- export function controlAction(
50
- message: Pick<MessageSummary, "text" | "mentions">,
51
- dispatch: Pick<ResolvedDispatchConfig, "stopWords" | "resetWords">,
52
- myDisplayName?: string,
53
- ): ControlAction | undefined {
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 {
54
43
  const names = [...(message.mentions ?? []).map((m) => m.displayName), ...selfNames(myDisplayName)];
55
- const text = normalizeCommand(message.text ?? "", names);
56
- if (!text) return undefined;
57
- if (dispatch.resetWords.includes(text)) return "reset";
58
- if (dispatch.stopWords.includes(text)) return "stop";
59
- return undefined;
44
+ return normalizeCommand(message.text ?? "", names);
60
45
  }
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Listen mode — who a chat worker is.
3
3
  *
4
- * In dispatch mode (`watch.dispatch.mode: "process"`) every chat that wakes pi
5
- * gets a pi process of its own. That process loads this extension like any
6
- * other, so it has to know two things the moment it starts: that it must never
7
- * run a watcher of its own, and which single chat it may write to.
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
8
  *
9
- * Both travel in the environment, set by the dispatcher when it spawns the
10
- * worker. An environment variable is the one channel that exists before the
11
- * first line of the extension runs, and the model cannot change it.
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.
12
13
  */
13
14
 
14
15
  /** Environment variables a chat worker is started with. */
@@ -1,577 +0,0 @@
1
- /**
2
- * Listen mode — one pi process per chat.
3
- *
4
- * With `watch.dispatch.mode: "process"` a wake does not become a turn in the
5
- * session that runs the watcher. It goes to a worker: a `pi --mode rpc` process
6
- * that belongs to that one chat and keeps its own session from message to
7
- * message. Different chats are worked on at the same time; the watching
8
- * session only routes and stays small.
9
- *
10
- * The rules, in the order they are applied:
11
- *
12
- * - A chat whose worker is busy gets the new wake **steered** into the running
13
- * turn. Nobody waits for a long task to finish before "stop, take repo X
14
- * instead" is read.
15
- * - A chat without a busy worker starts one, if fewer than `maxConcurrent`
16
- * chats are being worked on. Otherwise it waits in line; a newer wake for a
17
- * chat already in line replaces the older one (its prompt carries every open
18
- * message anyway).
19
- * - A stop word aborts the running turn; a reset word ends the worker and the
20
- * chat starts over with a fresh session. Both prompts still reach the model,
21
- * so the person gets a confirmation.
22
- * - An idle worker exits after `idleMinutes`. Its session stays on disk, and
23
- * the next message in that chat continues it — unless the chat has been
24
- * quiet for longer than `freshAfterHours`.
25
- *
26
- * Everything a worker is asked and answers is written to a journal
27
- * (`pi-teams-dispatch.jsonl`), so "what did pi tell whom" can be answered
28
- * across chats without mixing their contexts.
29
- *
30
- * The process handling is injectable (`spawn`), so the scheduling rules are
31
- * tested without starting pi.
32
- */
33
-
34
- import { spawn as nodeSpawn } from "node:child_process";
35
- import { createHash } from "node:crypto";
36
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
37
- import { join } from "node:path";
38
- import type { ResolvedDispatchConfig } from "../config/index.ts";
39
- import type { ControlAction } from "./control.ts";
40
- import { workerEnv, type WorkerIdentity } from "./worker.ts";
41
-
42
- // ---------------------------------------------------------------------------
43
- // Types
44
- // ---------------------------------------------------------------------------
45
-
46
- /** One wake, ready to be handed to a worker. */
47
- export interface DispatchJob {
48
- chatId: string;
49
- label: string;
50
- /** "oneOnOne" | "group" | "meeting" */
51
- chatType?: string;
52
- /** Display name of the sender of the newest message */
53
- from?: string;
54
- /** Address of the other person in a one-to-one chat */
55
- peer?: string;
56
- /** The wake prompt, exactly as a session-mode wake would receive it */
57
- prompt: string;
58
- /** Short form of the newest message, for the journal */
59
- request?: string;
60
- control?: ControlAction;
61
- }
62
-
63
- export type DispatchOutcome = "started" | "steered" | "queued" | "stopped" | "reset";
64
-
65
- export type DispatchEvent =
66
- | "start"
67
- | "steer"
68
- | "queued"
69
- | "stop"
70
- | "reset"
71
- | "done"
72
- | "error"
73
- | "idle-exit";
74
-
75
- export interface DispatchJournalEntry {
76
- at: string;
77
- chatId: string;
78
- chat: string;
79
- event: DispatchEvent;
80
- from?: string;
81
- request?: string;
82
- /** The worker's last answer text, shortened */
83
- result?: string;
84
- durationMs?: number;
85
- detail?: string;
86
- }
87
-
88
- /** The part of a child process the dispatcher uses. */
89
- export interface WorkerProcess {
90
- pid?: number;
91
- stdin: { write(chunk: string): unknown; end(): unknown; on(event: "error", listener: (err: Error) => void): unknown } | null;
92
- stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
93
- stderr: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
94
- on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
95
- on(event: "error", listener: (err: Error) => void): unknown;
96
- kill(signal?: NodeJS.Signals): unknown;
97
- }
98
-
99
- export type SpawnWorker = (
100
- command: string,
101
- args: string[],
102
- options: { cwd: string; env: NodeJS.ProcessEnv },
103
- ) => WorkerProcess;
104
-
105
- export interface DispatcherOptions {
106
- config: ResolvedDispatchConfig;
107
- /** pi's agent directory: chat sessions and the journal live below it */
108
- agentDir: string;
109
- /** Working directory of the workers */
110
- cwd: string;
111
- spawn?: SpawnWorker;
112
- now?: () => number;
113
- /** Environment the workers inherit (default: process.env) */
114
- env?: NodeJS.ProcessEnv;
115
- /** Journal writer (default: append to pi-teams-dispatch.jsonl) */
116
- journal?: (entry: DispatchJournalEntry) => void;
117
- /** A worker failed in a way the user should hear about */
118
- onError?: (message: string) => void;
119
- /** Interval of the idle sweep in ms (default: 60 000, 0 = no timer) */
120
- sweepIntervalMs?: number;
121
- }
122
-
123
- export interface DispatchStatus {
124
- mode: "process";
125
- busy: number;
126
- alive: number;
127
- queued: number;
128
- maxConcurrent: number;
129
- chats: Array<{ chatId: string; label: string; busy: boolean; sinceMs: number }>;
130
- waiting: Array<{ chatId: string; label: string }>;
131
- }
132
-
133
- interface Worker {
134
- identity: WorkerIdentity;
135
- proc: WorkerProcess;
136
- busy: boolean;
137
- job?: DispatchJob;
138
- startedAt: number;
139
- lastActivity: number;
140
- buffer: string;
141
- lastText?: string;
142
- stderrTail: string;
143
- /** Set when the dispatcher itself ended the process */
144
- retiring: boolean;
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // Paths and journal
149
- // ---------------------------------------------------------------------------
150
-
151
- export function getDispatchJournalPath(agentDir: string): string {
152
- return join(agentDir, "pi-teams-dispatch.jsonl");
153
- }
154
-
155
- /** The session directory of one chat. Hashed: chat IDs contain ':' and '@'. */
156
- export function chatSessionDir(agentDir: string, chatId: string): string {
157
- const key = createHash("sha256").update(chatId).digest("hex").slice(0, 16);
158
- return join(agentDir, "pi-teams-chats", key);
159
- }
160
-
161
- /** Append a journal entry. Never throws. */
162
- export function appendDispatchJournal(agentDir: string, entry: DispatchJournalEntry): void {
163
- try {
164
- if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true, mode: 0o700 });
165
- const path = getDispatchJournalPath(agentDir);
166
- const isNew = !existsSync(path);
167
- appendFileSync(path, `${JSON.stringify(entry)}\n`, { encoding: "utf-8", mode: 0o600 });
168
- if (isNew) chmodSync(path, 0o600);
169
- } catch {
170
- /* the journal must never stop an answer */
171
- }
172
- }
173
-
174
- /** One line, at most `max` characters. */
175
- export function excerpt(text: string | undefined, max = 300): string | undefined {
176
- if (!text) return undefined;
177
- const flat = text.replace(/\s+/g, " ").trim();
178
- if (!flat) return undefined;
179
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
180
- }
181
-
182
- /**
183
- * Whether the chat's last session is recent enough to continue.
184
- *
185
- * `freshAfterHours: 0` means always continue. No session at all means there is
186
- * nothing to continue.
187
- */
188
- export function shouldContinue(dir: string, freshAfterHours: number, now: number): boolean {
189
- let newest = 0;
190
- try {
191
- for (const name of readdirSync(dir)) {
192
- if (!name.endsWith(".jsonl")) continue;
193
- const mtime = statSync(join(dir, name)).mtimeMs;
194
- if (mtime > newest) newest = mtime;
195
- }
196
- } catch {
197
- return false;
198
- }
199
- if (newest === 0) return false;
200
- if (freshAfterHours === 0) return true;
201
- return now - newest <= freshAfterHours * 3_600_000;
202
- }
203
-
204
- /** The text of an assistant message, or undefined. */
205
- function assistantText(message: any): string | undefined {
206
- if (!message || message.role !== "assistant") return undefined;
207
- const content = message.content;
208
- if (typeof content === "string") return content;
209
- if (!Array.isArray(content)) return undefined;
210
- const text = content
211
- .filter((part: any) => part?.type === "text" && typeof part.text === "string")
212
- .map((part: any) => part.text)
213
- .join("\n")
214
- .trim();
215
- return text || undefined;
216
- }
217
-
218
- const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
219
-
220
- // ---------------------------------------------------------------------------
221
- // Dispatcher
222
- // ---------------------------------------------------------------------------
223
-
224
- export class ChatDispatcher {
225
- private config: ResolvedDispatchConfig;
226
- private readonly workers = new Map<string, Worker>();
227
- private queue: DispatchJob[] = [];
228
- private readonly spawnWorker: SpawnWorker;
229
- private readonly now: () => number;
230
- private readonly journalWriter: (entry: DispatchJournalEntry) => void;
231
- private sweepTimer?: ReturnType<typeof setInterval>;
232
- private stopped = false;
233
- private readonly options: DispatcherOptions;
234
-
235
- constructor(options: DispatcherOptions) {
236
- this.options = options;
237
- this.config = options.config;
238
- this.spawnWorker =
239
- options.spawn ??
240
- ((command, args, opts) =>
241
- nodeSpawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"] }) as WorkerProcess);
242
- this.now = options.now ?? Date.now;
243
- this.journalWriter = options.journal ?? ((entry) => appendDispatchJournal(options.agentDir, entry));
244
- const every = options.sweepIntervalMs ?? 60_000;
245
- if (every > 0) {
246
- this.sweepTimer = setInterval(() => this.sweep(), every);
247
- this.sweepTimer.unref?.();
248
- }
249
- }
250
-
251
- /** Apply changed settings. Running workers keep going; limits apply from now on. */
252
- configure(config: ResolvedDispatchConfig): void {
253
- this.config = config;
254
- this.drain();
255
- }
256
-
257
- /** Hand a wake to its chat's worker. */
258
- deliver(job: DispatchJob): DispatchOutcome {
259
- if (this.stopped) return "queued";
260
- const worker = this.workers.get(job.chatId);
261
-
262
- if (job.control === "reset") {
263
- this.dropQueued(job.chatId);
264
- if (worker) this.retire(worker, "reset");
265
- this.record(job, "reset");
266
- this.start(job, { fresh: true, force: true });
267
- return "reset";
268
- }
269
-
270
- if (job.control === "stop") {
271
- this.dropQueued(job.chatId);
272
- if (worker?.busy) {
273
- this.send(worker, { type: "clear_queue" });
274
- this.send(worker, { type: "abort" });
275
- this.record(job, "stop");
276
- // Queued behind the abort: the model confirms in the chat once it is idle.
277
- this.send(worker, { type: "prompt", message: job.prompt, streamingBehavior: "followUp" });
278
- worker.job = job;
279
- return "stopped";
280
- }
281
- this.record(job, "stop", { detail: "nothing was running" });
282
- }
283
-
284
- if (worker?.busy) {
285
- this.send(worker, { type: "prompt", message: job.prompt, streamingBehavior: "steer" });
286
- worker.lastActivity = this.now();
287
- this.record(job, "steer");
288
- return "steered";
289
- }
290
-
291
- if (this.busyCount() >= this.config.maxConcurrent) {
292
- this.dropQueued(job.chatId);
293
- this.queue.push(job);
294
- this.record(job, "queued", { detail: `${this.busyCount()} chat(s) in progress` });
295
- return "queued";
296
- }
297
-
298
- this.start(job, { fresh: false });
299
- return "started";
300
- }
301
-
302
- /** Snapshot for status commands. */
303
- status(): DispatchStatus {
304
- const now = this.now();
305
- return {
306
- mode: "process",
307
- busy: this.busyCount(),
308
- alive: this.workers.size,
309
- queued: this.queue.length,
310
- maxConcurrent: this.config.maxConcurrent,
311
- chats: [...this.workers.values()].map((w) => ({
312
- chatId: w.identity.chatId,
313
- label: w.identity.label,
314
- busy: w.busy,
315
- sinceMs: now - (w.busy ? w.startedAt : w.lastActivity),
316
- })),
317
- waiting: this.queue.map((job) => ({ chatId: job.chatId, label: job.label })),
318
- };
319
- }
320
-
321
- /** End idle workers that have been quiet for longer than `idleMinutes`. */
322
- sweep(): void {
323
- const now = this.now();
324
- const limit = this.config.idleMinutes * 60_000;
325
- for (const worker of [...this.workers.values()]) {
326
- if (!worker.busy && now - worker.lastActivity > limit) {
327
- this.journalWriter({
328
- at: new Date(now).toISOString(),
329
- chatId: worker.identity.chatId,
330
- chat: worker.identity.label,
331
- event: "idle-exit",
332
- });
333
- this.retire(worker, "idle");
334
- }
335
- }
336
- }
337
-
338
- /** End every worker. Their sessions stay on disk. */
339
- stopAll(): void {
340
- this.stopped = true;
341
- if (this.sweepTimer) clearInterval(this.sweepTimer);
342
- this.queue = [];
343
- for (const worker of [...this.workers.values()]) this.retire(worker, "shutdown");
344
- }
345
-
346
- // -----------------------------------------------------------------------
347
-
348
- private busyCount(): number {
349
- let busy = 0;
350
- for (const worker of this.workers.values()) if (worker.busy) busy += 1;
351
- return busy;
352
- }
353
-
354
- private dropQueued(chatId: string): void {
355
- this.queue = this.queue.filter((job) => job.chatId !== chatId);
356
- }
357
-
358
- private record(job: DispatchJob, event: DispatchEvent, extra: Partial<DispatchJournalEntry> = {}): void {
359
- this.journalWriter({
360
- at: new Date(this.now()).toISOString(),
361
- chatId: job.chatId,
362
- chat: job.label,
363
- event,
364
- from: job.from,
365
- request: job.request,
366
- ...extra,
367
- });
368
- }
369
-
370
- private send(worker: Worker, command: Record<string, unknown>): void {
371
- try {
372
- worker.proc.stdin?.write(`${JSON.stringify(command)}\n`);
373
- } catch {
374
- /* a dead pipe shows up as an exit event */
375
- }
376
- }
377
-
378
- private start(job: DispatchJob, { fresh, force = false }: { fresh: boolean; force?: boolean }): void {
379
- let worker = this.workers.get(job.chatId);
380
- if (!worker) worker = this.launch(job, fresh);
381
- if (!worker) return;
382
-
383
- const now = this.now();
384
- worker.busy = true;
385
- worker.job = job;
386
- worker.startedAt = now;
387
- worker.lastActivity = now;
388
- worker.lastText = undefined;
389
- // followUp: harmless when idle, and correct if the worker is still
390
- // finishing something the dispatcher did not see yet.
391
- this.send(worker, { type: "prompt", message: job.prompt, streamingBehavior: "followUp" });
392
- this.record(job, "start", force ? { detail: "fresh session" } : {});
393
- }
394
-
395
- private launch(job: DispatchJob, fresh: boolean): Worker | undefined {
396
- this.makeRoom();
397
-
398
- const identity: WorkerIdentity = {
399
- chatId: job.chatId,
400
- label: job.label,
401
- chatType: job.chatType,
402
- peer: job.chatType === "oneOnOne" ? job.peer : undefined,
403
- };
404
- const dir = chatSessionDir(this.options.agentDir, job.chatId);
405
- try {
406
- mkdirSync(dir, { recursive: true, mode: 0o700 });
407
- } catch {
408
- /* spawn reports the real problem */
409
- }
410
-
411
- const resume = !fresh && shouldContinue(dir, this.config.freshAfterHours, this.now());
412
- const args = [
413
- ...this.config.args,
414
- "--mode",
415
- "rpc",
416
- "--session-dir",
417
- dir,
418
- ...(resume ? ["--continue"] : ["--name", `teams: ${job.label}`]),
419
- ];
420
-
421
- let proc: WorkerProcess;
422
- try {
423
- proc = this.spawnWorker(this.config.command, args, {
424
- cwd: this.options.cwd,
425
- env: { ...(this.options.env ?? process.env), ...workerEnv(identity) },
426
- });
427
- } catch (err) {
428
- const message = err instanceof Error ? err.message : String(err);
429
- this.record(job, "error", { detail: `could not start ${this.config.command}: ${message}` });
430
- this.options.onError?.(`Teams dispatch: could not start a worker for "${job.label}": ${message}`);
431
- return undefined;
432
- }
433
-
434
- const now = this.now();
435
- const worker: Worker = {
436
- identity,
437
- proc,
438
- busy: false,
439
- startedAt: now,
440
- lastActivity: now,
441
- buffer: "",
442
- stderrTail: "",
443
- retiring: false,
444
- };
445
- this.workers.set(job.chatId, worker);
446
-
447
- proc.stdin?.on("error", () => undefined);
448
- proc.stdout?.on("data", (chunk) => this.onData(worker, chunk));
449
- proc.stderr?.on("data", (chunk) => {
450
- worker.stderrTail = (worker.stderrTail + chunk.toString()).slice(-2000);
451
- });
452
- proc.on("error", (err) => this.onExit(worker, `error: ${err.message}`));
453
- proc.on("exit", (code, signal) => this.onExit(worker, signal ? `signal ${signal}` : `exit code ${code}`));
454
- return worker;
455
- }
456
-
457
- /** Keep the number of live workers under `maxWorkers` by ending the longest-idle one. */
458
- private makeRoom(): void {
459
- while (this.workers.size >= this.config.maxWorkers) {
460
- const idle = [...this.workers.values()].filter((w) => !w.busy).sort((a, b) => a.lastActivity - b.lastActivity);
461
- if (idle.length === 0) return;
462
- this.retire(idle[0]!, "room");
463
- }
464
- }
465
-
466
- /** End a worker on purpose. Closing stdin lets pi shut down cleanly. */
467
- private retire(worker: Worker, _why: string): void {
468
- worker.retiring = true;
469
- if (this.workers.get(worker.identity.chatId) === worker) this.workers.delete(worker.identity.chatId);
470
- try {
471
- worker.proc.stdin?.end();
472
- } catch {
473
- /* ignore */
474
- }
475
- const timer = setTimeout(() => {
476
- try {
477
- worker.proc.kill("SIGTERM");
478
- } catch {
479
- /* already gone */
480
- }
481
- }, 10_000);
482
- timer.unref?.();
483
- }
484
-
485
- private onData(worker: Worker, chunk: Buffer | string): void {
486
- worker.buffer += chunk.toString();
487
- let index = worker.buffer.indexOf("\n");
488
- while (index >= 0) {
489
- const line = worker.buffer.slice(0, index).replace(/\r$/, "");
490
- worker.buffer = worker.buffer.slice(index + 1);
491
- if (line.trim()) this.onLine(worker, line);
492
- index = worker.buffer.indexOf("\n");
493
- }
494
- }
495
-
496
- private onLine(worker: Worker, line: string): void {
497
- let event: any;
498
- try {
499
- event = JSON.parse(line);
500
- } catch {
501
- return;
502
- }
503
- if (!event || typeof event !== "object") return;
504
-
505
- switch (event.type) {
506
- case "agent_start":
507
- // A steer that arrived just after the worker settled starts a new run.
508
- if (!worker.busy) {
509
- worker.busy = true;
510
- worker.startedAt = this.now();
511
- }
512
- return;
513
- case "message_end": {
514
- const text = assistantText(event.message);
515
- if (text) worker.lastText = text;
516
- return;
517
- }
518
- case "agent_settled":
519
- this.finish(worker);
520
- return;
521
- case "extension_ui_request":
522
- // Nobody sits in front of a worker. A dialog would wait forever.
523
- if (DIALOG_METHODS.has(event.method)) {
524
- this.send(worker, { type: "extension_ui_response", id: event.id, cancelled: true });
525
- }
526
- return;
527
- case "response":
528
- if (event.success === false) {
529
- const detail = `${event.command ?? "command"} failed: ${event.error ?? "unknown error"}`;
530
- if (worker.job) this.record(worker.job, "error", { detail });
531
- this.options.onError?.(`Teams dispatch (${worker.identity.label}): ${detail}`);
532
- if (event.command === "prompt" && worker.busy) this.finish(worker, true);
533
- }
534
- return;
535
- default:
536
- return;
537
- }
538
- }
539
-
540
- private finish(worker: Worker, failed = false): void {
541
- if (!worker.busy) return;
542
- const now = this.now();
543
- worker.busy = false;
544
- worker.lastActivity = now;
545
- if (worker.job && !failed) {
546
- this.record(worker.job, "done", { durationMs: now - worker.startedAt, result: excerpt(worker.lastText) });
547
- }
548
- this.drain();
549
- }
550
-
551
- private onExit(worker: Worker, how: string): void {
552
- const current = this.workers.get(worker.identity.chatId) === worker;
553
- if (current) this.workers.delete(worker.identity.chatId);
554
- if (!worker.retiring && worker.busy && worker.job) {
555
- const detail = `worker ended (${how})${worker.stderrTail ? `: ${excerpt(worker.stderrTail, 200)}` : ""}`;
556
- this.record(worker.job, "error", { detail });
557
- this.options.onError?.(`Teams dispatch (${worker.identity.label}): ${detail}`);
558
- }
559
- worker.busy = false;
560
- worker.retiring = true;
561
- if (!this.stopped) this.drain();
562
- }
563
-
564
- /** Start waiting chats while there is room. */
565
- private drain(): void {
566
- while (this.queue.length > 0 && this.busyCount() < this.config.maxConcurrent) {
567
- const job = this.queue.shift()!;
568
- const worker = this.workers.get(job.chatId);
569
- if (worker?.busy) {
570
- this.send(worker, { type: "prompt", message: job.prompt, streamingBehavior: "steer" });
571
- this.record(job, "steer");
572
- continue;
573
- }
574
- this.start(job, { fresh: false });
575
- }
576
- }
577
- }