taskplane 0.22.18 → 0.23.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.
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { join, dirname } from "path";
27
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from "fs";
27
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, appendFileSync } from "fs";
28
28
  import { randomBytes } from "crypto";
29
29
  import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts";
30
30
  import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts";
@@ -339,3 +339,351 @@ export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage {
339
339
  typeof m.content === "string"
340
340
  );
341
341
  }
342
+
343
+
344
+ // ── Outbox (Agent → Supervisor, TP-106) ─────────────────────────
345
+
346
+ /**
347
+ * Outbox directory for a specific agent session.
348
+ *
349
+ * @param stateRoot - Root directory containing .pi/
350
+ * @param batchId - Batch ID
351
+ * @param sessionName - Agent ID / session name
352
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/outbox/`
353
+ *
354
+ * @since TP-106
355
+ */
356
+ export function sessionOutboxDir(stateRoot: string, batchId: string, sessionName: string): string {
357
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "outbox");
358
+ }
359
+
360
+ /**
361
+ * Write a reply or escalation message to an agent's outbox.
362
+ *
363
+ * Used by agents (via bridge tools or direct write) to communicate
364
+ * back to the supervisor. The engine or lane-runner polls outbox
365
+ * directories and surfaces messages as supervisor alerts.
366
+ *
367
+ * @param stateRoot - Root directory containing .pi/
368
+ * @param batchId - Current batch ID
369
+ * @param from - Agent ID writing the message
370
+ * @param opts - Message content and metadata
371
+ * @returns The written MailboxMessage
372
+ *
373
+ * @since TP-106
374
+ */
375
+ export function writeOutboxMessage(
376
+ stateRoot: string,
377
+ batchId: string,
378
+ from: string,
379
+ opts: WriteMailboxMessageOpts,
380
+ ): MailboxMessage {
381
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, from);
382
+ mkdirSync(outboxDir, { recursive: true });
383
+
384
+ const contentBytes = Buffer.byteLength(opts.content, "utf8");
385
+ if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
386
+ throw new Error(
387
+ `Outbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit (${contentBytes} bytes).`,
388
+ );
389
+ }
390
+
391
+ const timestamp = Date.now();
392
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
393
+ const id = `${timestamp}-${nonce}`;
394
+
395
+ const message: MailboxMessage = {
396
+ id,
397
+ batchId,
398
+ from,
399
+ to: "supervisor",
400
+ timestamp,
401
+ type: opts.type,
402
+ content: opts.content,
403
+ expectsReply: opts.expectsReply ?? false,
404
+ replyTo: opts.replyTo ?? null,
405
+ };
406
+
407
+ const finalFilename = `${id}.msg.json`;
408
+ const tempFilename = `${id}.msg.json.tmp`;
409
+ const tempPath = join(outboxDir, tempFilename);
410
+ const finalPath = join(outboxDir, finalFilename);
411
+
412
+ try {
413
+ writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
414
+ renameSync(tempPath, finalPath);
415
+ } catch (err) {
416
+ try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* cleanup */ }
417
+ throw new Error(`Failed to write outbox message: ${err instanceof Error ? err.message : String(err)}`);
418
+ }
419
+
420
+ return message;
421
+ }
422
+
423
+ /**
424
+ * Read pending outbox messages from an agent's outbox directory.
425
+ *
426
+ * @param stateRoot - Root directory containing .pi/
427
+ * @param batchId - Batch ID
428
+ * @param agentId - Agent ID whose outbox to read
429
+ * @returns Array of outbox messages sorted by timestamp
430
+ *
431
+ * @since TP-106
432
+ */
433
+ export function readOutbox(
434
+ stateRoot: string,
435
+ batchId: string,
436
+ agentId: string,
437
+ ): MailboxMessage[] {
438
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
439
+ if (!existsSync(outboxDir)) return [];
440
+
441
+ let entries: string[];
442
+ try {
443
+ entries = readdirSync(outboxDir);
444
+ } catch {
445
+ return [];
446
+ }
447
+
448
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
449
+ const messages: MailboxMessage[] = [];
450
+
451
+ for (const filename of msgFiles) {
452
+ try {
453
+ const raw = readFileSync(join(outboxDir, filename), "utf-8");
454
+ const parsed = JSON.parse(raw);
455
+ if (isValidMailboxMessage(parsed)) {
456
+ messages.push(parsed);
457
+ }
458
+ } catch { /* skip malformed */ }
459
+ }
460
+
461
+ messages.sort((a, b) => a.timestamp - b.timestamp);
462
+ return messages;
463
+ }
464
+
465
+ /**
466
+ * Read all outbox messages (pending + processed) for durable history.
467
+ *
468
+ * Unlike readOutbox() which only reads pending messages, this function
469
+ * also reads outbox/processed/ so consumed replies remain visible to
470
+ * the supervisor via read_agent_replies.
471
+ *
472
+ * @param stateRoot - Root directory containing .pi/
473
+ * @param batchId - Batch ID
474
+ * @param agentId - Agent ID whose outbox history to read
475
+ * @returns Array of { message, acked } sorted by timestamp
476
+ *
477
+ * @since TP-091
478
+ */
479
+ export function readOutboxHistory(
480
+ stateRoot: string,
481
+ batchId: string,
482
+ agentId: string,
483
+ ): Array<{ message: MailboxMessage; acked: boolean }> {
484
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
485
+ const results: Array<{ message: MailboxMessage; acked: boolean }> = [];
486
+
487
+ for (const [dir, acked] of [[outboxDir, false], [join(outboxDir, "processed"), true]] as const) {
488
+ if (!existsSync(dir)) continue;
489
+ let entries: string[];
490
+ try { entries = readdirSync(dir); } catch { continue; }
491
+
492
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
493
+ for (const filename of msgFiles) {
494
+ try {
495
+ const raw = readFileSync(join(dir, filename), "utf-8");
496
+ const parsed = JSON.parse(raw);
497
+ if (isValidMailboxMessage(parsed)) {
498
+ results.push({ message: parsed, acked });
499
+ }
500
+ } catch { /* skip malformed */ }
501
+ }
502
+ }
503
+
504
+ results.sort((a, b) => a.message.timestamp - b.message.timestamp);
505
+ return results;
506
+ }
507
+
508
+ /**
509
+ * Ack (consume) a specific outbox message by moving it to processed/.
510
+ *
511
+ * Returns false if the message is already gone (race-safe/idempotent).
512
+ *
513
+ * @since TP-106
514
+ */
515
+ export function ackOutboxMessage(
516
+ stateRoot: string,
517
+ batchId: string,
518
+ agentId: string,
519
+ messageId: string,
520
+ ): boolean {
521
+ const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId);
522
+ const processedDir = join(outboxDir, "processed");
523
+ const file = `${messageId}.msg.json`;
524
+ const srcPath = join(outboxDir, file);
525
+ const dstPath = join(processedDir, file);
526
+
527
+ try {
528
+ mkdirSync(processedDir, { recursive: true });
529
+ renameSync(srcPath, dstPath);
530
+ return true;
531
+ } catch (err: unknown) {
532
+ const code = (err as NodeJS.ErrnoException).code;
533
+ if (code === "ENOENT") return false;
534
+ process.stderr.write(
535
+ `[mailbox] WARNING: failed to ack outbox ${file}: ${err instanceof Error ? err.message : String(err)}\n`,
536
+ );
537
+ return false;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Discover all agent IDs that have mailbox directories for a batch.
543
+ * Returns directory names under .pi/mailbox/{batchId}/ excluding _broadcast.
544
+ * Used to find agents with historical messages even if no longer in the registry.
545
+ *
546
+ * @param stateRoot - Root directory containing .pi/
547
+ * @param batchId - Batch ID
548
+ * @returns Array of agent IDs found in mailbox directories
549
+ *
550
+ * @since TP-091
551
+ */
552
+ export function discoverMailboxAgentIds(
553
+ stateRoot: string,
554
+ batchId: string,
555
+ ): string[] {
556
+ const mbRoot = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
557
+ if (!existsSync(mbRoot)) return [];
558
+ try {
559
+ const entries = readdirSync(mbRoot, { withFileTypes: true });
560
+ return entries
561
+ .filter(e => e.isDirectory() && e.name !== "_broadcast")
562
+ .map(e => e.name);
563
+ } catch {
564
+ return [];
565
+ }
566
+ }
567
+
568
+
569
+ export type MailboxAuditEventType =
570
+ | "message_sent"
571
+ | "message_delivered"
572
+ | "message_replied"
573
+ | "message_escalated"
574
+ | "message_rate_limited";
575
+
576
+ /**
577
+ * Append a mailbox audit event to .pi/mailbox/{batchId}/events.jsonl.
578
+ *
579
+ * Best-effort: logs warning but never throws.
580
+ *
581
+ * @since TP-106
582
+ */
583
+ export function appendMailboxAuditEvent(
584
+ stateRoot: string,
585
+ batchId: string,
586
+ event: {
587
+ type: MailboxAuditEventType;
588
+ ts?: number;
589
+ from?: string;
590
+ to?: string;
591
+ messageId?: string;
592
+ messageType?: string;
593
+ contentPreview?: string;
594
+ broadcast?: boolean;
595
+ reason?: string;
596
+ retryAfterMs?: number;
597
+ },
598
+ ): void {
599
+ const eventsPath = join(mailboxRoot(stateRoot, batchId), "events.jsonl");
600
+ try {
601
+ mkdirSync(dirname(eventsPath), { recursive: true });
602
+ appendFileSync(
603
+ eventsPath,
604
+ JSON.stringify({ batchId, ts: event.ts ?? Date.now(), ...event }) + "\n",
605
+ "utf-8",
606
+ );
607
+ } catch (err) {
608
+ process.stderr.write(
609
+ `[mailbox] WARNING: failed to append mailbox event: ${err instanceof Error ? err.message : String(err)}\n`,
610
+ );
611
+ }
612
+ }
613
+
614
+
615
+ // ── Broadcast (TP-106) ────────────────────────────────────────
616
+
617
+ /**
618
+ * Write a broadcast message to all agents.
619
+ *
620
+ * The message is written to `_broadcast/inbox/`. Agent hosts check
621
+ * this directory alongside their own inbox on each `message_end`.
622
+ *
623
+ * @param stateRoot - Root directory containing .pi/
624
+ * @param batchId - Current batch ID
625
+ * @param opts - Message content and metadata
626
+ * @returns The written MailboxMessage
627
+ *
628
+ * @since TP-106
629
+ */
630
+ export function writeBroadcastMessage(
631
+ stateRoot: string,
632
+ batchId: string,
633
+ opts: WriteMailboxMessageOpts,
634
+ ): MailboxMessage {
635
+ return writeMailboxMessage(stateRoot, batchId, "_broadcast", {
636
+ ...opts,
637
+ from: opts.from || "supervisor",
638
+ });
639
+ }
640
+
641
+
642
+ // ── Rate Limiting (TP-106) ─────────────────────────────────────
643
+
644
+ /** Default rate limit: max 1 message per agent per 30 seconds. */
645
+ export const RATE_LIMIT_WINDOW_MS = 30_000;
646
+
647
+ /** In-memory rate limit tracker. Keyed by target agent ID. */
648
+ const rateLimitTracker = new Map<string, number>();
649
+
650
+ /**
651
+ * Check whether sending a message to a target is rate-limited.
652
+ *
653
+ * @param targetAgentId - Agent ID being sent to
654
+ * @param windowMs - Rate limit window in ms (default: 30_000)
655
+ * @returns Object with `allowed` and optional `retryAfterMs`
656
+ *
657
+ * @since TP-106
658
+ */
659
+ export function checkRateLimit(
660
+ targetAgentId: string,
661
+ windowMs: number = RATE_LIMIT_WINDOW_MS,
662
+ ): { allowed: boolean; retryAfterMs?: number } {
663
+ const lastSent = rateLimitTracker.get(targetAgentId);
664
+ if (!lastSent) return { allowed: true };
665
+
666
+ const elapsed = Date.now() - lastSent;
667
+ if (elapsed >= windowMs) return { allowed: true };
668
+
669
+ return { allowed: false, retryAfterMs: windowMs - elapsed };
670
+ }
671
+
672
+ /**
673
+ * Record a send timestamp for rate limiting.
674
+ *
675
+ * @param targetAgentId - Agent ID that was sent to
676
+ *
677
+ * @since TP-106
678
+ */
679
+ export function recordSend(targetAgentId: string): void {
680
+ rateLimitTracker.set(targetAgentId, Date.now());
681
+ }
682
+
683
+ /**
684
+ * Reset rate limit state (for testing).
685
+ * @since TP-106
686
+ */
687
+ export function _resetRateLimits(): void {
688
+ rateLimitTracker.clear();
689
+ }