taskplane 0.22.17 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,21 @@ 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";
35
+ import {
36
+ writeMailboxMessage,
37
+ readOutbox,
38
+ readOutboxHistory,
39
+ discoverMailboxAgentIds,
40
+ writeBroadcastMessage,
41
+ checkRateLimit,
42
+ recordSend,
43
+ appendMailboxAuditEvent,
44
+ } from "./mailbox.ts";
45
+ import {
46
+ readRegistrySnapshot,
47
+ isProcessAlive as registryIsProcessAlive,
48
+ isTerminalStatus,
49
+ } from "./process-registry.ts";
36
50
  import type { MailboxMessageType } from "./types.ts";
37
51
  import {
38
52
  activateSupervisor,
@@ -3515,19 +3529,22 @@ export default function (pi: ExtensionAPI) {
3515
3529
  label: "Start Batch",
3516
3530
  description:
3517
3531
  "Start a new orchestration batch. Target is \"all\" to run all pending tasks, " +
3518
- "or a specific task area name or path. The batch runs asynchronously " +
3519
- "use orch_status() to monitor progress.",
3532
+ "a task area name, a directory path, or one or more PROMPT.md paths. " +
3533
+ "The batch runs asynchronously — use orch_status() to monitor progress.",
3520
3534
  promptSnippet: "orch_start(target) — start a new batch",
3521
3535
  promptGuidelines: [
3522
3536
  "Call orch_start to begin executing pending tasks as a batch.",
3523
- 'Use target="all" to run all pending tasks, or specify a task area name or path.',
3537
+ 'Use target="all" to run all pending tasks.',
3538
+ "Specify a task area name to run all pending tasks in that area.",
3539
+ "Specify a PROMPT.md path to run a single task: target=\"taskplane-tasks/TP-101/PROMPT.md\"",
3540
+ "Specify multiple space-separated PROMPT.md paths to run specific tasks: target=\"path/TP-001/PROMPT.md path/TP-002/PROMPT.md\"",
3524
3541
  "Cannot start if a batch is already running — check orch_status() first.",
3525
3542
  "The batch runs asynchronously. The tool returns immediately with an ACK.",
3526
3543
  "After starting, use orch_status() to track progress.",
3527
3544
  ],
3528
3545
  parameters: Type.Object({
3529
3546
  target: Type.String({
3530
- description: 'Target to run: "all" for all pending tasks, or a task area name/path',
3547
+ description: 'Target to run: "all" for all pending tasks, a task area name, a directory path, or one or more PROMPT.md paths (space-separated)',
3531
3548
  }),
3532
3549
  }),
3533
3550
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -3689,6 +3706,34 @@ export default function (pi: ExtensionAPI) {
3689
3706
  },
3690
3707
  });
3691
3708
 
3709
+ function collectKnownAgentIds(stateRoot: string, state: PersistedBatchState): string[] {
3710
+ const ids = new Set<string>();
3711
+
3712
+ // Runtime V2 source of truth first.
3713
+ const registry = readRegistrySnapshot(stateRoot, state.batchId);
3714
+ if (registry) {
3715
+ for (const manifest of Object.values(registry.agents)) {
3716
+ if (manifest.role !== "worker" && manifest.role !== "reviewer" && manifest.role !== "merger") continue;
3717
+ if (isTerminalStatus(manifest.status) || !registryIsProcessAlive(manifest.pid)) continue;
3718
+ ids.add(manifest.agentId);
3719
+ }
3720
+ }
3721
+
3722
+ // Legacy fallback from lane naming when registry is absent/empty.
3723
+ if (ids.size === 0) {
3724
+ const orchConfig = execCtx?.orchestratorConfig;
3725
+ const tmuxPrefix = orchConfig?.orchestrator?.tmux_prefix ?? "orch";
3726
+ const opId = orchConfig ? resolveOperatorId(orchConfig) : "op";
3727
+ for (const lane of state.lanes) {
3728
+ ids.add(`${lane.tmuxSessionName}-worker`);
3729
+ ids.add(`${lane.tmuxSessionName}-reviewer`);
3730
+ ids.add(`${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`);
3731
+ }
3732
+ }
3733
+
3734
+ return [...ids];
3735
+ }
3736
+
3692
3737
  /**
3693
3738
  * Send a steering message to a running agent via the mailbox system.
3694
3739
  *
@@ -3722,19 +3767,8 @@ export default function (pi: ExtensionAPI) {
3722
3767
  return `❌ Batch ${state.batchId} is in terminal phase (${state.phase}). Start or resume a batch before sending messages.`;
3723
3768
  }
3724
3769
 
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
- }
3770
+ // Build valid runtime agent IDs (registry-first, legacy fallback).
3771
+ const validSessions = new Set<string>(collectKnownAgentIds(stateRoot, state));
3738
3772
 
3739
3773
  // Validate target session
3740
3774
  if (!validSessions.has(to)) {
@@ -3742,11 +3776,38 @@ export default function (pi: ExtensionAPI) {
3742
3776
  return `❌ Unknown session "${to}" in batch ${state.batchId}.\nValid targets: ${examples}${validSessions.size > 5 ? ` (${validSessions.size} total)` : ""}`;
3743
3777
  }
3744
3778
 
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.`;
3779
+ // Guard: ensure the target agent is currently alive.
3780
+ // Check process registry first (Runtime V2), fall back to TMUX (legacy).
3781
+ let agentAlive = false;
3782
+ try {
3783
+ const registry = readRegistrySnapshot(stateRoot, state.batchId);
3784
+ if (registry && registry.agents[to]) {
3785
+ const manifest = registry.agents[to];
3786
+ agentAlive = !isTerminalStatus(manifest.status) && registryIsProcessAlive(manifest.pid);
3787
+ } else {
3788
+ // No registry entry — fall back to TMUX for legacy batches
3789
+ agentAlive = tmuxHasSession(to);
3790
+ }
3791
+ } catch {
3792
+ // Registry read failed — fall back to TMUX
3793
+ agentAlive = tmuxHasSession(to);
3794
+ }
3795
+ if (!agentAlive) {
3796
+ return `❌ Agent "${to}" is not currently running. Use orch_status() or orch_resume() before sending messages.`;
3797
+ }
3798
+
3799
+ // Rate limiting (TP-106)
3800
+ const rateCheck = checkRateLimit(to);
3801
+ if (!rateCheck.allowed) {
3802
+ const waitSec = Math.ceil((rateCheck.retryAfterMs ?? 0) / 1000);
3803
+ appendMailboxAuditEvent(stateRoot, state.batchId, {
3804
+ type: "message_rate_limited",
3805
+ from: "supervisor",
3806
+ to,
3807
+ reason: "per-agent rate limit",
3808
+ retryAfterMs: rateCheck.retryAfterMs,
3809
+ });
3810
+ return `⏳ Rate limited: wait ${waitSec}s before sending another message to \`${to}\`.`;
3750
3811
  }
3751
3812
 
3752
3813
  // Write message to inbox
@@ -3756,6 +3817,16 @@ export default function (pi: ExtensionAPI) {
3756
3817
  type: messageType as MailboxMessageType,
3757
3818
  content,
3758
3819
  });
3820
+ recordSend(to);
3821
+ appendMailboxAuditEvent(stateRoot, state.batchId, {
3822
+ type: "message_sent",
3823
+ from: "supervisor",
3824
+ to,
3825
+ messageId: msg.id,
3826
+ messageType,
3827
+ contentPreview: content.slice(0, 200),
3828
+ broadcast: false,
3829
+ });
3759
3830
  return `✅ Message sent to \`${to}\` (batch ${state.batchId})\n` +
3760
3831
  `- **ID:** ${msg.id}\n` +
3761
3832
  `- **Type:** ${messageType}\n` +
@@ -3766,6 +3837,192 @@ export default function (pi: ExtensionAPI) {
3766
3837
  }
3767
3838
  }
3768
3839
 
3840
+ // ── TP-106: read_agent_replies tool ───────────────────────
3841
+
3842
+ pi.registerTool({
3843
+ name: "read_agent_replies",
3844
+ label: "Read Agent Replies",
3845
+ description:
3846
+ "Read reply and escalation messages from agents (non-consuming). " +
3847
+ "Returns pending and already-acked outbox messages from a specific agent or all agents. " +
3848
+ "Messages are never removed by reading — this is a durable history view.",
3849
+ promptSnippet: "read_agent_replies(from?) \u2014 read replies/escalations from agents (read-only, non-consuming)",
3850
+ promptGuidelines: [
3851
+ "Call read_agent_replies to check if any agent has sent a reply or escalation.",
3852
+ "Omit 'from' to read replies from all agents.",
3853
+ "Provide 'from' with an agent ID to read replies from a specific agent.",
3854
+ "This is non-consuming: replies remain visible after reading (pending + acked history).",
3855
+ ],
3856
+ parameters: Type.Object({
3857
+ from: Type.Optional(Type.String({
3858
+ description: "Agent ID to read replies from (omit for all agents)",
3859
+ })),
3860
+ }),
3861
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3862
+ try {
3863
+ const result = doReadAgentReplies(params.from, ctx);
3864
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
3865
+ } catch (err) {
3866
+ return {
3867
+ content: [{ type: "text" as const, text: `Error reading replies: ${err instanceof Error ? err.message : String(err)}` }],
3868
+ details: undefined,
3869
+ };
3870
+ }
3871
+ },
3872
+ });
3873
+
3874
+ /**
3875
+ * Read agent replies/escalations. Non-consuming (read-only):
3876
+ * reads both pending outbox and processed (acked) messages so
3877
+ * replies are never lost from the supervisor's view.
3878
+ *
3879
+ * @since TP-091 (lifecycle semantics hardened)
3880
+ */
3881
+ function doReadAgentReplies(from: string | undefined, ctx: ExtensionContext): string {
3882
+ const stateRoot = resolveToolStateRoot(ctx);
3883
+ const state = loadBatchState(stateRoot);
3884
+ if (!state) return "❌ No batch state found.";
3885
+
3886
+ // TP-091: when from is omitted, union live agents + mailbox history roots
3887
+ // so replies from agents no longer active are still visible.
3888
+ const agentIds = from
3889
+ ? [from]
3890
+ : [...new Set([
3891
+ ...collectKnownAgentIds(stateRoot, state),
3892
+ ...discoverMailboxAgentIds(stateRoot, state.batchId),
3893
+ ])];
3894
+
3895
+ // TP-091: read full outbox history (pending + processed) for durable visibility
3896
+ const allEntries: Array<{ agentId: string; message: import("./types.ts").MailboxMessage; acked: boolean }> = [];
3897
+ for (const agentId of agentIds) {
3898
+ const history = readOutboxHistory(stateRoot, state.batchId, agentId);
3899
+ for (const entry of history) {
3900
+ allEntries.push({ agentId, ...entry });
3901
+ }
3902
+ }
3903
+
3904
+ if (allEntries.length === 0) {
3905
+ return from
3906
+ ? `No replies from \`${from}\` in batch ${state.batchId}.`
3907
+ : `No agent replies in batch ${state.batchId}.`;
3908
+ }
3909
+
3910
+ const lines: string[] = [`📨 **Agent Replies** (${allEntries.length} message(s))\n`];
3911
+ for (const { agentId, message, acked } of allEntries) {
3912
+ const ts = new Date(message.timestamp).toISOString().slice(0, 16).replace("T", " ");
3913
+ const statusTag = acked ? " *(acked)*" : " *(pending)*";
3914
+ lines.push(`### ${message.type.toUpperCase()} from \`${agentId}\`${statusTag}`);
3915
+ lines.push(`- **Time:** ${ts}`);
3916
+ lines.push(`- **ID:** ${message.id}`);
3917
+ if (message.replyTo) lines.push(`- **Reply to:** ${message.replyTo}`);
3918
+ lines.push(`- **Content:** ${message.content.slice(0, 500)}`);
3919
+ lines.push("");
3920
+ }
3921
+
3922
+ return lines.join("\n");
3923
+ }
3924
+
3925
+ // ── TP-106: broadcast_message tool ────────────────────────
3926
+
3927
+ pi.registerTool({
3928
+ name: "broadcast_message",
3929
+ label: "Broadcast Message",
3930
+ description:
3931
+ "Send a message to all active agents. " +
3932
+ "The message is written to the broadcast directory and delivered to all agents at their next turn boundary.",
3933
+ promptSnippet: "broadcast_message(content, type?) \u2014 send message to all agents",
3934
+ promptGuidelines: [
3935
+ "Call broadcast_message to send a message to all active agents at once.",
3936
+ "Default type is 'info'. Other types: 'steer', 'abort'.",
3937
+ "Messages are limited to 4KB.",
3938
+ "Rate limiting is all-or-none: if ANY recipient is rate-limited, the entire broadcast is rejected.",
3939
+ ],
3940
+ parameters: Type.Object({
3941
+ content: Type.String({
3942
+ description: "Message content (max 4KB)",
3943
+ }),
3944
+ type: Type.Optional(Type.Union(
3945
+ [Type.Literal("steer"), Type.Literal("info"), Type.Literal("abort")],
3946
+ { description: 'Message type (default: "info")' },
3947
+ )),
3948
+ }),
3949
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3950
+ try {
3951
+ const result = doBroadcastMessage(params.content, params.type ?? "info", ctx);
3952
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
3953
+ } catch (err) {
3954
+ return {
3955
+ content: [{ type: "text" as const, text: `Error broadcasting: ${err instanceof Error ? err.message : String(err)}` }],
3956
+ details: undefined,
3957
+ };
3958
+ }
3959
+ },
3960
+ });
3961
+
3962
+ function doBroadcastMessage(content: string, messageType: string, ctx: ExtensionContext): string {
3963
+ const stateRoot = resolveToolStateRoot(ctx);
3964
+ const state = loadBatchState(stateRoot);
3965
+ if (!state) return "❌ No batch state found.";
3966
+ if (isBatchTerminal(state.phase)) {
3967
+ return `❌ Batch ${state.batchId} is in terminal phase (${state.phase}).`;
3968
+ }
3969
+
3970
+ const validTypes = new Set(["steer", "info", "abort"]);
3971
+ if (!validTypes.has(messageType)) {
3972
+ return `❌ Invalid broadcast type "${messageType}". Valid types: steer, info, abort.`;
3973
+ }
3974
+
3975
+ const recipients = collectKnownAgentIds(stateRoot, state);
3976
+ if (recipients.length === 0) {
3977
+ return `❌ No known agents found in batch ${state.batchId} for broadcast delivery.`;
3978
+ }
3979
+
3980
+ const blocked = recipients
3981
+ .map((agentId) => ({ agentId, check: checkRateLimit(agentId) }))
3982
+ .filter(({ check }) => !check.allowed);
3983
+ if (blocked.length > 0) {
3984
+ for (const b of blocked) {
3985
+ appendMailboxAuditEvent(stateRoot, state.batchId, {
3986
+ type: "message_rate_limited",
3987
+ from: "supervisor",
3988
+ to: b.agentId,
3989
+ reason: "broadcast blocked by per-agent rate limit",
3990
+ retryAfterMs: b.check.retryAfterMs,
3991
+ });
3992
+ }
3993
+ const preview = blocked.slice(0, 5).map(b => `${b.agentId} (${Math.ceil((b.check.retryAfterMs ?? 0) / 1000)}s)`).join(", ");
3994
+ return `⏳ Broadcast rate limited for ${blocked.length}/${recipients.length} agent(s): ${preview}${blocked.length > 5 ? " ..." : ""}`;
3995
+ }
3996
+
3997
+ try {
3998
+ const msg = writeBroadcastMessage(stateRoot, state.batchId, {
3999
+ from: "supervisor",
4000
+ type: messageType as MailboxMessageType,
4001
+ content,
4002
+ });
4003
+ for (const agentId of recipients) {
4004
+ recordSend(agentId);
4005
+ }
4006
+ appendMailboxAuditEvent(stateRoot, state.batchId, {
4007
+ type: "message_sent",
4008
+ from: "supervisor",
4009
+ to: "_broadcast",
4010
+ messageId: msg.id,
4011
+ messageType,
4012
+ contentPreview: content.slice(0, 200),
4013
+ broadcast: true,
4014
+ });
4015
+ return `✅ Broadcast sent (batch ${state.batchId})\n` +
4016
+ `- **ID:** ${msg.id}\n` +
4017
+ `- **Type:** ${messageType}\n` +
4018
+ `- **Recipients:** ${recipients.length}\n` +
4019
+ `- **Size:** ${Buffer.byteLength(content, "utf8")} bytes\n` +
4020
+ `Message will be delivered to all agents at their next turn boundary.`;
4021
+ } catch (err) {
4022
+ return `❌ Failed to broadcast: ${err instanceof Error ? err.message : String(err)}`;
4023
+ }
4024
+ }
4025
+
3769
4026
  function resolveToolStateRoot(context: ExtensionContext): string {
3770
4027
  return execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? context.cwd;
3771
4028
  }
@@ -4148,7 +4405,16 @@ export default function (pi: ExtensionAPI) {
4148
4405
  function doListActiveAgents(ctx: ExtensionContext): string {
4149
4406
  const stateRoot = resolveToolStateRoot(ctx);
4150
4407
 
4151
- // Get tmux sessions
4408
+ // Try Runtime V2 registry first
4409
+ const state = loadBatchState(stateRoot);
4410
+ if (state) {
4411
+ const registry = readRegistrySnapshot(stateRoot, state.batchId);
4412
+ if (registry && Object.keys(registry.agents).length > 0) {
4413
+ return formatRegistryAgents(registry, state);
4414
+ }
4415
+ }
4416
+
4417
+ // Fall back to TMUX-based discovery for legacy batches
4152
4418
  let sessions: string[] = [];
4153
4419
  try {
4154
4420
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
@@ -4158,13 +4424,12 @@ export default function (pi: ExtensionAPI) {
4158
4424
  }).trim();
4159
4425
  sessions = output ? output.split("\n").map(s => s.trim()).filter(Boolean) : [];
4160
4426
  } catch {
4161
- return "❌ tmux not available or no sessions running.";
4427
+ return "❌ No active agents found (no registry and no tmux sessions).";
4162
4428
  }
4163
4429
 
4164
- if (sessions.length === 0) return "❌ No tmux sessions found.";
4430
+ if (sessions.length === 0) return "❌ No active agents found.";
4165
4431
 
4166
- // Load batch state for task/lane mapping
4167
- const state = loadBatchState(stateRoot);
4432
+ // state already loaded above for registry check (may be null)
4168
4433
 
4169
4434
  // Build a map of session name → lane-state data
4170
4435
  const laneStates: Record<string, any> = {};
@@ -4244,6 +4509,35 @@ export default function (pi: ExtensionAPI) {
4244
4509
  return lines.join("\n");
4245
4510
  }
4246
4511
 
4512
+
4513
+ // ── TP-106: Registry-based agent list formatter ────────────────
4514
+
4515
+ function formatRegistryAgents(registry: import("./types.ts").RuntimeRegistry, _batchState: PersistedBatchState | null): string {
4516
+ const agents = Object.values(registry.agents);
4517
+ if (agents.length === 0) return "❌ No agents in registry.";
4518
+
4519
+ const lines: string[] = [];
4520
+ lines.push(`👥 **Active Agents** (${agents.length} registered)
4521
+ `);
4522
+
4523
+ for (const m of agents) {
4524
+ const alive = !isTerminalStatus(m.status) && registryIsProcessAlive(m.pid);
4525
+ const icon = alive ? "🟢" : "🔴";
4526
+ const parts: string[] = [`**${m.agentId}**`];
4527
+ parts.push(`role: ${m.role}`);
4528
+ if (m.laneNumber != null) parts.push(`lane: ${m.laneNumber}`);
4529
+ if (m.taskId) parts.push(`task: ${m.taskId}`);
4530
+ parts.push(`status: ${m.status}`);
4531
+ if (alive) {
4532
+ const elapsed = Math.round((Date.now() - m.startedAt) / 1000);
4533
+ parts.push(`elapsed: ${elapsed}s`);
4534
+ }
4535
+ lines.push(`- ${icon} ${parts.join(" · ")}`);
4536
+ }
4537
+
4538
+ return lines.join("\n");
4539
+ }
4540
+
4247
4541
  // ── Settings TUI ─────────────────────────────────────────────────
4248
4542
 
4249
4543
  pi.registerCommand("taskplane-settings", {