taskplane 0.22.11 → 0.22.13

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.
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
2
2
  import { Type } from "@mariozechner/pi-ai";
3
3
 
4
4
  import { execSync, execFileSync } from "child_process";
5
- import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
5
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { fork, type ChildProcess } from "child_process";
@@ -17,7 +17,7 @@ import { computeWaveAssignments } from "./waves.ts";
17
17
  import { createOrchWidget, formatDependencyGraph, formatWavePlan } from "./formatting.ts";
18
18
  import { deleteBatchState, loadBatchState, saveBatchState, detectOrphanSessions, parseOrchSessionNames } from "./persistence.ts";
19
19
  import { deleteStaleBranches, listWorktrees, resolveWorktreeBasePath, formatPreflightResults, runPreflight } from "./worktree.ts";
20
- import { computeTransitiveDependents, executeLane } from "./execution.ts";
20
+ import { computeTransitiveDependents, executeLane, resolveCanonicalTaskPaths, tmuxHasSession } from "./execution.ts";
21
21
  import { executeOrchBatch } from "./engine.ts";
22
22
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
23
23
  import { formatOrchSessions, listOrchSessions } from "./sessions.ts";
@@ -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,
@@ -1142,7 +1144,7 @@ export function startBatchInWorker(
1142
1144
  *
1143
1145
  * @since TP-043 R002
1144
1146
  */
1145
- export function buildIntegrationExecutor(repoRoot: string, opId?: string): IntegrationExecutor {
1147
+ export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateRoot?: string): IntegrationExecutor {
1146
1148
  return (mode, context) => {
1147
1149
  // Ensure we're on the base branch before integrating
1148
1150
  const currentBranch = getCurrentBranch(repoRoot);
@@ -1202,7 +1204,7 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string): Integ
1202
1204
  // TP-065: Post-integrate artifact cleanup (Layer 1).
1203
1205
  // Also runs on the supervisor auto-integration path.
1204
1206
  try {
1205
- cleanupPostIntegrate(repoRoot, context.batchId);
1207
+ cleanupPostIntegrate(stateRoot ?? repoRoot, context.batchId);
1206
1208
  } catch { /* best effort — don't fail integration for cleanup errors */ }
1207
1209
  }
1208
1210
 
@@ -1905,7 +1907,7 @@ export default function (pi: ExtensionAPI) {
1905
1907
  orchBatchState,
1906
1908
  mode,
1907
1909
  repoRoot,
1908
- buildIntegrationExecutor(repoRoot, opId),
1910
+ buildIntegrationExecutor(repoRoot, opId, execCtx!.workspaceRoot),
1909
1911
  buildCiDeps(repoRoot),
1910
1912
  sDeps,
1911
1913
  );
@@ -2125,7 +2127,7 @@ export default function (pi: ExtensionAPI) {
2125
2127
  orchBatchState,
2126
2128
  mode,
2127
2129
  execCtx!.repoRoot,
2128
- buildIntegrationExecutor(execCtx!.repoRoot, opId),
2130
+ buildIntegrationExecutor(execCtx!.repoRoot, opId, execCtx!.workspaceRoot),
2129
2131
  buildCiDeps(execCtx!.repoRoot),
2130
2132
  sDeps,
2131
2133
  );
@@ -2807,6 +2809,7 @@ export default function (pi: ExtensionAPI) {
2807
2809
 
2808
2810
  // Resolve integration context
2809
2811
  const { repoRoot } = execCtx!;
2812
+ const stateRoot = execCtx!.workspaceRoot;
2810
2813
  const resolution = resolveIntegrationContext(parsed, {
2811
2814
  loadBatchState: () => loadBatchState(repoRoot),
2812
2815
  getCurrentBranch: () => getCurrentBranch(repoRoot),
@@ -2991,13 +2994,19 @@ export default function (pi: ExtensionAPI) {
2991
2994
  // Non-fatal — failures warn but don't block integration.
2992
2995
  if (batchId) {
2993
2996
  try {
2994
- const artifactCleanup = cleanupPostIntegrate(repoRoot, batchId);
2995
- const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted;
2997
+ const artifactCleanup = cleanupPostIntegrate(stateRoot, batchId);
2998
+ const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted + artifactCleanup.mailboxDirsDeleted;
2996
2999
  if (totalCleaned > 0) {
3000
+ const cleanupParts = [
3001
+ `${artifactCleanup.telemetryFilesDeleted} telemetry file(s)`,
3002
+ `${artifactCleanup.mergeFilesDeleted} merge result(s)`,
3003
+ `${artifactCleanup.promptFilesDeleted} prompt file(s)`,
3004
+ ];
3005
+ if (artifactCleanup.mailboxDirsDeleted > 0) {
3006
+ cleanupParts.push(`${artifactCleanup.mailboxDirsDeleted} mailbox dir(s)`);
3007
+ }
2997
3008
  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}`,
3009
+ `🧹 Cleaned up ${cleanupParts.join(", ")} for batch ${batchId}`,
3001
3010
  );
3002
3011
  }
3003
3012
  if (artifactCleanup.warnings.length > 0) {
@@ -3639,6 +3648,602 @@ export default function (pi: ExtensionAPI) {
3639
3648
  },
3640
3649
  });
3641
3650
 
3651
+ // ── TP-089: Agent Mailbox Steering Tool ──────────────────────────
3652
+
3653
+ pi.registerTool({
3654
+ name: "send_agent_message",
3655
+ label: "Send Agent Message",
3656
+ description:
3657
+ "Send a steering message to a running agent (worker, reviewer, or merger). " +
3658
+ "The message is delivered into the agent's LLM context at the next turn boundary.",
3659
+ promptSnippet: "send_agent_message(to, content, type?) — send steering message to a running agent",
3660
+ promptGuidelines: [
3661
+ "Call send_agent_message to course-correct a running agent (worker, reviewer, or merger).",
3662
+ "The 'to' parameter must be a valid agent session name from the current batch.",
3663
+ "Use orch_status() to see active session names.",
3664
+ "Default type is 'steer' (course correction). Other types: 'query', 'abort', 'info'.",
3665
+ "Messages are limited to 4KB. For larger context, write to a file and reference by path.",
3666
+ ],
3667
+ parameters: Type.Object({
3668
+ to: Type.String({
3669
+ description: "Target agent session name (e.g., 'orch-henrylach-lane-1-worker')",
3670
+ }),
3671
+ content: Type.String({
3672
+ description: "Message content (max 4KB). Concise directive for the agent.",
3673
+ }),
3674
+ type: Type.Optional(Type.Union(
3675
+ [Type.Literal("steer"), Type.Literal("query"), Type.Literal("abort"), Type.Literal("info")],
3676
+ { description: 'Message type (default: "steer")' },
3677
+ )),
3678
+ }),
3679
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3680
+ try {
3681
+ const result = doSendAgentMessage(params.to, params.content, params.type ?? "steer", ctx);
3682
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
3683
+ } catch (err) {
3684
+ return {
3685
+ content: [{ type: "text" as const, text: `Error sending message: ${err instanceof Error ? err.message : String(err)}` }],
3686
+ details: undefined,
3687
+ };
3688
+ }
3689
+ },
3690
+ });
3691
+
3692
+ /**
3693
+ * Send a steering message to a running agent via the mailbox system.
3694
+ *
3695
+ * Resolves the target session from batch state, validates it exists,
3696
+ * and writes the message to the agent's inbox.
3697
+ *
3698
+ * @since TP-089
3699
+ */
3700
+ function doSendAgentMessage(to: string, content: string, messageType: string, ctx: ExtensionContext): string {
3701
+ const stateRoot = resolveToolStateRoot(ctx);
3702
+
3703
+ // Validate message type (outbound allowlist: steer, query, abort, info)
3704
+ const validOutboundTypes = new Set(["steer", "query", "abort", "info"]);
3705
+ if (!validOutboundTypes.has(messageType)) {
3706
+ return `❌ Invalid message type "${messageType}". Valid types: steer, query, abort, info.`;
3707
+ }
3708
+
3709
+ // Load batch state
3710
+ let state: PersistedBatchState | null = null;
3711
+ try {
3712
+ state = loadBatchState(stateRoot);
3713
+ } catch (err) {
3714
+ return `❌ Failed to load batch state: ${err instanceof Error ? err.message : String(err)}`;
3715
+ }
3716
+ if (!state) {
3717
+ return "❌ No batch state found. There is no active or recent batch.";
3718
+ }
3719
+
3720
+ // Guard: terminal batches have no running agent sessions to receive messages.
3721
+ if (isBatchTerminal(state.phase)) {
3722
+ return `❌ Batch ${state.batchId} is in terminal phase (${state.phase}). Start or resume a batch before sending messages.`;
3723
+ }
3724
+
3725
+ // Build the set of valid agent session names from batch state
3726
+ const validSessions = new Set<string>();
3727
+ const orchConfig = execCtx?.orchestratorConfig;
3728
+ const tmuxPrefix = orchConfig?.orchestrator?.tmux_prefix ?? "orch";
3729
+ const opId = orchConfig ? resolveOperatorId(orchConfig) : "op";
3730
+
3731
+ for (const lane of state.lanes) {
3732
+ // Worker and reviewer are derived from lane session name
3733
+ validSessions.add(`${lane.tmuxSessionName}-worker`);
3734
+ validSessions.add(`${lane.tmuxSessionName}-reviewer`);
3735
+ // Merger: {tmuxPrefix}-{opId}-merge-{laneNumber}
3736
+ validSessions.add(`${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`);
3737
+ }
3738
+
3739
+ // Validate target session
3740
+ if (!validSessions.has(to)) {
3741
+ const examples = [...validSessions].slice(0, 5).join(", ");
3742
+ return `❌ Unknown session "${to}" in batch ${state.batchId}.\nValid targets: ${examples}${validSessions.size > 5 ? ` (${validSessions.size} total)` : ""}`;
3743
+ }
3744
+
3745
+ // Guard: ensure the target tmux session is currently alive.
3746
+ // Prevents false-positive "message sent" confirmations when the
3747
+ // batch is paused/stopped or the agent session has already exited.
3748
+ if (!tmuxHasSession(to)) {
3749
+ return `❌ Session "${to}" is not currently running. Use orch_status() or orch_resume() before sending messages.`;
3750
+ }
3751
+
3752
+ // Write message to inbox
3753
+ try {
3754
+ const msg = writeMailboxMessage(stateRoot, state.batchId, to, {
3755
+ from: "supervisor",
3756
+ type: messageType as MailboxMessageType,
3757
+ content,
3758
+ });
3759
+ return `✅ Message sent to \`${to}\` (batch ${state.batchId})\n` +
3760
+ `- **ID:** ${msg.id}\n` +
3761
+ `- **Type:** ${messageType}\n` +
3762
+ `- **Size:** ${Buffer.byteLength(content, "utf8")} bytes\n` +
3763
+ `Message will be delivered at the agent's next turn boundary.`;
3764
+ } catch (err) {
3765
+ return `❌ Failed to write message: ${err instanceof Error ? err.message : String(err)}`;
3766
+ }
3767
+ }
3768
+
3769
+ function resolveToolStateRoot(context: ExtensionContext): string {
3770
+ return execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? context.cwd;
3771
+ }
3772
+
3773
+ function resolveLaneRepoRootForTools(laneRec: PersistedBatchState["lanes"][number], stateRoot: string): string {
3774
+ if (execCtx?.workspaceConfig && laneRec.repoId) {
3775
+ const repo = execCtx.workspaceConfig.repos[laneRec.repoId];
3776
+ if (repo?.path) return repo.path;
3777
+ }
3778
+ return execCtx?.repoRoot ?? stateRoot;
3779
+ }
3780
+
3781
+ // ── TP-096: Supervisor Recovery Tools ─────────────────────────────────
3782
+
3783
+ pi.registerTool({
3784
+ name: "read_agent_status",
3785
+ label: "Read Agent Status",
3786
+ description:
3787
+ "Read STATUS.md and telemetry for a running agent's lane. " +
3788
+ "Returns current step, checkbox progress, context %, cost, tool count, and elapsed time. " +
3789
+ "If lane is omitted, returns status for all active lanes.",
3790
+ promptSnippet: "read_agent_status(lane?) — read STATUS.md + context % + cost from a running agent",
3791
+ promptGuidelines: [
3792
+ "Call read_agent_status to check on a specific lane's worker progress.",
3793
+ "Omit lane to get a summary of all active lanes.",
3794
+ "Returns: current step, checked/total items, context %, cost, elapsed.",
3795
+ ],
3796
+ parameters: Type.Object({
3797
+ lane: Type.Optional(Type.Number({
3798
+ description: "Lane number to check (omit for all lanes)",
3799
+ })),
3800
+ }),
3801
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3802
+ try {
3803
+ const result = doReadAgentStatus(params.lane, ctx);
3804
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
3805
+ } catch (err) {
3806
+ return {
3807
+ content: [{ type: "text" as const, text: `Error reading agent status: ${err instanceof Error ? err.message : String(err)}` }],
3808
+ details: undefined,
3809
+ };
3810
+ }
3811
+ },
3812
+ });
3813
+
3814
+ /**
3815
+ * Read agent status from STATUS.md and lane-state sidecar.
3816
+ * @since TP-096
3817
+ */
3818
+ function doReadAgentStatus(lane: number | undefined, ctx: ExtensionContext): string {
3819
+ const stateRoot = resolveToolStateRoot(ctx);
3820
+
3821
+ // Load batch state
3822
+ const state = loadBatchState(stateRoot);
3823
+ if (!state) return "❌ No batch state found.";
3824
+
3825
+ const targetLanes = lane != null
3826
+ ? state.lanes.filter(l => l.laneNumber === lane)
3827
+ : state.lanes;
3828
+
3829
+ if (targetLanes.length === 0) {
3830
+ return lane != null
3831
+ ? `❌ Lane ${lane} not found in batch ${state.batchId}.`
3832
+ : "❌ No lanes in current batch.";
3833
+ }
3834
+
3835
+ const lines: string[] = [];
3836
+ lines.push(`📊 **Agent Status** — batch ${state.batchId}\n`);
3837
+
3838
+ for (const laneRec of targetLanes) {
3839
+ // Find current task for this lane
3840
+ const laneTasks = state.tasks.filter(t => t.laneNumber === laneRec.laneNumber);
3841
+ const runningTask = laneTasks.find(t => t.status === "running");
3842
+ const currentTask = runningTask || laneTasks[laneTasks.length - 1];
3843
+
3844
+ lines.push(`### Lane ${laneRec.laneNumber} — ${laneRec.tmuxSessionName}`);
3845
+ lines.push(`**Branch:** ${laneRec.branch}`);
3846
+
3847
+ if (currentTask) {
3848
+ lines.push(`**Task:** ${currentTask.taskId} (${currentTask.status})`);
3849
+
3850
+ // Read STATUS.md from canonical task paths (workspace-safe, cross-repo-safe)
3851
+ try {
3852
+ const taskFolderAbs = currentTask.taskFolder;
3853
+ const worktreePath = laneRec.worktreePath;
3854
+ if (taskFolderAbs && worktreePath) {
3855
+ const repoRootForLane = resolveLaneRepoRootForTools(laneRec, stateRoot);
3856
+ const resolved = resolveCanonicalTaskPaths(
3857
+ taskFolderAbs,
3858
+ worktreePath,
3859
+ repoRootForLane,
3860
+ !!execCtx?.workspaceConfig,
3861
+ );
3862
+ if (existsSync(resolved.statusPath)) {
3863
+ const content = readFileSync(resolved.statusPath, "utf-8");
3864
+ const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
3865
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
3866
+ const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
3867
+ const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
3868
+ const checked = (content.match(/- \[x\]/gi) || []).length;
3869
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
3870
+ const total = checked + unchecked;
3871
+
3872
+ if (stepMatch) lines.push(`**Step:** ${stepMatch[1].trim()}`);
3873
+ if (statusMatch) lines.push(`**Step Status:** ${statusMatch[1].trim()}`);
3874
+ if (total > 0) lines.push(`**Progress:** ${checked}/${total} (${Math.round((checked / total) * 100)}%)`);
3875
+ if (iterMatch) lines.push(`**Iteration:** ${iterMatch[1]}`);
3876
+ if (reviewMatch && Number.parseInt(reviewMatch[1], 10) > 0) lines.push(`**Reviews:** ${reviewMatch[1]}`);
3877
+ }
3878
+ }
3879
+ } catch {
3880
+ // STATUS.md not available in worktree — degrade gracefully
3881
+ }
3882
+ } else {
3883
+ lines.push("**Task:** none assigned");
3884
+ }
3885
+
3886
+ // Read lane-state sidecar
3887
+ try {
3888
+ const lsPath = join(stateRoot, ".pi", `lane-state-${laneRec.tmuxSessionName}.json`);
3889
+ if (existsSync(lsPath)) {
3890
+ const ls = JSON.parse(readFileSync(lsPath, "utf-8"));
3891
+ const parts: string[] = [];
3892
+ if (ls.workerContextPct) parts.push(`context: ${Math.round(ls.workerContextPct)}%`);
3893
+ if (ls.workerCostUsd) parts.push(`cost: $${ls.workerCostUsd.toFixed(3)}`);
3894
+ if (ls.workerToolCount) parts.push(`tools: ${ls.workerToolCount}`);
3895
+ if (ls.workerElapsed) parts.push(`elapsed: ${Math.round(ls.workerElapsed / 1000)}s`);
3896
+ if (ls.workerStatus) parts.push(`worker: ${ls.workerStatus}`);
3897
+ if (ls.reviewerStatus && ls.reviewerStatus !== "idle") parts.push(`reviewer: ${ls.reviewerStatus}`);
3898
+ if (parts.length > 0) lines.push(`**Telemetry:** ${parts.join(" · ")}`);
3899
+ }
3900
+ } catch {
3901
+ // Lane state not available — degrade gracefully
3902
+ }
3903
+
3904
+ lines.push("");
3905
+ }
3906
+
3907
+ return lines.join("\n");
3908
+ }
3909
+
3910
+ pi.registerTool({
3911
+ name: "trigger_wrap_up",
3912
+ label: "Trigger Wrap Up",
3913
+ description:
3914
+ "Write the .task-wrap-up signal file for a specific lane, telling the worker to finish its current step and exit gracefully.",
3915
+ promptSnippet: "trigger_wrap_up(lane) — write .task-wrap-up signal file for a lane",
3916
+ promptGuidelines: [
3917
+ "Call trigger_wrap_up to gracefully stop a worker on a specific lane.",
3918
+ "The worker will finish its current step and exit.",
3919
+ "Validates the lane exists and has a running worker.",
3920
+ ],
3921
+ parameters: Type.Object({
3922
+ lane: Type.Number({
3923
+ description: "Lane number to send wrap-up signal to",
3924
+ }),
3925
+ }),
3926
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3927
+ try {
3928
+ const result = doTriggerWrapUp(params.lane, ctx);
3929
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
3930
+ } catch (err) {
3931
+ return {
3932
+ content: [{ type: "text" as const, text: `Error triggering wrap-up: ${err instanceof Error ? err.message : String(err)}` }],
3933
+ details: undefined,
3934
+ };
3935
+ }
3936
+ },
3937
+ });
3938
+
3939
+ /**
3940
+ * Write .task-wrap-up signal file for a lane's current task.
3941
+ * @since TP-096
3942
+ */
3943
+ function doTriggerWrapUp(lane: number, ctx: ExtensionContext): string {
3944
+ const stateRoot = resolveToolStateRoot(ctx);
3945
+
3946
+ const state = loadBatchState(stateRoot);
3947
+ if (!state) return "❌ No batch state found.";
3948
+
3949
+ const laneRec = state.lanes.find(l => l.laneNumber === lane);
3950
+ if (!laneRec) return `❌ Lane ${lane} not found in batch ${state.batchId}.`;
3951
+
3952
+ // Find running task for this lane
3953
+ const runningTask = state.tasks.find(t => t.laneNumber === lane && t.status === "running");
3954
+ if (!runningTask) return `❌ No running task on lane ${lane}.`;
3955
+
3956
+ // Resolve task folder in the worktree using canonical path resolver
3957
+ const taskFolderAbs = runningTask.taskFolder;
3958
+ const worktreePath = laneRec.worktreePath;
3959
+ if (!taskFolderAbs || !worktreePath) {
3960
+ return `❌ Cannot resolve task folder for lane ${lane}.`;
3961
+ }
3962
+
3963
+ const repoRootForLane = resolveLaneRepoRootForTools(laneRec, stateRoot);
3964
+ const resolved = resolveCanonicalTaskPaths(
3965
+ taskFolderAbs,
3966
+ worktreePath,
3967
+ repoRootForLane,
3968
+ !!execCtx?.workspaceConfig,
3969
+ );
3970
+ const wrapUpPath = join(resolved.taskFolderResolved, ".task-wrap-up");
3971
+
3972
+ try {
3973
+ const dir = dirname(wrapUpPath);
3974
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3975
+ writeFileSync(wrapUpPath, `wrap-up signal for ${runningTask.taskId}\n`, "utf-8");
3976
+ return `✅ Wrap-up signal written for **${runningTask.taskId}** on lane ${lane}.\n` +
3977
+ `Path: \`${wrapUpPath}\`\n` +
3978
+ `The worker will finish its current step and exit gracefully.`;
3979
+ } catch (err) {
3980
+ return `❌ Failed to write wrap-up file: ${err instanceof Error ? err.message : String(err)}`;
3981
+ }
3982
+ }
3983
+
3984
+ pi.registerTool({
3985
+ name: "read_lane_logs",
3986
+ label: "Read Lane Logs",
3987
+ description:
3988
+ "Read stderr/crash logs for a specific lane from .pi/telemetry/ directory.",
3989
+ promptSnippet: "read_lane_logs(lane) — read stderr/crash logs for a lane",
3990
+ promptGuidelines: [
3991
+ "Call read_lane_logs to read crash/error logs from a lane's stderr capture.",
3992
+ "Falls back gracefully when logs don't exist (older batches).",
3993
+ ],
3994
+ parameters: Type.Object({
3995
+ lane: Type.Number({
3996
+ description: "Lane number to read logs for",
3997
+ }),
3998
+ }),
3999
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
4000
+ try {
4001
+ const result = doReadLaneLogs(params.lane, ctx);
4002
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
4003
+ } catch (err) {
4004
+ return {
4005
+ content: [{ type: "text" as const, text: `Error reading lane logs: ${err instanceof Error ? err.message : String(err)}` }],
4006
+ details: undefined,
4007
+ };
4008
+ }
4009
+ },
4010
+ });
4011
+
4012
+ /**
4013
+ * Read stderr/crash logs for a lane.
4014
+ * @since TP-096
4015
+ */
4016
+ function doReadLaneLogs(lane: number, ctx: ExtensionContext): string {
4017
+ const stateRoot = resolveToolStateRoot(ctx);
4018
+
4019
+ const state = loadBatchState(stateRoot);
4020
+ if (!state) return "❌ No batch state found.";
4021
+
4022
+ const laneRec = state.lanes.find(l => l.laneNumber === lane);
4023
+ if (!laneRec) return `❌ Lane ${lane} not found in batch ${state.batchId}.`;
4024
+
4025
+ const telemetryDir = join(stateRoot, ".pi", "telemetry");
4026
+ let stderrFile: string | null = null;
4027
+
4028
+ // Discover stderr logs by actual telemetry naming:
4029
+ // {opId}-{batchId}-{repoId}[-{taskId}]-lane-{N}-worker-stderr.log
4030
+ try {
4031
+ if (existsSync(telemetryDir)) {
4032
+ const allStderr = readdirSync(telemetryDir)
4033
+ .filter(f => f.endsWith("-stderr.log"))
4034
+ .filter(f => f.includes(`-lane-${lane}-worker`));
4035
+ const batchScoped = allStderr.filter(f => f.includes(`-${state.batchId}-`));
4036
+ const candidates = (batchScoped.length > 0 ? batchScoped : allStderr)
4037
+ .map(name => {
4038
+ const absPath = join(telemetryDir, name);
4039
+ let mtime = 0;
4040
+ try { mtime = statSync(absPath).mtimeMs; } catch {}
4041
+ return { name, mtime };
4042
+ })
4043
+ .sort((a, b) => b.mtime - a.mtime);
4044
+ stderrFile = candidates[0]?.name ?? null;
4045
+ }
4046
+ } catch {
4047
+ // Directory not readable — handled below
4048
+ }
4049
+
4050
+ // Legacy fallback from older conventions
4051
+ if (!stderrFile) {
4052
+ const legacy = `${state.batchId}-lane-${lane}-stderr.log`;
4053
+ if (existsSync(join(telemetryDir, legacy))) {
4054
+ stderrFile = legacy;
4055
+ }
4056
+ }
4057
+
4058
+ const stderrPath = stderrFile ? join(telemetryDir, stderrFile) : null;
4059
+
4060
+ // Also try to find worker-exit JSON files for crash diagnostics
4061
+ const exitFiles: string[] = [];
4062
+ try {
4063
+ if (existsSync(telemetryDir)) {
4064
+ const files = readdirSync(telemetryDir)
4065
+ .filter(f => f.endsWith("-worker-exit.json"))
4066
+ .filter(f => f.includes(`-lane-${lane}-`));
4067
+ const batchScoped = files.filter(f => f.includes(`-${state.batchId}-`));
4068
+ exitFiles.push(...(batchScoped.length > 0 ? batchScoped : files));
4069
+ }
4070
+ } catch { /* directory not readable */ }
4071
+
4072
+ const lines: string[] = [];
4073
+ lines.push(`📜 **Lane ${lane} Logs** — batch ${state.batchId}\n`);
4074
+
4075
+ // Read stderr log
4076
+ if (stderrPath && existsSync(stderrPath)) {
4077
+ try {
4078
+ const content = readFileSync(stderrPath, "utf-8");
4079
+ const truncated = content.length > 5000
4080
+ ? "...\n" + content.slice(-5000)
4081
+ : content;
4082
+ lines.push("### Stderr Log");
4083
+ lines.push("```");
4084
+ lines.push(truncated.trim());
4085
+ lines.push("```");
4086
+ lines.push("");
4087
+ } catch {
4088
+ lines.push("Stderr log found but unreadable.");
4089
+ }
4090
+ } else {
4091
+ lines.push(`No stderr log found for lane ${lane} (pattern: \`*-lane-${lane}-worker-stderr.log\`).`);
4092
+ }
4093
+
4094
+ // Read most recent exit diagnostic
4095
+ if (exitFiles.length > 0) {
4096
+ const latestExit = exitFiles
4097
+ .map(name => {
4098
+ const absPath = join(telemetryDir, name);
4099
+ let mtime = 0;
4100
+ try { mtime = statSync(absPath).mtimeMs; } catch {}
4101
+ return { name, mtime };
4102
+ })
4103
+ .sort((a, b) => b.mtime - a.mtime)[0]?.name;
4104
+ if (latestExit) {
4105
+ try {
4106
+ const exitData = JSON.parse(readFileSync(join(telemetryDir, latestExit), "utf-8"));
4107
+ lines.push("### Latest Exit Diagnostic");
4108
+ if (exitData.classification) lines.push(`**Classification:** ${exitData.classification}`);
4109
+ if (exitData.exitCode != null) lines.push(`**Exit Code:** ${exitData.exitCode}`);
4110
+ if (exitData.errorMessage) lines.push(`**Error:** ${exitData.errorMessage}`);
4111
+ if (exitData.durationSec) lines.push(`**Duration:** ${exitData.durationSec}s`);
4112
+ lines.push("");
4113
+ } catch { /* skip malformed exit file */ }
4114
+ }
4115
+ }
4116
+
4117
+ return lines.join("\n");
4118
+ }
4119
+
4120
+ pi.registerTool({
4121
+ name: "list_active_agents",
4122
+ label: "List Active Agents",
4123
+ description:
4124
+ "List all tmux sessions with their role, lane, task, context %, and elapsed time.",
4125
+ promptSnippet: "list_active_agents() — show all tmux sessions with role, lane, task, context %, elapsed",
4126
+ promptGuidelines: [
4127
+ "Call list_active_agents to see all running agent sessions.",
4128
+ "Shows: session name, role (worker/reviewer/merger/supervisor), lane, task, context %, elapsed.",
4129
+ ],
4130
+ parameters: Type.Object({}),
4131
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
4132
+ try {
4133
+ const result = doListActiveAgents(ctx);
4134
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
4135
+ } catch (err) {
4136
+ return {
4137
+ content: [{ type: "text" as const, text: `Error listing agents: ${err instanceof Error ? err.message : String(err)}` }],
4138
+ details: undefined,
4139
+ };
4140
+ }
4141
+ },
4142
+ });
4143
+
4144
+ /**
4145
+ * List all active tmux sessions with agent metadata.
4146
+ * @since TP-096
4147
+ */
4148
+ function doListActiveAgents(ctx: ExtensionContext): string {
4149
+ const stateRoot = resolveToolStateRoot(ctx);
4150
+
4151
+ // Get tmux sessions
4152
+ let sessions: string[] = [];
4153
+ try {
4154
+ const output = execSync('tmux list-sessions -F "#{session_name}"', {
4155
+ encoding: "utf-8",
4156
+ timeout: 5000,
4157
+ stdio: ["ignore", "pipe", "ignore"],
4158
+ }).trim();
4159
+ sessions = output ? output.split("\n").map(s => s.trim()).filter(Boolean) : [];
4160
+ } catch {
4161
+ return "❌ tmux not available or no sessions running.";
4162
+ }
4163
+
4164
+ if (sessions.length === 0) return "❌ No tmux sessions found.";
4165
+
4166
+ // Load batch state for task/lane mapping
4167
+ const state = loadBatchState(stateRoot);
4168
+
4169
+ // Build a map of session name → lane-state data
4170
+ const laneStates: Record<string, any> = {};
4171
+ try {
4172
+ const piDir = join(stateRoot, ".pi");
4173
+ if (existsSync(piDir)) {
4174
+ const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
4175
+ for (const file of files) {
4176
+ try {
4177
+ const data = JSON.parse(readFileSync(join(piDir, file), "utf-8"));
4178
+ if (data.prefix) laneStates[data.prefix] = data;
4179
+ } catch { continue; }
4180
+ }
4181
+ }
4182
+ } catch { /* .pi dir missing */ }
4183
+
4184
+ const lines: string[] = [];
4185
+ lines.push(`👥 **Active Agents** (${sessions.length} sessions)\n`);
4186
+
4187
+ // Parse each session name to extract role, lane, etc.
4188
+ for (const sess of sessions) {
4189
+ let role = "unknown";
4190
+ let laneNum = "";
4191
+ let taskId = "";
4192
+ let contextPct = "";
4193
+ let elapsed = "";
4194
+ let costStr = "";
4195
+
4196
+ // Parse session name pattern:
4197
+ // Workers/reviewers: orch-{opId}-lane-{N} (or -worker/-reviewer suffix)
4198
+ // Mergers: orch-{opId}-merge-{N}
4199
+ // Supervisor: pi-supervisor-{...}
4200
+ const laneMatch = sess.match(/-lane-(\d+)/);
4201
+ const mergeMatch = sess.match(/-merge-(\d+)/);
4202
+
4203
+ if (mergeMatch) {
4204
+ role = "merger";
4205
+ laneNum = mergeMatch[1];
4206
+ } else if (laneMatch) {
4207
+ if (sess.includes("-reviewer")) {
4208
+ role = "reviewer";
4209
+ } else {
4210
+ role = "worker";
4211
+ }
4212
+ laneNum = laneMatch[1];
4213
+ } else if (sess.includes("supervisor")) {
4214
+ role = "supervisor";
4215
+ }
4216
+
4217
+ // Find matching task and lane-state
4218
+ if (state && laneNum) {
4219
+ const ln = parseInt(laneNum);
4220
+ const task = state.tasks.find(t => t.laneNumber === ln && t.status === "running");
4221
+ if (task) taskId = task.taskId;
4222
+
4223
+ // Find lane-state prefix (may be the session name or a prefix of it)
4224
+ const laneRec = state.lanes.find(l => l.laneNumber === ln);
4225
+ const prefix = laneRec?.tmuxSessionName || sess;
4226
+ const ls = laneStates[prefix];
4227
+ if (ls) {
4228
+ if (ls.workerContextPct) contextPct = `${Math.round(ls.workerContextPct)}%`;
4229
+ if (ls.workerElapsed) elapsed = `${Math.round(ls.workerElapsed / 1000)}s`;
4230
+ if (ls.workerCostUsd) costStr = `$${ls.workerCostUsd.toFixed(3)}`;
4231
+ }
4232
+ }
4233
+
4234
+ const parts: string[] = [`**${sess}**`];
4235
+ parts.push(`role: ${role}`);
4236
+ if (laneNum) parts.push(`lane: ${laneNum}`);
4237
+ if (taskId) parts.push(`task: ${taskId}`);
4238
+ if (contextPct) parts.push(`ctx: ${contextPct}`);
4239
+ if (elapsed) parts.push(`elapsed: ${elapsed}`);
4240
+ if (costStr) parts.push(`cost: ${costStr}`);
4241
+ lines.push(`- ${parts.join(" · ")}`);
4242
+ }
4243
+
4244
+ return lines.join("\n");
4245
+ }
4246
+
3642
4247
  // ── Settings TUI ─────────────────────────────────────────────────
3643
4248
 
3644
4249
  pi.registerCommand("taskplane-settings", {