diff --git a/CHANGELOG.md b/CHANGELOG.md index 034a5f2..392a7d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-14 + +### Changed +- **Eventbus RPC for subagent communication** — replaced the `Symbol.for` global registry bridge with a proper eventbus RPC protocol. [`pi-tasks`](https://github.com/tintinweb/pi-tasks) now communicates with `@tintinweb/pi-subagents` via scoped request/reply channels (`subagents:rpc:spawn`, `subagents:rpc:ping`), eliminating shared mutable global state and enabling reliable cross-extension coordination regardless of load order. +- **Presence detection** — two-path handshake: (1) ping RPC on init with scoped reply channel, (2) `subagents:ready` broadcast listener. Works whether [`pi-subagents`](https://github.com/tintinweb/pi-subagents) loads before or after [`pi-tasks`](https://github.com/tintinweb/pi-tasks). +- **Agent-task mapping** — in-memory `agentTaskMap` (agentId → taskId) replaces linear `store.list().find()` scans for O(1) completion event lookup. +- **Spawn error handling** — `spawnSubagent()` returns a Promise with 30s timeout. Failed spawns revert tasks to `pending` with error in metadata instead of silently failing. +- **Removed `SubagentBridge` type** — the `types.ts` interface for the global registry bridge is no longer needed. +- **Widget icon colors** — completed tasks show green `✔`, in-progress tasks show accent-colored `◼` (matching Claude Code's UI). + ## [0.2.0] - 2026-03-12 ### Added -- **`TaskExecute` tool** — execute tasks as background subagents via pi-chonky-subagents. Tasks with `agentType` metadata are spawned as independent agents; validates status, dependencies, and agent type before launching. +- **`TaskExecute` tool** — execute tasks as background subagents via @tintinweb/pi-subagents. Tasks with `agentType` metadata are spawned as independent agents; validates status, dependencies, and agent type before launching. - **`agentType` parameter on `TaskCreate`** — opt-in field (e.g., `"general-purpose"`, `"Explore"`) that marks tasks for subagent execution. - **Auto-cascade** — when enabled via `/tasks` → Settings, completed agent tasks automatically trigger execution of their unblocked dependents, flowing through the task DAG like a build system. Off by default. - **Subagent completion listener** — listens to `subagents:completed` and `subagents:failed` events to automatically update task status. Failed tasks revert to `pending` with error stored in metadata. @@ -18,8 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`SubagentBridge` type** — typed interface for the cross-extension Symbol.for bridge. ### Changed -- `pi-chonky-subagents` global registry now exposes `spawn()` and `getRecord()` in addition to `waitForAll()` and `hasRunning()`. -- `pi-chonky-subagents` emits lifecycle events on `pi.events`: `subagents:created`, `subagents:started`, `subagents:completed`, `subagents:failed`, `subagents:steered`. +- `@tintinweb/pi-subagents` global registry now exposes `spawn()` and `getRecord()` in addition to `waitForAll()` and `hasRunning()`. +- `@tintinweb/pi-subagents` emits lifecycle events on `pi.events`: `subagents:created`, `subagents:started`, `subagents:completed`, `subagents:failed`, `subagents:steered`. - `AgentManager` accepts an optional `onStart` callback, fired when an agent transitions to running (including from queue). ## [0.1.0] - 2026-03-12 @@ -41,5 +51,6 @@ Initial release — Claude Code-style task tracking and coordination for pi. - **Background process tracker** — output buffering (stdout + stderr), waiter notification, graceful stop with timeout escalation (SIGTERM → 5s → SIGKILL). - **78 unit tests** — task store CRUD, dependencies, warnings, file persistence; widget rendering, icons, spinners, token/duration formatting; process tracker lifecycle. +[0.3.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.0 [0.2.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.2.0 [0.1.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 806d91e..ef4d7cd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ https://github.com/user-attachments/assets/86b09bd1-6882-4b0c-be20-ea866dd44b6a - **Shared task lists** — multiple pi sessions can share a file-backed task list for agent team coordination - **File locking** — concurrent access is safe when multiple sessions share a task list - **Background process tracking** — track spawned processes with output buffering, blocking wait, and graceful stop -- **Subagent integration** — tasks with `agentType` can be executed as subagents via `TaskExecute` (requires [pi-chonky-subagents](https://github.com/tintinweb/pi-subagents)). Auto-cascade mode flows through the task DAG automatically when enabled. +- **Subagent integration** — tasks with `agentType` can be executed as subagents via `TaskExecute` (requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)). Auto-cascade mode flows through the task DAG automatically when enabled. ## Install @@ -147,7 +147,7 @@ Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIG ### `TaskExecute` -Execute one or more tasks as background subagents. Requires [pi-chonky-subagents](https://github.com/tintinweb/pi-subagents). +Execute one or more tasks as background subagents. Requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents). | Parameter | Type | Description | |-----------|------|-------------| @@ -206,6 +206,56 @@ Tasks - **Settings** — toggle auto-cascade (auto-execute unblocked agent tasks on completion) - **Clear completed** — remove all completed tasks +## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) + +[`pi-tasks`](https://github.com/tintinweb/pi-tasks) communicates with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) via pi's eventbus using a scoped request/reply RPC protocol. No shared global state — just events. + +### Presence Detection + +Load order doesn't matter. Two handshake paths ensure detection regardless of which extension loads first: + +1. **Ping on init** — [`pi-tasks`](https://github.com/tintinweb/pi-tasks) emits `subagents:rpc:ping` with a unique `requestId` and listens for `subagents:rpc:ping:reply:{requestId}`. If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is already loaded, it replies immediately. +2. **Ready broadcast** — [`pi-subagents`](https://github.com/tintinweb/pi-subagents) emits `subagents:ready` when it initializes. If [`pi-tasks`](https://github.com/tintinweb/pi-tasks) loaded first, it picks this up. + +``` +┌─────────────┐ ┌──────────────────┐ +│ pi-tasks │ │ pi-subagents │ +└──────┬──────┘ └────────┬─────────┘ + │ │ + │──── subagents:rpc:ping ───────────▶│ + │◀─── subagents:rpc:ping:reply ──────│ + │ │ + │◀─── subagents:ready ───────────────│ (broadcast on init) + │ │ +``` + +### Spawning Subagents + +When `TaskExecute` runs, it sends a spawn RPC with a scoped reply channel: + +``` +pi-tasks pi-subagents + │ │ + │── subagents:rpc:spawn ─────────────────▶│ { requestId, type, prompt, options } + │◀─ subagents:rpc:spawn:reply:{reqId} ───│ { id } (or { error }) + │ │ +``` + +The returned `id` is stored in an in-memory `agentTaskMap` (agentId → taskId) for O(1) completion lookup. A 30-second timeout rejects the Promise if no reply arrives. + +### Lifecycle Events + +[`pi-subagents`](https://github.com/tintinweb/pi-subagents) emits lifecycle events that [`pi-tasks`](https://github.com/tintinweb/pi-tasks) listens to: + +| Event | Payload | Action | +|-------|---------|--------| +| `subagents:completed` | `{ id, result? }` | Mark task `completed`, trigger auto-cascade if enabled | +| `subagents:failed` | `{ id, error?, status }` | Revert task to `pending`, store error in metadata | + +### Standalone Mode + +If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is not installed, everything works except `TaskExecute`, which returns a friendly error message. All core task tools (create, list, get, update, dependencies, widget, system-reminder injection) function independently. + ## Architecture ``` diff --git a/package-lock.json b/package-lock.json index dc37d22..66cf043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tintinweb/pi-tasks", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tintinweb/pi-tasks", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@mariozechner/pi-coding-agent": "^0.57.1", diff --git a/package.json b/package.json index 630c890..cfd534e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tintinweb/pi-tasks", - "version": "0.2.0", + "version": "0.3.0", "description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.", "author": "tintinweb", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 063c390..dd13a2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ * TaskUpdate — Update task fields, status, dependencies * TaskOutput — Get output from a background task process * TaskStop — Stop a running background task process - * TaskExecute — Execute tasks as subagents (requires pi-chonky-subagents) + * TaskExecute — Execute tasks as subagents (requires @tintinweb/pi-subagents) * * Commands: * /tasks — Interactive task management menu @@ -19,7 +19,7 @@ import { Type } from "@sinclair/typebox"; import { TaskStore } from "./task-store.js"; import { ProcessTracker } from "./process-tracker.js"; import { TaskWidget, type UICtx } from "./ui/task-widget.js"; -import type { SubagentBridge } from "./types.js"; +import { randomUUID } from "node:crypto"; // ---- Helpers ---- @@ -50,11 +50,41 @@ export default function (pi: ExtensionAPI) { let latestCtx: ExtensionContext | undefined; /** Cascade config — set by TaskExecute, consumed by completion listener. */ let cascadeConfig: { additionalContext?: string; model?: string; maxTurns?: number } | undefined; + /** Maps agent IDs to task IDs for O(1) completion lookup. */ + const agentTaskMap = new Map(); - /** Get the subagent bridge from the global registry (returns undefined if pi-chonky-subagents not loaded). */ - function getSubagentBridge(): SubagentBridge | undefined { - const key = Symbol.for("pi-subagents:manager"); - return (globalThis as any)[key] as SubagentBridge | undefined; + // ── Subagent extension presence detection ── + // Two paths: (1) listen for ready broadcast (subagents loads first), + // (2) send ping on our init (tasks loads first). + let subagentsAvailable = false; + + // Ping subagents extension — scoped reply channel, no filtering needed + const pingId = randomUUID(); + const unsubPing = pi.events.on(`subagents:rpc:ping:reply:${pingId}`, () => { + subagentsAvailable = true; + unsubPing(); + }); + pi.events.emit("subagents:rpc:ping", { requestId: pingId }); + + // Also listen for ready broadcast (covers: subagents loads after us) + pi.events.on("subagents:ready", () => { + subagentsAvailable = true; + unsubPing(); // clean up ping listener if still pending + }); + + /** Spawn a subagent via pi.events RPC (requires @tintinweb/pi-subagents extension). */ + function spawnSubagent(type: string, prompt: string, options?: any): Promise { + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { unsub(); reject(new Error("subagents:rpc:spawn timeout")); }, 30000); + const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (p: unknown) => { + const { id, error } = p as { id?: string; error?: string }; + unsub(); clearTimeout(timer); + if (error) reject(new Error(error)); + else resolve(id!); + }); + pi.events.emit("subagents:rpc:spawn", { requestId, type, prompt, options }); + }); } /** Build a prompt for a task being executed by a subagent. */ @@ -69,35 +99,39 @@ export default function (pi: ExtensionAPI) { // Listens for subagent lifecycle events to update task status and optionally cascade. // Success → mark task completed, cascade if enabled - pi.events.on("subagents:completed", (data) => { - const { id } = data as { id: string }; - const task = store.list().find(t => t.metadata?.agentId === id); + pi.events.on("subagents:completed", async (data) => { + const { id, result } = data as { id: string; result?: string }; + const taskId = agentTaskMap.get(id); + if (!taskId) return; + agentTaskMap.delete(id); + const task = store.get(taskId); if (!task) return; - store.update(task.id, { status: "completed" }); + store.update(task.id, { status: "completed", metadata: { ...task.metadata, result } }); widget.setActiveTask(task.id, false); // Auto-cascade: find unblocked dependents with agentType if (autoCascadeEnabled && cascadeConfig && latestCtx) { - const bridge = getSubagentBridge(); - if (bridge) { - const unblocked = store.list().filter(t => - t.status === "pending" && - t.metadata?.agentType && - t.blockedBy.includes(task.id) && - t.blockedBy.every(depId => store.get(depId)?.status === "completed") - ); - for (const next of unblocked) { - store.update(next.id, { status: "in_progress" }); - const prompt = buildTaskPrompt(next, cascadeConfig.additionalContext); - const agentId = bridge.spawn(pi, latestCtx, - next.metadata.agentType, prompt, { - description: next.subject, - isBackground: true, - maxTurns: cascadeConfig.maxTurns, - }); + const unblocked = store.list().filter(t => + t.status === "pending" && + t.metadata?.agentType && + t.blockedBy.includes(task.id) && + t.blockedBy.every(depId => store.get(depId)?.status === "completed") + ); + for (const next of unblocked) { + store.update(next.id, { status: "in_progress" }); + const prompt = buildTaskPrompt(next, cascadeConfig.additionalContext); + try { + const agentId = await spawnSubagent(next.metadata.agentType, prompt, { + description: next.subject, + isBackground: true, + maxTurns: cascadeConfig.maxTurns, + }); + agentTaskMap.set(agentId, next.id); store.update(next.id, { owner: agentId, metadata: { ...next.metadata, agentId } }); widget.setActiveTask(next.id); + } catch (err: any) { + store.update(next.id, { status: "pending", metadata: { ...next.metadata, lastError: err.message } }); } } } @@ -107,7 +141,10 @@ export default function (pi: ExtensionAPI) { // Failure → store error, revert to pending, don't cascade (branch stops) pi.events.on("subagents:failed", (data) => { const { id, error, status } = data as { id: string; error?: string; status: string }; - const task = store.list().find(t => t.metadata?.agentId === id); + const taskId = agentTaskMap.get(id); + if (!taskId) return; + agentTaskMap.delete(id); + const task = store.get(taskId); if (!task) return; store.update(task.id, { status: "pending", @@ -608,7 +645,7 @@ Set up task dependencies: pi.registerTool({ name: "TaskExecute", label: "TaskExecute", - description: `Execute one or more tasks as subagents. Requires pi-chonky-subagents extension. + description: `Execute one or more tasks as subagents. Requires @tintinweb/pi-subagents extension. ## When to Use This Tool @@ -629,13 +666,12 @@ Set up task dependencies: max_turns: Type.Optional(Type.Number({ description: "Max turns per agent", minimum: 1 })), }), - execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const bridge = getSubagentBridge(); - if (!bridge) { - return Promise.resolve(textResult( - "TaskExecute requires the pi-chonky-subagents extension to be loaded. " + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + if (!subagentsAvailable) { + return textResult( + "TaskExecute requires the @tintinweb/pi-subagents extension to be loaded. " + "Install and enable it, then try again." - )); + ); } const results: string[] = []; @@ -666,18 +702,23 @@ Set up task dependencies: continue; } - // Mark in_progress and spawn agent + // Mark in_progress and spawn agent via RPC store.update(taskId, { status: "in_progress" }); const prompt = buildTaskPrompt(task, params.additional_context); - const agentId = bridge.spawn(pi, ctx, task.metadata.agentType, prompt, { - description: task.subject, - isBackground: true, - maxTurns: params.max_turns, - }); - - store.update(taskId, { owner: agentId, metadata: { ...task.metadata, agentId } }); - widget.setActiveTask(taskId); - launched.push(`#${taskId} → agent ${agentId}`); + try { + const agentId = await spawnSubagent(task.metadata.agentType, prompt, { + description: task.subject, + isBackground: true, + maxTurns: params.max_turns, + }); + agentTaskMap.set(agentId, taskId); + store.update(taskId, { owner: agentId, metadata: { ...task.metadata, agentId } }); + widget.setActiveTask(taskId); + launched.push(`#${taskId} → agent ${agentId}`); + } catch (err: any) { + store.update(taskId, { status: "pending" }); + results.push(`#${taskId}: spawn failed — ${err.message}`); + } } // Save cascade config for the completion listener @@ -694,7 +735,7 @@ Set up task dependencies: if (results.length > 0) lines.push(`Skipped:\n${results.join("\n")}`); if (lines.length === 0) lines.push("No tasks to execute."); - return Promise.resolve(textResult(lines.join("\n\n"))); + return textResult(lines.join("\n\n")); }, }); diff --git a/src/types.ts b/src/types.ts index fac1504..6ba2cf3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,14 +24,6 @@ export interface TaskStoreData { tasks: Task[]; } -/** Bridge to the pi-chonky-subagents extension via Symbol.for global registry. */ -export interface SubagentBridge { - waitForAll(): Promise; - hasRunning(): boolean; - spawn(pi: any, ctx: any, type: string, prompt: string, options: any): string; - getRecord(id: string): any | undefined; -} - /** Background process associated with a task. */ export interface BackgroundProcess { taskId: string; diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index 9083386..608a4ea 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -169,9 +169,9 @@ export class TaskWidget { if (isActive) { icon = theme.fg("accent", spinnerChar); } else if (task.status === "completed") { - icon = "✔"; + icon = theme.fg("green", "✔"); } else if (task.status === "in_progress") { - icon = "◼"; + icon = theme.fg("accent", "◼"); } else { icon = "◻"; } @@ -206,7 +206,7 @@ export class TaskWidget { } text = ` ${icon} ${theme.fg("accent", form + agentLabel + "…")}${stats}`; } else if (task.status === "completed") { - text = ` ${theme.fg("dim", icon)} ${theme.fg("dim", theme.strikethrough(task.subject))}`; + text = ` ${icon} ${theme.fg("dim", theme.strikethrough(task.subject))}`; } else { const agentSuffix = task.status === "in_progress" && task.metadata?.agentId ? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`) diff --git a/test/subagent-integration.test.ts b/test/subagent-integration.test.ts index ef2d073..e879b7e 100644 --- a/test/subagent-integration.test.ts +++ b/test/subagent-integration.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { TaskStore } from "../src/task-store.js"; import { TaskWidget, type UICtx, type Theme } from "../src/ui/task-widget.js"; -import type { SubagentBridge } from "../src/types.js"; import initExtension from "../src/index.js"; // ---- Mock pi ---- @@ -76,67 +75,72 @@ function mockCtx() { }; } -// ---- Mock subagent bridge ---- +// ---- Mock subagents extension (RPC responders) ---- -function mockBridge(): SubagentBridge & { spawned: Array<{ type: string; prompt: string; options: any }> } { +/** Simulates the @tintinweb/pi-subagents extension: responds to ping + spawn RPCs and emits ready. */ +function installSubagentsMock(pi: { events: { on: Function; emit: Function } }) { let idCounter = 0; const spawned: Array<{ id: string; type: string; prompt: string; options: any }> = []; + // Respond to ping — reply on scoped channel + const unsubPing = pi.events.on("subagents:rpc:ping", (data: unknown) => { + const { requestId } = data as { requestId: string }; + pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, {}); + }); + + // Respond to spawn — reply on scoped channel + const unsubSpawn = pi.events.on("subagents:rpc:spawn", (data: unknown) => { + const { requestId, type, prompt, options } = data as { + requestId: string; type: string; prompt: string; options?: any; + }; + const id = `agent-${++idCounter}`; + spawned.push({ id, type, prompt, options }); + pi.events.emit(`subagents:rpc:spawn:reply:${requestId}`, { id }); + }); + + // Broadcast readiness + pi.events.emit("subagents:ready", {}); + return { spawned, - waitForAll: async () => {}, - hasRunning: () => false, - spawn(_pi: any, _ctx: any, type: string, prompt: string, options: any) { - const id = `agent-${++idCounter}`; - spawned.push({ id, type, prompt, options }); - return id; - }, - getRecord(id: string) { - return spawned.find(s => s.id === id) ? { id, status: "running" } : undefined; - }, + unsub() { unsubPing(); unsubSpawn(); }, }; } -/** Install/remove a mock bridge on the global registry. */ -function installBridge(bridge: SubagentBridge) { - const key = Symbol.for("pi-subagents:manager"); - (globalThis as any)[key] = bridge; - return () => { delete (globalThis as any)[key]; }; -} - // ---- Tests ---- describe("TaskExecute", () => { let mock: ReturnType; - let bridge: ReturnType; - let removeBridge: () => void; + let rpc: ReturnType; beforeEach(() => { mock = mockPi(); + // Install mock BEFORE init so ping reply is received during extension init + rpc = installSubagentsMock(mock.pi); initExtension(mock.pi as any); - bridge = mockBridge(); - removeBridge = installBridge(bridge); }); afterEach(() => { - removeBridge(); + rpc.unsub(); }); it("is registered as a tool", () => { expect(mock.tools.has("TaskExecute")).toBe(true); }); - it("returns error when subagent bridge is not loaded", async () => { - removeBridge(); - // Create a task with agentType - await mock.executeTool("TaskCreate", { + it("returns error when subagent extension is not loaded", async () => { + // Re-init without mock to simulate missing extension + const freshMock = mockPi(); + initExtension(freshMock.pi as any); + + await freshMock.executeTool("TaskCreate", { subject: "Test task", description: "Do something", agentType: "general-purpose", }); - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("requires the pi-chonky-subagents extension"); + const result = await freshMock.executeTool("TaskExecute", { task_ids: ["1"] }); + expect(result.content[0].text).toContain("requires the @tintinweb/pi-subagents extension"); }); it("rejects non-existent tasks", async () => { @@ -194,11 +198,11 @@ describe("TaskExecute", () => { expect(result.content[0].text).toContain("Launched 1 agent"); expect(result.content[0].text).toContain("#1 → agent agent-1"); - // Verify the bridge was called - expect(bridge.spawned).toHaveLength(1); - expect(bridge.spawned[0].type).toBe("general-purpose"); - expect(bridge.spawned[0].prompt).toContain("Run the test suite"); - expect(bridge.spawned[0].options.isBackground).toBe(true); + // Verify the RPC responder was called + expect(rpc.spawned).toHaveLength(1); + expect(rpc.spawned[0].type).toBe("general-purpose"); + expect(rpc.spawned[0].prompt).toContain("Run the test suite"); + expect(rpc.spawned[0].options.isBackground).toBe(true); }); it("passes additional_context and max_turns to spawned agents", async () => { @@ -214,8 +218,8 @@ describe("TaskExecute", () => { max_turns: 10, }); - expect(bridge.spawned[0].prompt).toContain("Focus on REST endpoints only"); - expect(bridge.spawned[0].options.maxTurns).toBe(10); + expect(rpc.spawned[0].prompt).toContain("Focus on REST endpoints only"); + expect(rpc.spawned[0].options.maxTurns).toBe(10); }); it("allows executing tasks whose blockers are all completed", async () => { @@ -255,20 +259,42 @@ describe("TaskExecute", () => { }); }); +describe("TaskExecute via ready broadcast", () => { + it("detects subagents when ready fires after tasks init", async () => { + // Init tasks WITHOUT the mock — subagents not available yet + const mock = mockPi(); + initExtension(mock.pi as any); + + // Now install the mock (simulates subagents loading later) and broadcast ready + const rpc = installSubagentsMock(mock.pi); + + // Create a task and execute — should work because ready was received + await mock.executeTool("TaskCreate", { + subject: "Late-loaded test", + description: "Desc", + agentType: "general-purpose", + }); + + const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); + expect(result.content[0].text).toContain("Launched 1 agent"); + expect(rpc.spawned).toHaveLength(1); + + rpc.unsub(); + }); +}); + describe("Completion listener", () => { let mock: ReturnType; - let bridge: ReturnType; - let removeBridge: () => void; + let rpc: ReturnType; beforeEach(() => { mock = mockPi(); + rpc = installSubagentsMock(mock.pi); initExtension(mock.pi as any); - bridge = mockBridge(); - removeBridge = installBridge(bridge); }); afterEach(() => { - removeBridge(); + rpc.unsub(); }); it("marks task completed on subagents:completed event", async () => { @@ -318,29 +344,18 @@ describe("Completion listener", () => { describe("Auto-cascade", () => { let mock: ReturnType; - let bridge: ReturnType; - let removeBridge: () => void; + let rpc: ReturnType; beforeEach(() => { mock = mockPi(); + rpc = installSubagentsMock(mock.pi); initExtension(mock.pi as any); - bridge = mockBridge(); - removeBridge = installBridge(bridge); }); afterEach(() => { - removeBridge(); + rpc.unsub(); }); - /** Enable auto-cascade by toggling the setting via the /tasks command mock. */ - function enableAutoCascade() { - // Auto-cascade is toggled via module-level state. Since we can't access it - // directly, we test that WITHOUT enabling it, cascade doesn't happen, - // and test the cascade logic indirectly via event flow. - // For a proper toggle test we'd need to invoke the /tasks command handler, - // but that requires a full UI mock. Instead we test the default (off) behavior. - } - it("does NOT cascade when auto-cascade is off (default)", async () => { // Create A → B chain await mock.executeTool("TaskCreate", { @@ -357,13 +372,13 @@ describe("Auto-cascade", () => { // Execute A await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(bridge.spawned).toHaveLength(1); + expect(rpc.spawned).toHaveLength(1); // Complete A mock.emitEvent("subagents:completed", { id: "agent-1" }); // B should NOT have been auto-started - expect(bridge.spawned).toHaveLength(1); + expect(rpc.spawned).toHaveLength(1); // B should still be pending const result = await mock.executeTool("TaskGet", { taskId: "2" }); @@ -387,7 +402,7 @@ describe("Auto-cascade", () => { mock.emitEvent("subagents:failed", { id: "agent-1", error: "crashed", status: "error" }); // B should not start - expect(bridge.spawned).toHaveLength(1); + expect(rpc.spawned).toHaveLength(1); const result = await mock.executeTool("TaskGet", { taskId: "2" }); expect(result.content[0].text).toContain("Status: pending"); }); @@ -409,7 +424,7 @@ describe("Auto-cascade", () => { mock.emitEvent("subagents:completed", { id: "agent-1" }); // Manual task should stay pending - expect(bridge.spawned).toHaveLength(1); + expect(rpc.spawned).toHaveLength(1); }); }); @@ -488,6 +503,177 @@ describe("System prompt READY tags", () => { }); }); +describe("Standalone operation (no subagents extension)", () => { + let mock: ReturnType; + + beforeEach(() => { + // Init WITHOUT installSubagentsMock — no subagents extension present + mock = mockPi(); + initExtension(mock.pi as any); + }); + + it("all core task tools are registered", () => { + for (const name of ["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskExecute"]) { + expect(mock.tools.has(name)).toBe(true); + } + }); + + it("TaskCreate works without subagents", async () => { + const result = await mock.executeTool("TaskCreate", { + subject: "Write tests", + description: "Add unit tests for the parser", + }); + expect(result.content[0].text).toContain("Write tests"); + }); + + it("TaskList works without subagents", async () => { + await mock.executeTool("TaskCreate", { subject: "A", description: "desc" }); + await mock.executeTool("TaskCreate", { subject: "B", description: "desc" }); + const result = await mock.executeTool("TaskList", {}); + expect(result.content[0].text).toContain("#1"); + expect(result.content[0].text).toContain("#2"); + }); + + it("TaskGet works without subagents", async () => { + await mock.executeTool("TaskCreate", { subject: "Read me", description: "details here" }); + const result = await mock.executeTool("TaskGet", { taskId: "1" }); + expect(result.content[0].text).toContain("Read me"); + expect(result.content[0].text).toContain("details here"); + }); + + it("TaskUpdate works without subagents", async () => { + await mock.executeTool("TaskCreate", { subject: "Update me", description: "desc" }); + await mock.executeTool("TaskUpdate", { taskId: "1", status: "in_progress" }); + const result = await mock.executeTool("TaskGet", { taskId: "1" }); + expect(result.content[0].text).toContain("in_progress"); + }); + + it("TaskExecute gracefully refuses without subagents", async () => { + await mock.executeTool("TaskCreate", { + subject: "Agent task", + description: "desc", + agentType: "general-purpose", + }); + const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); + expect(result.content[0].text).toContain("requires the @tintinweb/pi-subagents extension"); + }); + + it("subagents lifecycle events are silently ignored without mapped agents", () => { + // These should not throw even though no subagents extension is loaded + mock.emitEvent("subagents:completed", { id: "ghost-agent", result: "done" }); + mock.emitEvent("subagents:failed", { id: "ghost-agent", error: "boom", status: "error" }); + // No crash = pass + }); + + it("task dependencies work without subagents", async () => { + await mock.executeTool("TaskCreate", { subject: "First", description: "desc" }); + await mock.executeTool("TaskCreate", { subject: "Second", description: "desc" }); + await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); + + const result = await mock.executeTool("TaskGet", { taskId: "2" }); + expect(result.content[0].text).toContain("Blocked by"); + expect(result.content[0].text).toContain("#1"); + }); +}); + +describe("RPC protocol correctness", () => { + it("ping uses scoped reply channel (not shared channel)", () => { + const mock = mockPi(); + const emitted: Array<{ channel: string; data: unknown }> = []; + const origEmit = mock.pi.events.emit.bind(mock.pi.events); + mock.pi.events.emit = (channel: string, data: unknown) => { + emitted.push({ channel, data }); + origEmit(channel, data); + }; + + initExtension(mock.pi as any); + + // Find the ping emit + const pingEmit = emitted.find(e => e.channel === "subagents:rpc:ping"); + expect(pingEmit).toBeDefined(); + const pingData = pingEmit!.data as { requestId: string }; + expect(pingData.requestId).toBeDefined(); + expect(typeof pingData.requestId).toBe("string"); + }); + + it("spawn reply cleans up listener and timer on success", async () => { + const mock = mockPi(); + const rpc = installSubagentsMock(mock.pi); + initExtension(mock.pi as any); + + await mock.executeTool("TaskCreate", { + subject: "Test", + description: "desc", + agentType: "general-purpose", + }); + + await mock.executeTool("TaskExecute", { task_ids: ["1"] }); + expect(rpc.spawned).toHaveLength(1); + + // Second spawn should get a fresh requestId (not conflict with first) + await mock.executeTool("TaskCreate", { + subject: "Test 2", + description: "desc", + agentType: "general-purpose", + }); + await mock.executeTool("TaskExecute", { task_ids: ["2"] }); + expect(rpc.spawned).toHaveLength(2); + expect(rpc.spawned[0].id).not.toBe(rpc.spawned[1].id); + + rpc.unsub(); + }); + + it("spawn RPC rejects on timeout when no responder exists", async () => { + const mock = mockPi(); + initExtension(mock.pi as any); + + // Emit ready AFTER init so the listener is registered — marks subagents + // as available, but there's no spawn handler installed + mock.pi.events.emit("subagents:ready", {}); + + await mock.executeTool("TaskCreate", { + subject: "Timeout test", + description: "desc", + agentType: "general-purpose", + }); + + // spawnSubagent has a 30s timeout — we'll advance timers + vi.useFakeTimers(); + const execPromise = mock.executeTool("TaskExecute", { task_ids: ["1"] }); + await vi.advanceTimersByTimeAsync(31000); + + const result = await execPromise; + expect(result.content[0].text).toContain("timeout"); + + vi.useRealTimers(); + }); + + it("ready broadcast sets subagentsAvailable even after init", async () => { + const mock = mockPi(); + initExtension(mock.pi as any); + + // Initially no subagents + await mock.executeTool("TaskCreate", { + subject: "Test", + description: "desc", + agentType: "general-purpose", + }); + let result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); + expect(result.content[0].text).toContain("requires the @tintinweb/pi-subagents extension"); + + // Reset task status + await mock.executeTool("TaskUpdate", { taskId: "1", status: "pending" }); + + // Late subagents extension broadcasts ready + const rpc = installSubagentsMock(mock.pi); + + result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); + expect(result.content[0].text).toContain("Launched 1 agent"); + + rpc.unsub(); + }); +}); + describe("Widget agent ID display", () => { let store: TaskStore; let widget: TaskWidget;