@patimweb/pi-teams 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Listen mode — control words in a chat.
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.
13
+ */
14
+
15
+ import type { ResolvedDispatchConfig } from "../config/index.ts";
16
+ import type { MessageSummary } from "../types.ts";
17
+
18
+ export type ControlAction = "stop" | "reset";
19
+
20
+ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+
22
+ /** Lower-case the text and drop mentions, punctuation and extra spaces. */
23
+ export function normalizeCommand(text: string, names: string[] = []): string {
24
+ let result = ` ${text.toLowerCase()} `;
25
+ const sorted = [...new Set(names.map((n) => n.trim().toLowerCase()).filter(Boolean))].sort((a, b) => b.length - a.length);
26
+ for (const name of sorted) {
27
+ result = result.replace(new RegExp(`(^|[\\s@])${escapeRegExp(name)}(?=[\\s,.:;!?]|$)`, "g"), " ");
28
+ }
29
+ return result
30
+ .replace(/@/g, " ")
31
+ .replace(/[.,;:!?"'„“”«»()[\]]/g, " ")
32
+ .replace(/\s+/g, " ")
33
+ .trim();
34
+ }
35
+
36
+ /**
37
+ * The names pi can be addressed by, for stripping a leading mention.
38
+ *
39
+ * "Neo (neoimpulse)" is mentioned as "Neo" in Teams, so the part before the
40
+ * first space or bracket counts as well.
41
+ */
42
+ export function selfNames(displayName: string | undefined): string[] {
43
+ if (!displayName) return [];
44
+ const short = displayName.split(/[\s(]/)[0] ?? "";
45
+ return [displayName, short].filter(Boolean);
46
+ }
47
+
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 {
54
+ 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;
60
+ }
@@ -0,0 +1,577 @@
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
+ }