taskplane 0.22.10 → 0.22.12

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.
@@ -28,8 +28,8 @@
28
28
  */
29
29
 
30
30
  import { spawn } from "node:child_process";
31
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs";
32
- import { dirname, resolve } from "node:path";
31
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
32
+ import { dirname, resolve, join, basename } from "node:path";
33
33
  import { StringDecoder } from "node:string_decoder";
34
34
 
35
35
  // ── CLI Argument Parsing ─────────────────────────────────────────────
@@ -45,6 +45,7 @@ function parseArgs(argv) {
45
45
  extensions: [],
46
46
  passthrough: [],
47
47
  help: false,
48
+ mailboxDir: null,
48
49
  };
49
50
 
50
51
  let i = 2; // skip "node" and script path
@@ -74,6 +75,9 @@ function parseArgs(argv) {
74
75
  } else if (arg === "--extensions" && i + 1 < argv.length) {
75
76
  args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
76
77
  i++;
78
+ } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
79
+ args.mailboxDir = argv[++i];
80
+ i++;
77
81
  } else if (arg === "--") {
78
82
  args.passthrough = argv.slice(i + 1);
79
83
  break;
@@ -103,6 +107,7 @@ Optional:
103
107
  --system-prompt-file <path> Path to system prompt file
104
108
  --tools <t1,t2,...> Comma-separated tool names
105
109
  --extensions <e1,e2,...> Comma-separated extension paths
110
+ --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
106
111
  -h, --help Show this help
107
112
  `
108
113
  );
@@ -487,6 +492,160 @@ function createSingleWriteGuard(writer) {
487
492
  };
488
493
  }
489
494
 
495
+ // ── Agent Mailbox Check (TP-089) ─────────────────────────────────────
496
+
497
+ /**
498
+ * Valid mailbox message types (must match MailboxMessageType in types.ts).
499
+ */
500
+ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
501
+
502
+ /**
503
+ * Check the agent's mailbox inbox for pending messages and inject them
504
+ * into the pi process via the `steer` RPC command.
505
+ *
506
+ * Called on every `message_end` event when `--mailbox-dir` is provided.
507
+ * Messages are validated (batchId, to, shape), sorted deterministically,
508
+ * injected via steer, and moved from inbox/ to ack/.
509
+ *
510
+ * @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
511
+ * @param {object} proc - The spawned pi process (must have writable stdin)
512
+ * @returns {{ delivered: number, skipped: number }} Delivery stats
513
+ */
514
+ function checkMailboxAndSteer(mailboxDir, proc) {
515
+ const stats = { delivered: 0, skipped: 0 };
516
+
517
+ // Derive expected values from path structure:
518
+ // mailboxDir = .pi/mailbox/{batchId}/{sessionName}
519
+ const expectedSessionName = basename(mailboxDir);
520
+ const expectedBatchId = basename(dirname(mailboxDir));
521
+
522
+ const inboxDir = join(mailboxDir, "inbox");
523
+
524
+ // Read inbox — ENOENT is quiet no-op (inbox may not exist yet)
525
+ let entries;
526
+ try {
527
+ entries = readdirSync(inboxDir);
528
+ } catch (err) {
529
+ if (err.code === "ENOENT") return stats;
530
+ process.stderr.write(`\n[STEERING] WARNING: failed to read inbox: ${err.message}\n`);
531
+ return stats;
532
+ }
533
+
534
+ // Filter: only *.msg.json files (excludes .msg.json.tmp temp files)
535
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
536
+ if (msgFiles.length === 0) return stats;
537
+
538
+ // Read and validate all messages
539
+ const validMessages = [];
540
+
541
+ for (const filename of msgFiles) {
542
+ const filePath = join(inboxDir, filename);
543
+ let raw;
544
+ try {
545
+ raw = readFileSync(filePath, "utf-8");
546
+ } catch (err) {
547
+ process.stderr.write(`\n[STEERING] WARNING: failed to read ${filename}: ${err.message}\n`);
548
+ stats.skipped++;
549
+ continue;
550
+ }
551
+
552
+ let msg;
553
+ try {
554
+ msg = JSON.parse(raw);
555
+ } catch {
556
+ process.stderr.write(`\n[STEERING] WARNING: malformed JSON in ${filename}, skipping\n`);
557
+ stats.skipped++;
558
+ continue;
559
+ }
560
+
561
+ // Validate shape
562
+ if (!isValidMailboxMessageShape(msg)) {
563
+ process.stderr.write(`\n[STEERING] WARNING: invalid message shape in ${filename}, skipping\n`);
564
+ stats.skipped++;
565
+ continue;
566
+ }
567
+
568
+ // Validate batchId (derived from path, not message content)
569
+ if (msg.batchId !== expectedBatchId) {
570
+ process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`);
571
+ stats.skipped++;
572
+ continue;
573
+ }
574
+
575
+ // Validate to (no misdelivery)
576
+ if (msg.to !== expectedSessionName) {
577
+ process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`);
578
+ stats.skipped++;
579
+ continue;
580
+ }
581
+
582
+ validMessages.push({ filename, message: msg });
583
+ }
584
+
585
+ // Sort: primary by timestamp ascending, tie-break by filename lexical
586
+ validMessages.sort((a, b) => {
587
+ const tsDiff = a.message.timestamp - b.message.timestamp;
588
+ if (tsDiff !== 0) return tsDiff;
589
+ return a.filename.localeCompare(b.filename);
590
+ });
591
+
592
+ // Inject each message via steer RPC command and move to ack/
593
+ for (const { filename, message } of validMessages) {
594
+ try {
595
+ // Precondition: stdin must be available for injection.
596
+ // If stdin is closed/destroyed, keep message in inbox (no false ack).
597
+ if (!proc.stdin || proc.stdin.destroyed) {
598
+ stats.skipped++;
599
+ continue;
600
+ }
601
+
602
+ // Inject via steer RPC command
603
+ proc.stdin.write(JSON.stringify({ type: "steer", message: message.content }) + "\n");
604
+
605
+ // Move to ack/ (delivery proof)
606
+ const ackDir = join(mailboxDir, "ack");
607
+ try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ }
608
+ try {
609
+ renameSync(join(inboxDir, filename), join(ackDir, filename));
610
+ } catch (err) {
611
+ // ENOENT race is harmless (another process acked it)
612
+ if (err.code !== "ENOENT") {
613
+ process.stderr.write(`\n[STEERING] WARNING: failed to ack ${filename}: ${err.message}\n`);
614
+ }
615
+ }
616
+
617
+ stats.delivered++;
618
+ process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
619
+ } catch (err) {
620
+ process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
621
+ stats.skipped++;
622
+ }
623
+ }
624
+
625
+ return stats;
626
+ }
627
+
628
+ /**
629
+ * Runtime validation for mailbox message shape in rpc-wrapper.
630
+ * Mirrors isValidMailboxMessage() from mailbox.ts but as a standalone
631
+ * function (rpc-wrapper.mjs is a plain .mjs module, not TypeScript).
632
+ *
633
+ * @param {any} obj - Parsed JSON value
634
+ * @returns {boolean} true if valid shape
635
+ */
636
+ function isValidMailboxMessageShape(obj) {
637
+ if (!obj || typeof obj !== "object") return false;
638
+ return (
639
+ typeof obj.id === "string" &&
640
+ typeof obj.batchId === "string" &&
641
+ typeof obj.from === "string" &&
642
+ typeof obj.to === "string" &&
643
+ typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
644
+ typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
645
+ typeof obj.content === "string"
646
+ );
647
+ }
648
+
490
649
  // ── Exports for Testing ──────────────────────────────────────────────
491
650
 
492
651
  // Export pure functions so tests can import them without triggering side effects.
@@ -503,6 +662,9 @@ export {
503
662
  applyEvent,
504
663
  buildExitSummary,
505
664
  createSingleWriteGuard,
665
+ checkMailboxAndSteer,
666
+ isValidMailboxMessageShape,
667
+ MAILBOX_MESSAGE_TYPES,
506
668
  };
507
669
 
508
670
  // ── Main ─────────────────────────────────────────────────────────────
@@ -602,6 +764,15 @@ const proc = spawn("pi", piArgs, {
602
764
  const promptCmd = { type: "prompt", message: promptContent };
603
765
  proc.stdin.write(JSON.stringify(promptCmd) + "\n");
604
766
 
767
+ // ── Agent Mailbox Steering Setup (TP-089) ────────────────────────────
768
+ // When mailbox-dir is provided, set steering mode to "all" so queued
769
+ // steering messages are delivered together at the next turn boundary.
770
+ // Must be sent after prompt but before any agent processing begins.
771
+ if (args.mailboxDir) {
772
+ proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
773
+ process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`);
774
+ }
775
+
605
776
  // ── Stdin Lifecycle ──────────────────────────────────────────────────
606
777
 
607
778
  /**
@@ -640,11 +811,31 @@ function querySessionStats() {
640
811
 
641
812
  // ── Route RPC events ─────────────────────────────────────────────────
642
813
 
814
+ // Event types worth persisting to the sidecar JSONL.
815
+ // Streaming deltas (content_block_delta, content_block_start/stop, message_start,
816
+ // input_json_delta, etc.) are omitted — they're high-volume, large, and not used
817
+ // by the dashboard or telemetry consumers. A single merge agent can produce 42MB+
818
+ // of sidecar data from streaming deltas alone.
819
+ const SIDECAR_EVENT_TYPES = new Set([
820
+ "agent_start",
821
+ "agent_end",
822
+ "message_end",
823
+ "tool_execution_start",
824
+ "tool_execution_end",
825
+ "tool_execution_update",
826
+ "auto_retry_start",
827
+ "auto_retry_end",
828
+ "auto_compaction_start",
829
+ "response",
830
+ ]);
831
+
643
832
  function handleEvent(event) {
644
833
  if (!event || !event.type) return;
645
834
 
646
- // Write ALL events to sidecar (redacted)
647
- writeSidecarEvent(args.sidecarPath, event);
835
+ // Write only telemetry-relevant events to sidecar (redacted)
836
+ if (SIDECAR_EVENT_TYPES.has(event.type)) {
837
+ writeSidecarEvent(args.sidecarPath, event);
838
+ }
648
839
 
649
840
  // Delegate state mutation to the extracted (testable) accumulator
650
841
  applyEvent(state, event);
@@ -657,6 +848,16 @@ function handleEvent(event) {
657
848
  // Falls back gracefully: older pi versions ignore the command
658
849
  // or return a response without contextUsage — state.contextUsage stays null.
659
850
  querySessionStats();
851
+ // Check mailbox for pending steering messages (TP-089).
852
+ // Only active when --mailbox-dir is provided (backward compatible).
853
+ if (args.mailboxDir) {
854
+ try {
855
+ checkMailboxAndSteer(args.mailboxDir, proc);
856
+ } catch (err) {
857
+ // Never crash on mailbox I/O errors
858
+ process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
859
+ }
860
+ }
660
861
  break;
661
862
 
662
863
  case "tool_execution_start":
@@ -1121,7 +1121,7 @@ function extractVerdict(reviewContent: string): string {
1121
1121
  // TP-068: Tolerate non-standard verdict formats from models that don't
1122
1122
  // follow the exact template (e.g., "Changes requested", "Needs revision").
1123
1123
  const lower = reviewContent.toLowerCase();
1124
- if (/\b(changes?\s+requested|needs?\s+revision|please\s+revise|must\s+revise)\b/.test(lower)) {
1124
+ if (/\b(request\s+changes?|changes?\s+requested|needs?\s+revision|please\s+revise|must\s+revise)\b/.test(lower)) {
1125
1125
  return "REVISE";
1126
1126
  }
1127
1127
  if (/\b(looks?\s+good|no\s+issues?\s+found|approved?)\b/.test(lower)) {
@@ -1835,6 +1835,15 @@ function spawnAgentTmux(opts: {
1835
1835
  if (opts.extensions && opts.extensions.length > 0) {
1836
1836
  wrapperArgs.push("--extensions", quoteArg(opts.extensions.join(",")));
1837
1837
  }
1838
+ // TP-089: Agent mailbox steering — construct mailbox dir when in orchestrator mode.
1839
+ // ORCH_BATCH_ID is set by execution.ts for all lane spawns (including retries).
1840
+ // getSidecarDir() returns the .pi/ directory path (already includes .pi/).
1841
+ const orchBatchId = process.env.ORCH_BATCH_ID;
1842
+ if (orchBatchId) {
1843
+ const mailboxDir = join(getSidecarDir(), "mailbox", orchBatchId, opts.sessionName);
1844
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
1845
+ wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
1846
+ }
1838
1847
  // Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
1839
1848
  // Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
1840
1849
  wrapperArgs.push("--");
@@ -19,8 +19,9 @@
19
19
  * @module orch/cleanup
20
20
  * @since TP-065
21
21
  */
22
- import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync } from "fs";
22
+ import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync, rmSync } from "fs";
23
23
  import { join } from "path";
24
+ import { MAILBOX_DIR_NAME } from "./types.ts";
24
25
 
25
26
  // ── Layer 1: Post-Integrate Cleanup ─────────────────────────────────
26
27
 
@@ -34,6 +35,8 @@ export interface PostIntegrateCleanupResult {
34
35
  mergeFilesDeleted: number;
35
36
  /** Number of lane prompt files deleted */
36
37
  promptFilesDeleted: number;
38
+ /** Number of mailbox batch directories deleted (0 or 1) */
39
+ mailboxDirsDeleted: number;
37
40
  /** Warnings from non-fatal cleanup failures */
38
41
  warnings: string[];
39
42
  }
@@ -57,6 +60,7 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
57
60
  telemetryFilesDeleted: 0,
58
61
  mergeFilesDeleted: 0,
59
62
  promptFilesDeleted: 0,
63
+ mailboxDirsDeleted: 0,
60
64
  warnings: [],
61
65
  };
62
66
 
@@ -119,6 +123,17 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
119
123
  }
120
124
  }
121
125
 
126
+ // ── Mailbox directory (.pi/mailbox/{batchId}/) ───────────
127
+ const mailboxBatchDir = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
128
+ if (existsSync(mailboxBatchDir)) {
129
+ try {
130
+ rmSync(mailboxBatchDir, { recursive: true, force: true });
131
+ result.mailboxDirsDeleted = 1;
132
+ } catch (err: unknown) {
133
+ result.warnings.push(`Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`);
134
+ }
135
+ }
136
+
122
137
  return result;
123
138
  }
124
139
 
@@ -127,13 +142,14 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
127
142
  */
128
143
  export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string {
129
144
  const parts: string[] = [];
130
- const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted;
145
+ const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted + result.mailboxDirsDeleted;
131
146
 
132
147
  if (totalDeleted > 0) {
133
148
  const segments: string[] = [];
134
149
  if (result.telemetryFilesDeleted > 0) segments.push(`${result.telemetryFilesDeleted} telemetry`);
135
150
  if (result.mergeFilesDeleted > 0) segments.push(`${result.mergeFilesDeleted} merge`);
136
151
  if (result.promptFilesDeleted > 0) segments.push(`${result.promptFilesDeleted} prompt`);
152
+ if (result.mailboxDirsDeleted > 0) segments.push(`${result.mailboxDirsDeleted} mailbox`);
137
153
  parts.push(`🧹 Cleaned up ${totalDeleted} artifact file(s): ${segments.join(", ")}`);
138
154
  }
139
155
 
@@ -155,6 +171,8 @@ export const STALE_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
155
171
  export interface PreflightSweepResult {
156
172
  /** Number of stale files deleted */
157
173
  staleFilesDeleted: number;
174
+ /** Number of stale mailbox batch directories deleted */
175
+ staleDirsDeleted: number;
158
176
  /** Whether the sweep was skipped (e.g., active batch) */
159
177
  skipped: boolean;
160
178
  /** Reason for skipping (if skipped) */
@@ -198,6 +216,7 @@ export function sweepStaleArtifacts(
198
216
  ): PreflightSweepResult {
199
217
  const result: PreflightSweepResult = {
200
218
  staleFilesDeleted: 0,
219
+ staleDirsDeleted: 0,
201
220
  skipped: false,
202
221
  warnings: [],
203
222
  };
@@ -255,6 +274,29 @@ export function sweepStaleArtifacts(
255
274
  (name.startsWith("merge-request-") && name.endsWith(".txt")),
256
275
  );
257
276
 
277
+ // Sweep stale mailbox batch directories (.pi/mailbox/{batchId}/)
278
+ const mailboxBase = join(stateRoot, ".pi", MAILBOX_DIR_NAME);
279
+ if (existsSync(mailboxBase)) {
280
+ try {
281
+ const entries = readdirSync(mailboxBase);
282
+ for (const entry of entries) {
283
+ const entryPath = join(mailboxBase, entry);
284
+ try {
285
+ const stat = statSync(entryPath);
286
+ if (!stat.isDirectory()) continue;
287
+ if (stat.mtimeMs < cutoff) {
288
+ rmSync(entryPath, { recursive: true, force: true });
289
+ result.staleDirsDeleted++;
290
+ }
291
+ } catch (err: unknown) {
292
+ result.warnings.push(`Failed to process mailbox dir ${entry}: ${(err as Error).message}`);
293
+ }
294
+ }
295
+ } catch (err: unknown) {
296
+ result.warnings.push(`Failed to read mailbox directory ${mailboxBase}: ${(err as Error).message}`);
297
+ }
298
+ }
299
+
258
300
  return result;
259
301
  }
260
302
 
@@ -265,12 +307,15 @@ export function formatPreflightSweep(result: PreflightSweepResult): string {
265
307
  if (result.skipped) {
266
308
  return `ℹ️ Preflight sweep skipped: ${result.skipReason}`;
267
309
  }
268
- if (result.staleFilesDeleted === 0 && result.warnings.length === 0) {
310
+ if (result.staleFilesDeleted === 0 && result.staleDirsDeleted === 0 && result.warnings.length === 0) {
269
311
  return ""; // Nothing to report
270
312
  }
271
313
  const parts: string[] = [];
272
- if (result.staleFilesDeleted > 0) {
273
- parts.push(`🧹 Preflight cleanup: removed ${result.staleFilesDeleted} stale artifact(s) (>7 days old)`);
314
+ if (result.staleFilesDeleted > 0 || result.staleDirsDeleted > 0) {
315
+ const segments: string[] = [];
316
+ if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
317
+ if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
318
+ parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>7 days old)`);
274
319
  }
275
320
  for (const warning of result.warnings) {
276
321
  parts.push(` ⚠️ ${warning}`);
@@ -396,8 +441,11 @@ export function formatPreflightCleanup(result: PreflightCleanupResult): string {
396
441
  const parts: string[] = [];
397
442
 
398
443
  // Layer 2: age-based sweep
399
- if (!result.sweep.skipped && result.sweep.staleFilesDeleted > 0) {
400
- parts.push(`removed ${result.sweep.staleFilesDeleted} stale artifact(s) (>7 days old)`);
444
+ if (!result.sweep.skipped && (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0)) {
445
+ const segments: string[] = [];
446
+ if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
447
+ if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
448
+ parts.push(`removed ${segments.join(" and ")} (>7 days old)`);
401
449
  }
402
450
 
403
451
  // Layer 3: log rotation
@@ -139,7 +139,7 @@ export interface ExitSummary {
139
139
  compactions: number;
140
140
  /** Wall-clock duration of the session in seconds (always written, even on crash) */
141
141
  durationSec: number;
142
- /** Last tool call description (e.g., "bash: npx vitest run"), null if no tools were called */
142
+ /** Last tool call description (e.g., "bash: node --test tests/*.test.ts"), null if no tools were called */
143
143
  lastToolCall: string | null;
144
144
  /** Error message if the session ended with an error, null on clean exit */
145
145
  error: string | null;
@@ -85,7 +85,7 @@ export interface SerializedWorkspaceConfig {
85
85
  * workerData shape passed from the main thread.
86
86
  */
87
87
  export interface EngineWorkerData {
88
- /** Sentinel flag — distinguishes engine worker from vitest threads */
88
+ /** Sentinel flag — distinguishes engine worker from test-runner worker threads */
89
89
  engineWorker: true;
90
90
  /** "execute" for new batch, "resume" for resume */
91
91
  mode: "execute" | "resume";
@@ -228,6 +228,7 @@ async function attemptWorkerCrashRetry(
228
228
  retryPauseSignal,
229
229
  wsRoot,
230
230
  isWsMode,
231
+ { ORCH_BATCH_ID: batchState.batchId }, // TP-089: ensure mailbox works for retries
231
232
  );
232
233
 
233
234
  const retryOutcome = retryResult.tasks[0];
@@ -484,7 +485,8 @@ async function attemptModelFallbackRetry(
484
485
  const retryPauseSignal = { paused: false };
485
486
  // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
486
487
  // the task-runner to use the session model instead of configured model.
487
- const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1" };
488
+ // TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
489
+ const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId };
488
490
  const retryResult = await executeLane(
489
491
  retryLane,
490
492
  orchConfig,
@@ -915,10 +915,8 @@ export function spawnLaneSession(
915
915
 
916
916
  // Build env vars
917
917
  const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
918
- // Pass batch ID so task-runner can include it in lane state for dashboard filtering
919
- if (config.orchestrator?.batchId) {
920
- envVars.ORCH_BATCH_ID = config.orchestrator.batchId;
921
- }
918
+ // ORCH_BATCH_ID is passed via extraEnvVars from executeWave executeLane spawnLaneSession.
919
+ // The task-runner reads it to include batchId in lane-state JSON for dashboard filtering.
922
920
  if (extraEnvVars) {
923
921
  Object.assign(envVars, extraEnvVars);
924
922
  }
@@ -2371,7 +2369,9 @@ export async function executeWave(
2371
2369
  const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
2372
2370
  const isWsMode = !!workspaceConfig;
2373
2371
  const lanePromises = lanes.map(lane =>
2374
- executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode),
2372
+ executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, {
2373
+ ORCH_BATCH_ID: batchId,
2374
+ }),
2375
2375
  );
2376
2376
 
2377
2377
  // Start monitoring as a sibling async loop
@@ -32,6 +32,8 @@ import { runMigrations } from "./migrations.ts";
32
32
  import { serializeWorkspaceConfig, applySerializedState, deserializeWorkspaceConfig } from "./engine-worker.ts";
33
33
  import type { EngineWorkerData, WorkerToMainMessage } from "./engine-worker.ts";
34
34
  import { cleanupPostIntegrate, formatPostIntegrateCleanup, sweepStaleArtifacts, formatPreflightSweep, rotateSupervisorLogs, formatLogRotation } from "./cleanup.ts";
35
+ import { writeMailboxMessage } from "./mailbox.ts";
36
+ import type { MailboxMessageType } from "./types.ts";
35
37
  import {
36
38
  activateSupervisor,
37
39
  deactivateSupervisor,
@@ -2992,12 +2994,18 @@ export default function (pi: ExtensionAPI) {
2992
2994
  if (batchId) {
2993
2995
  try {
2994
2996
  const artifactCleanup = cleanupPostIntegrate(repoRoot, batchId);
2995
- const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted;
2997
+ const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted + artifactCleanup.mailboxDirsDeleted;
2996
2998
  if (totalCleaned > 0) {
2999
+ const cleanupParts = [
3000
+ `${artifactCleanup.telemetryFilesDeleted} telemetry file(s)`,
3001
+ `${artifactCleanup.mergeFilesDeleted} merge result(s)`,
3002
+ `${artifactCleanup.promptFilesDeleted} prompt file(s)`,
3003
+ ];
3004
+ if (artifactCleanup.mailboxDirsDeleted > 0) {
3005
+ cleanupParts.push(`${artifactCleanup.mailboxDirsDeleted} mailbox dir(s)`);
3006
+ }
2997
3007
  outputLines.push(
2998
- `🧹 Cleaned up ${artifactCleanup.telemetryFilesDeleted} telemetry file(s), ` +
2999
- `${artifactCleanup.mergeFilesDeleted} merge result(s), ` +
3000
- `${artifactCleanup.promptFilesDeleted} prompt file(s) for batch ${batchId}`,
3008
+ `🧹 Cleaned up ${cleanupParts.join(", ")} for batch ${batchId}`,
3001
3009
  );
3002
3010
  }
3003
3011
  if (artifactCleanup.warnings.length > 0) {
@@ -3639,6 +3647,112 @@ export default function (pi: ExtensionAPI) {
3639
3647
  },
3640
3648
  });
3641
3649
 
3650
+ // ── TP-089: Agent Mailbox Steering Tool ──────────────────────────
3651
+
3652
+ pi.registerTool({
3653
+ name: "send_agent_message",
3654
+ label: "Send Agent Message",
3655
+ description:
3656
+ "Send a steering message to a running agent (worker, reviewer, or merger). " +
3657
+ "The message is delivered into the agent's LLM context at the next turn boundary.",
3658
+ promptSnippet: "send_agent_message(to, content, type?) — send steering message to a running agent",
3659
+ promptGuidelines: [
3660
+ "Call send_agent_message to course-correct a running agent (worker, reviewer, or merger).",
3661
+ "The 'to' parameter must be a valid agent session name from the current batch.",
3662
+ "Use orch_status() to see active session names.",
3663
+ "Default type is 'steer' (course correction). Other types: 'query', 'abort', 'info'.",
3664
+ "Messages are limited to 4KB. For larger context, write to a file and reference by path.",
3665
+ ],
3666
+ parameters: Type.Object({
3667
+ to: Type.String({
3668
+ description: "Target agent session name (e.g., 'orch-henrylach-lane-1-worker')",
3669
+ }),
3670
+ content: Type.String({
3671
+ description: "Message content (max 4KB). Concise directive for the agent.",
3672
+ }),
3673
+ type: Type.Optional(Type.Union(
3674
+ [Type.Literal("steer"), Type.Literal("query"), Type.Literal("abort"), Type.Literal("info")],
3675
+ { description: 'Message type (default: "steer")' },
3676
+ )),
3677
+ }),
3678
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3679
+ try {
3680
+ const result = doSendAgentMessage(params.to, params.content, params.type ?? "steer", ctx);
3681
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
3682
+ } catch (err) {
3683
+ return {
3684
+ content: [{ type: "text" as const, text: `Error sending message: ${err instanceof Error ? err.message : String(err)}` }],
3685
+ details: undefined,
3686
+ };
3687
+ }
3688
+ },
3689
+ });
3690
+
3691
+ /**
3692
+ * Send a steering message to a running agent via the mailbox system.
3693
+ *
3694
+ * Resolves the target session from batch state, validates it exists,
3695
+ * and writes the message to the agent's inbox.
3696
+ *
3697
+ * @since TP-089
3698
+ */
3699
+ function doSendAgentMessage(to: string, content: string, messageType: string, ctx: ExtensionContext): string {
3700
+ const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
3701
+
3702
+ // Validate message type (outbound allowlist: steer, query, abort, info)
3703
+ const validOutboundTypes = new Set(["steer", "query", "abort", "info"]);
3704
+ if (!validOutboundTypes.has(messageType)) {
3705
+ return `❌ Invalid message type "${messageType}". Valid types: steer, query, abort, info.`;
3706
+ }
3707
+
3708
+ // Load batch state
3709
+ let state: PersistedBatchState | null = null;
3710
+ try {
3711
+ state = loadBatchState(stateRoot);
3712
+ } catch (err) {
3713
+ return `❌ Failed to load batch state: ${err instanceof Error ? err.message : String(err)}`;
3714
+ }
3715
+ if (!state) {
3716
+ return "❌ No batch state found. There is no active or recent batch.";
3717
+ }
3718
+
3719
+ // Build the set of valid agent session names from batch state
3720
+ const validSessions = new Set<string>();
3721
+ const orchConfig = execCtx?.orchestratorConfig;
3722
+ const tmuxPrefix = orchConfig?.orchestrator?.tmux_prefix ?? "orch";
3723
+ const opId = orchConfig ? resolveOperatorId(orchConfig) : "op";
3724
+
3725
+ for (const lane of state.lanes) {
3726
+ // Worker and reviewer are derived from lane session name
3727
+ validSessions.add(`${lane.tmuxSessionName}-worker`);
3728
+ validSessions.add(`${lane.tmuxSessionName}-reviewer`);
3729
+ // Merger: {tmuxPrefix}-{opId}-merge-{laneNumber}
3730
+ validSessions.add(`${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`);
3731
+ }
3732
+
3733
+ // Validate target session
3734
+ if (!validSessions.has(to)) {
3735
+ const examples = [...validSessions].slice(0, 5).join(", ");
3736
+ return `❌ Unknown session "${to}" in batch ${state.batchId}.\nValid targets: ${examples}${validSessions.size > 5 ? ` (${validSessions.size} total)` : ""}`;
3737
+ }
3738
+
3739
+ // Write message to inbox
3740
+ try {
3741
+ const msg = writeMailboxMessage(stateRoot, state.batchId, to, {
3742
+ from: "supervisor",
3743
+ type: messageType as MailboxMessageType,
3744
+ content,
3745
+ });
3746
+ return `✅ Message sent to \`${to}\` (batch ${state.batchId})\n` +
3747
+ `- **ID:** ${msg.id}\n` +
3748
+ `- **Type:** ${messageType}\n` +
3749
+ `- **Size:** ${Buffer.byteLength(content, "utf8")} bytes\n` +
3750
+ `Message will be delivered at the agent's next turn boundary.`;
3751
+ } catch (err) {
3752
+ return `❌ Failed to write message: ${err instanceof Error ? err.message : String(err)}`;
3753
+ }
3754
+ }
3755
+
3642
3756
  // ── Settings TUI ─────────────────────────────────────────────────
3643
3757
 
3644
3758
  pi.registerCommand("taskplane-settings", {