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.
- package/bin/rpc-wrapper.mjs +183 -2
- package/dashboard/public/app.js +108 -55
- package/dashboard/public/style.css +18 -0
- package/dashboard/server.cjs +29 -3
- package/extensions/task-runner.ts +179 -21
- package/extensions/taskplane/cleanup.ts +76 -7
- package/extensions/taskplane/diagnostics.ts +1 -1
- package/extensions/taskplane/engine-worker.ts +1 -1
- package/extensions/taskplane/engine.ts +14 -13
- package/extensions/taskplane/execution.ts +20 -3
- package/extensions/taskplane/extension.ts +616 -11
- package/extensions/taskplane/mailbox.ts +341 -0
- package/extensions/taskplane/merge.ts +11 -2
- package/extensions/taskplane/supervisor-primer.md +9 -3
- package/extensions/taskplane/supervisor.ts +2 -2
- package/extensions/taskplane/types.ts +95 -1
- package/extensions/taskplane/verification.ts +14 -9
- package/package.json +1 -1
- package/templates/agents/supervisor.md +1 -1
- package/templates/agents/task-worker.md +8 -14
|
@@ -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(
|
|
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 ${
|
|
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", {
|