Rename tool to taskgraph, use op dispatch field, add research reports

The built-in OpenCode 'task' tool spawns subagents for work delegation.
Naming our plugin 'tasks' would create confusion with two 'task' tools
that do completely different things. 'taskgraph' matches the core
library, clearly differentiates from the built-in, and describes what
the tool actually does.

The dispatch field is renamed from 'tool' to 'op' (operation) to
avoid collision with OpenCode's 'tool' terminology and match the
Rust CLI's subcommand pattern.

ADR-001 rewritten for taskgraph/op naming and Zod/TypeBox distinction.
ADR-007 added documenting the naming decision and the three 'task'
concepts (task, todowrite, taskgraph).

Research reports added:
- docs/research/opencode-task-tool-deep-dive.md
- docs/research/open-coordinator-deep-dive.md

Also: fixed SDD process link, resolved open question about 'show'
including full body, added todowrite to relationship table, clarified
Zod vs TypeBox roles, changed FileSource to async scan.
This commit is contained in:
2026-04-28 11:30:20 +00:00
parent f8b7a2fc1b
commit 9342dab70c
6 changed files with 1733 additions and 46 deletions

View File

@@ -11,7 +11,9 @@ The plugin exposes 14 distinct operations (list, show, deps, dependents, validat
## Decision
Collapse all operations into a single `tasks` tool that dispatches by `{tool: string, args?: Record<string, unknown>}`. The agent calls `tasks({tool: "help"})` to discover available operations on demand.
Collapse all operations into a single `taskgraph` tool that dispatches by `{op, args}`. The agent calls `taskgraph({op: "help"})` to discover available operations on demand.
The dispatch field is named `op` (operation) rather than `tool` to avoid collision with OpenCode's own "tool" terminology. An agent calling `taskgraph({op: "list"})` reads clearly: "run the list operation on the taskgraph." This also matches the Rust CLI's subcommand pattern (`taskgraph parallel`, `taskgraph critical`).
This follows the pattern established by open-memory, which exposes 9 operations through a single `memory` tool.
@@ -21,19 +23,25 @@ This follows the pattern established by open-memory, which exposes 9 operations
- Minimal context overhead (~250 tokens for one tool schema vs ~3500 for 14)
- Adding new operations never increases context bloat
- Agent always has access to the full operation set without schema pollution
- Consistent with the alk.dev ecosystem pattern (memory, coordinator all use this)
- Consistent with the alk.dev ecosystem pattern (memory, coordinator both use this)
- `op` field name is unambiguous in OpenCode's context
**Negative:**
- The `tool` and `args` fields are not validated by the outer Zod schema — validation happens inside the dispatch handler
- The `op` and `args` fields are not individually validated by the outer schema — validation happens inside the dispatch handler
- Agent must call help to discover operations; the tool description can only hint
- Slightly more overhead per call (string dispatch vs direct function call)
**Mitigation for negatives:**
- The `tool` field description enumerates all operation names, so the LLM can dispatch correctly
- The `op` field description enumerates all operation names, so the LLM can dispatch correctly
- Validation errors are clear and include usage guidance
- The help operation provides complete reference with examples
## Note on Schema Libraries
The tool's outer parameter schema uses **Zod** (from `@opencode-ai/plugin`'s `tool()` helper) because that's what OpenCode's plugin SDK provides for tool definitions. The plugin's internal config schema uses **TypeBox** (from `@alkdev/typebox`, already a dependency via `@alkdev/taskgraph`) for compile-time types and runtime `Value.Check()`. These are two different concerns: Zod for OpenCode's tool interface, TypeBox for our own config. No conflict — each is used where it's the native choice.
## References
- open-memory `src/tools.ts`: proven pattern in production
- OpenCode plugin SDK: `tool.schema` (Zod) for schema definition
- OpenCode plugin SDK: `tool.schema` (Zod) for tool parameter schemas
- ADR-007: naming decision — `taskgraph` not `tasks`, `op` not `tool`

View File

@@ -0,0 +1,53 @@
---
status: draft
last_updated: 2026-04-28
---
# ADR-007: Tool Naming — `taskgraph` not `tasks`
## Context
OpenCode has a built-in `task` tool that spawns subagents for work delegation. It creates child sessions, dispatches prompts to specialized agents, and returns results. It is deeply wired into the session, permission, and UI systems.
Our plugin was initially named `tasks` (plural), which creates three problems:
1. **Naming confusion**: `task` (spawning) vs `tasks` (analysis) — both deal with "tasks" but are fundamentally different. An LLM receiving a request like "look at the tasks" might invoke the wrong one.
2. **Semantic overlap**: `task` = delegation ("who should do this work?"), `tasks` = analysis ("what work exists and in what order?"), `todowrite` = progress tracking ("what am I working on right now?"). Three concepts, near-identical naming for two of them.
3. **Plugin shadowing risk**: OpenCode resolves tools into an object by ID. If a plugin registers a tool with the same ID as a built-in tool, the plugin wins. Accidentally shadowing the built-in `task` tool would break subagent spawning entirely.
Additionally, the dispatch field was initially named `tool` (matching open-memory's pattern). But the field name `tool` is ambiguous in OpenCode's context — every registered function is a "tool." The operation name `op` is more precise and matches the Rust CLI's subcommand pattern.
## Decision
- **Tool name**: `taskgraph` — directly matches the core library (`@alkdev/taskgraph`), clearly differentiates from the built-in `task`, and describes what the tool actually does.
- **Dispatch field**: `op` (operation) — unambiguous in context, distinguishes from the outer "tool" concept, matches the Rust CLI's subcommand pattern (`taskgraph parallel`, `taskgraph critical`, etc.).
## Consequences
**Positive:**
- No naming confusion with built-in `task`
- `taskgraph({op: "list"})` reads clearly: "run the list operation on the taskgraph"
- Matches the Rust CLI naming — users familiar with `taskgraph parallel` will recognize `taskgraph({op: "parallel"})`
- The `op` field name is self-documenting: each value is an operation, not a nested tool
**Negative:**
- Slightly longer tool name (10 chars vs 5 for `tasks`)
- Deviates from open-memory's `memory({tool: ...})` pattern — but memory doesn't have a naming collision with a built-in tool
## The Three "Task" Concepts
| Tool | Concept | Scope | Persistence |
|------|---------|-------|-------------|
| `task` (built-in) | Delegation — spawn a subagent | Session-scoped | Ephemeral |
| `todowrite` (built-in) | Progress tracking — what am I working on | Session-scoped | Ephemeral |
| `taskgraph` (this plugin) | Analysis — dependencies, risk, cost | Project-scoped | Persistent files |
These are complementary, not competing. Future integration could make `taskgraph` feed analysis into `task` (e.g., use `parallel` groups to drive `spawn` decisions), but that's a v2 concern.
## References
- OpenCode built-in `task` tool: `/workspace/opencode/packages/opencode/src/tool/task.ts`
- Research report: [docs/research/opencode-task-tool-deep-dive.md](../research/opencode-task-tool-deep-dive.md)
- Open-coordinator deep dive: [docs/research/open-coordinator-deep-dive.md](../research/open-coordinator-deep-dive.md)

View File

@@ -5,12 +5,24 @@ last_updated: 2026-04-28
# Open Tasks: Architecture Overview
Structured task management for OpenCode agents — graph analysis, dependency insight, decomposition guidance, and workflow cost estimation. Exposes a single `tasks` tool using a registry pattern to keep the agent's visible tool count minimal.
Structured task management for OpenCode agents — graph analysis, dependency insight, decomposition guidance, and workflow cost estimation. Exposes a single `taskgraph` tool using a registry pattern to keep the agent's visible tool count minimal.
## Problem
The `taskgraph` Rust CLI provides task graph operations but requires shell invocation — agents must compose bash commands and parse plain-text output. This is error-prone, context-expensive, and gives no structural validation or rich formatting. The TypeScript core library (`@alkdev/taskgraph`) now provides all graph operations natively. This plugin wraps that library into an OpenCode tool interface so agents get first-class, structured access without leaving the conversation.
## Naming: `taskgraph` not `tasks`
OpenCode has a built-in `task` tool that spawns subagents for work delegation. Naming our plugin `tasks` (plural) would create confusion — both deal with "tasks" but have completely different purposes:
| Tool | Concept | Scope |
|------|---------|-------|
| `task` (built-in) | **Delegation** — spawn a subagent to do work | Session-scoped, ephemeral |
| `todowrite` (built-in) | **Progress tracking** — what am I working on now | Session-scoped, flat list |
| `taskgraph` (this plugin) | **Analysis** — what work exists, what depends on what, what's risky | Persistent, graph-structured |
The name `taskgraph` directly matches the core library, clearly differentiates from the built-in `task`, and describes what the tool actually does. See [ADR-007](decisions/007-naming-taskgraph.md).
## What This Plugin Is
A **read-only analysis and query layer** on top of the project's `tasks/` directory. It:
@@ -30,33 +42,35 @@ A **read-only analysis and query layer** on top of the project's `tasks/` direct
### Single-Tool Registry Pattern
Following open-memory's proven approach, the plugin exposes **one tool** (`tasks`) with internal operation dispatch:
Following open-memory's proven approach, the plugin exposes **one tool** (`taskgraph`) with internal operation dispatch:
```
tasks({tool: "help"}) → Show available operations
tasks({tool: "list"}) → List tasks in project
tasks({tool: "show", args: {id: "..."}}) → Show task details
tasks({tool: "deps", args: {id: "..."}}) → Task prerequisites
tasks({tool: "dependents", args: {id: "..."}}) → Tasks depending on a task
tasks({tool: "validate"}) → Validate all task files
tasks({tool: "topo"}) → Topological ordering
tasks({tool: "cycles"}) → Circular dependency detection
tasks({tool: "critical"}) → Critical path
tasks({tool: "parallel"}) → Parallel execution groups
tasks({tool: "bottleneck"}) → Bottleneck analysis
tasks({tool: "risk"}) → Risk path + distribution
tasks({tool: "cost"}) → Workflow cost estimate
tasks({tool: "decompose", args: {id: "..."}}) → Decomposition guidance
taskgraph({op: "help"}) → Show available operations
taskgraph({op: "list"}) → List tasks in project
taskgraph({op: "show", args: {id: "..."}}) → Show task details
taskgraph({op: "deps", args: {id: "..."}}) → Task prerequisites
taskgraph({op: "dependents", args: {id: "..."}}) → Tasks depending on a task
taskgraph({op: "validate"}) → Validate all task files
taskgraph({op: "topo"}) → Topological ordering
taskgraph({op: "cycles"}) → Circular dependency detection
taskgraph({op: "critical"}) → Critical path
taskgraph({op: "parallel"}) → Parallel execution groups
taskgraph({op: "bottleneck"}) → Bottleneck analysis
taskgraph({op: "risk"}) → Risk path + distribution
taskgraph({op: "cost"}) → Workflow cost estimate
taskgraph({op: "decompose", args: {id: "..."}}) → Decomposition guidance
```
**Why**: Each tool definition adds JSON schema to the system prompt (~200-300 tokens each). 14 operations as 14 separate tools = ~3500 tokens of tool definitions. The registry pattern collapses this to ~250 tokens (one tool schema) plus an on-demand help text the agent retrieves only when needed. This is the same math that drove open-memory's design.
**Why `op` instead of `tool`**: The dispatch field is named `op` (operation) rather than `tool` to avoid collision with OpenCode's own "tool" terminology. An agent calling `taskgraph({tool: "list"})` reads ambiguously — is "list" a tool or an operation on the taskgraph tool? `taskgraph({op: "list"})` is clearer: "run the list operation on the taskgraph."
### Component Structure
```
src/
├── index.ts # Plugin entry: tool registration + config loading
├── tools.ts # Tool definition — single `tasks` tool with registry dispatch
├── tools.ts # Tool definition — single `taskgraph` tool with registry dispatch
├── registry.ts # Operation registry (dispatch table, arg validation)
├── config.ts # Plugin config schema + resolution (TypeBox, validated)
├── sources/
@@ -206,7 +220,7 @@ class FileSource implements TaskSource {
}
const glob = new Bun.Glob("**/*.md")
const files = Array.from(glob.scanSync({ cwd: this.dirPath }))
const files = await Array.fromAsync(glob.scan({ cwd: this.dirPath }))
// ... read each file, parse with parseFrontmatter, collect results
}
}
@@ -265,15 +279,15 @@ function createSource(config: Config, workspaceDir: string): TaskSource {
### Help Operation
`tasks({tool: "help"})` returns the full operation reference table. `tasks({tool: "help", args: {tool: "list"}})` returns detailed usage for one operation including argument shapes and example calls.
`taskgraph({op: "help"})` returns the full operation reference table. `taskgraph({op: "help", args: {op: "list"}})` returns detailed usage for one operation including argument shapes and example calls.
## Design Decisions
### D1: Registry Pattern (single tool, not 14)
- **Context**: 14 operations could each be a separate tool or collapsed into one router.
- **Choice**: Single `tasks` tool with `{tool, args}` dispatch.
- **Consequences**: Agent always has access to the help reference. Adding operations never increases context bloat. Trade-off: the `tool` and `args` fields are not individually validated by the outer schema — validation happens inside the dispatch.
- **Choice**: Single `taskgraph` tool with `{op, args}` dispatch.
- **Consequences**: Agent always has access to the help reference. Adding operations never increases context bloat. Trade-off: the `op` and `args` fields are not individually validated by the outer schema — validation happens inside the dispatch.
- **Reference**: See [ADR-001](decisions/001-registry-pattern.md)
### D2: No Caching, Fresh Graph Per Call
@@ -299,7 +313,7 @@ function createSource(config: Config, workspaceDir: string): TaskSource {
### D5: `cost` Defaults Match SDD Process
- **Context**: `workflowCost()` supports `propagationMode` (independent vs dag-propagate), `defaultQualityRetention`, and `includeCompleted`. Different defaults make sense for different workflows.
- **Choice**: Default to `propagationMode: "dag-propagate"`, `includeCompleted: false`, `defaultQualityRetention: 0.9` — matching the Spec-Driven Development (SDD) process's assumption that completed tasks are factored out of remaining cost, and that quality degrades probabilistically across dependencies. See [SDD Process](../../sdd_process.md) for the overall workflow.
- **Choice**: Default to `propagationMode: "dag-propagate"`, `includeCompleted: false`, `defaultQualityRetention: 0.9` — matching the Spec-Driven Development (SDD) process's assumption that completed tasks are factored out of remaining cost, and that quality degrades probabilistically across dependencies. See [SDD Process](../sdd_process.md) for the overall workflow.
- **Consequences**: The most common use case (active project planning) gets sensible defaults. Agents can override per-call.
### D6: Separate `registry.ts` From `tools.ts`
@@ -363,7 +377,9 @@ No hooks in v1. Future: task status injection into system prompt (similar to ope
### Tool Definition (`src/tools.ts`)
Single tool with `{tool: string, args?: Record<string, unknown>}` schema. The `tool` field dispatches to an operation handler via the registry. Unknown tool names produce a friendly error directing to `tasks({tool: "help"})`.
Single tool with `{op: string, args?: Record<string, unknown>}` schema. The `op` field dispatches to an operation handler via the registry. Unknown operation names produce a friendly error directing to `taskgraph({op: "help"})`.
The tool's parameter schema uses **Zod** (from `@opencode-ai/plugin`'s `tool()` helper) because that's what OpenCode's plugin SDK provides for tool definitions. The plugin's internal config schema uses **TypeBox** for compile-time types and runtime `Value.Check()`. These are two different concerns: Zod for the tool's external interface (what the LLM sees), TypeBox for our own config (what we validate at startup).
The `source` is passed from the plugin entry to `createTools()` and stored in the registry for all operations to use.
@@ -415,7 +431,7 @@ Operations encounter two categories of errors:
### Graph Errors (validation / cycles)
- **Cycle detection**: The `cycles` operation surfaces all cycles. Operations that require topological ordering (topo, critical, parallel, cost) catch `CircularDependencyError` and return a message suggesting `tasks({tool: "cycles"})` first
- **Cycle detection**: The `cycles` operation surfaces all cycles. Operations that require topological ordering (topo, critical, parallel, cost) catch `CircularDependencyError` and return a message suggesting `taskgraph({op: "cycles"})` first
- **Validation errors**: The `validate` operation returns both schema errors (field-level: invalid enums, missing required fields) and graph errors (dangling references, duplicate edges). Other operations call `graph.validate()` only when structural correctness matters
- **Task not found**: Operations that take a task `id` return a clear "not found" message listing the available task IDs (up to 20)
@@ -479,24 +495,32 @@ New operations can be added freely — the registry pattern means no schema bloa
| Plugin | Relationship |
|--------|-------------|
| **open-memory** | Complementary — memory handles session introspection; tasks handles task graph analysis. Both use the registry pattern. |
| **open-coordinator** | Downstream consumer — coordinator uses `tasks` to identify parallelizable work, then spawns worktrees. The `parallel` and `critical` operations inform coordination decisions. |
| **open-memory** | Complementary — memory handles session introspection; taskgraph handles task graph analysis. Both use the registry pattern. |
| **open-coordinator** | Future integration — coordinator's `spawn`/`swarm` could consume taskgraph's `parallel` and `critical` analysis for dependency-aware parallel execution. Currently no integration exists. |
| **taskgraph CLI** | Functional equivalent — the Rust CLI and this plugin expose the same operations, but this plugin is native TypeScript + in-process, while the CLI is a separate binary. |
| **@alkdev/taskgraph** | Core dependency — all graph operations. This plugin is a thin wrapper. |
| **`task` (built-in)** | Distinct concept — spawns subagents for work delegation. `taskgraph` analyzes dependencies. Future: `task` could consume `taskgraph` analysis for smarter delegation, but these are complementary, not competing. See [ADR-007](decisions/007-naming-taskgraph.md). |
| **`todowrite` (built-in)** | Complementary — session-scoped flat progress tracking. `taskgraph` operates on persistent graph-structured project files; `todowrite` tracks in-session ephemeral progress. No overlap. |
## Open Questions
1. **Should `show` include the task's markdown body?** Task files can be long (especially with acceptance criteria and notes). Option A: always include full body. Option B: `show` returns frontmatter summary, `show --full` includes body. Recommendation: always include body — agents need the full context for implementation tasks, and `show` is on-demand (not in every call).
1. ~~**Should `show` include the task's markdown body?**~~ **Resolved**: Yes. The `FileSource` provides `rawFiles` in `SourceResult`, and the `show` operation returns the full markdown body. This decision is locked in by the TaskSource design (ADR-005).
2. **Should `cost` accept `--format json`?** The CLI supports JSON output for programmatic consumption. Since the plugin returns to an agent (not a script), markdown is always appropriate. JSON output is out of scope.
3. **Future hook: task status injection?** Open-memory injects context percentage into the system prompt. Could open-tasks inject a brief task summary ("3 pending, 1 in-progress, 2 blocked")? This would require reading tasks on every message, which is cheap for small task sets but could be noisy. Defer to v2.
4. **Future: taskgraph-aware execution?** Open-coordinator's `swarm`/`spawn` operations take arrays of task names but have no dependency awareness. A natural integration would let `taskgraph({op: "parallel"})` feed directly into coordinator's `swarm` — each parallel group becomes a wave of worktrees. Similarly, the built-in `task` tool's prompt could be enriched with dependency context from `taskgraph`. Both are v2+ concerns.
5. **Should `TaskSource.load()` throw or capture errors in `SourceResult.errors`?** Per-file errors (malformed YAML, invalid schema) are captured in `errors`. Infrastructure errors (permission denied on the directory, disk failure) are thrown. This distinction needs to be documented in the `TaskSource` interface contract.
## References
- `@alkdev/taskgraph` API surface: see [`@alkdev/taskgraph` docs/architecture/api-surface.md](https://git.alk.dev/alkdev/taskgraph_ts) or the local clone at `/workspace/@alkdev/taskgraph_ts/docs/architecture/api-surface.md`
- `@alkdev/taskgraph` README: local clone at `/workspace/@alkdev/taskgraph_ts/README.md`
- open-memory architecture: `/workspace/@alkdev/open-memory/docs/architecture.md` (reference implementation for the registry pattern)
- open-memory tools.ts: `/workspace/@alkdev/open-memory/src/tools.ts` (reference for handler pattern)
- OpenCode `task` tool research: [../research/opencode-task-tool-deep-dive.md](../research/opencode-task-tool-deep-dive.md)
- open-coordinator research: [../research/open-coordinator-deep-dive.md](../research/open-coordinator-deep-dive.md)
- SDD process: [../sdd_process.md](../sdd_process.md)
- OpenCode plugin SDK: `@opencode-ai/plugin` npm package

View File

@@ -0,0 +1,827 @@
# Research: Open Coordinator Plugin — Deep Dive
## Metadata
| Field | Value |
|-------|-------|
| **Plugin** | `@alkdev/open-coordinator` v2.1.0 |
| **Repository** | `git@alk.dev:alkimiadev/open-coordinator` (fork of `0xSero/open-trees`) |
| **Source Path** | `/workspace/@alkimiadev/open-coordinator/` |
| **License** | MIT OR Apache-2.0 (dual) |
| **Runtime** | Bun, TypeScript (ESM, strict) |
| **Linter** | Biome |
| **Key Dependency** | `@opencode-ai/plugin` ^1.1.3, `jsonc-parser` ^3.3.1 |
| **Research Date** | 2026-04-28 |
---
## 1. Plugin Structure
### 1.1 Source Tree
```
src/
├── index.ts # Plugin entry point + hooks
├── tools.ts # Single `worktree` tool definition
├── registry.ts # Handler dispatch — operations, role detection, routing
├── state.ts # Session-to-worktree state persistence (state.json)
├── config.ts # Config path helpers (XDG_CONFIG_HOME)
├── git.ts # Git command runner + porcelain parser
├── paths.ts # Worktree path resolution + branch name normalization
├── format.ts # Output formatting (tables, errors, commands)
├── result.ts # ToolResult type (ok/error union)
├── sdk.ts # OpenCode SDK response unwrapping
├── status.ts # Git status porcelain parser
├── session-helpers.ts # TUI session helpers (openSessions, updateTitle)
├── opencode-config.ts # JSONC config manipulation (for `add` CLI)
├── cli.ts # Standalone CLI: `open-coordinator add`
├── worktree.ts # Core worktree CRUD (create, remove, prune, merge)
├── worktree-session.ts # Session creation: start, open, fork
├── worktree-spawn.ts # Async spawn with per-task prompts
├── worktree-swarm.ts # Batch worktree+session creation
├── worktree-status.ts # Per-worktree git status
├── worktree-dashboard.ts # Aggregated dashboard (state + git + sessions)
├── worktree-helpers.ts # Shared helpers (pathExists, findWorktreeMatch, etc.)
└── detection/
├── index.ts # SSE subscription + event loop + stall detection
├── types.ts # Types: SessionMetrics, AnomalyType, thresholds
├── heuristics.ts # Detection rules: model degradation, errors, stalls
├── metrics.ts # Per-session metric tracking + updates
└── notify.ts # Coordinator notification via session.promptAsync
```
### 1.2 Architecture Overview
The plugin follows the **single-tool registry pattern** (same as `@alkdev/open-memory`):
```
LLM calls: worktree({action: "spawn", args: {tasks: ["auth", "db"]}})
|
v
registry.ts: route(action, args, context)
|
v
handlers[action] <-- Record<string, Handler>
|
v
handlers.spawn(args, context) --> returns string
```
The key architect files and their responsibilities:
| File | Responsibility |
|------|---------------|
| `index.ts` | Plugin factory: creates tools, starts detection, sets up hooks |
| `tools.ts` | Defines the single `worktree` tool schema + calls registry |
| `registry.ts` | 17 operation handlers, role detection (`detectRole`), routing (`route`) |
| `state.ts` | JSON file I/O for `state.json`, session mapping CRUD |
| `git.ts` | Shell execution via `ctx$` for git commands |
| `worktree*.ts` | Implementation of each operation category |
| `detection/` | Real-time anomaly monitoring via SSE |
### 1.3 Build System
```bash
bun run build # bun build src/index.ts src/cli.ts → dist/ + tsc declarations
bun run typecheck # tsc --noEmit
bun run lint # biome check .
bun run format # biome format --write .
bun run test # bun test
```
---
## 2. Tool Definition
### 2.1 Single Tool: `worktree`
Defined in `src/tools.ts`:
```typescript
export const createTools = (ctx: PluginInput): Record<string, ToolDefinition> => ({
worktree: tool({
description: "Worktree coordinator: manage git worktrees, sessions, and communication...",
args: {
action: z.string().describe("Operation name: help, list, status, dashboard, ..."),
args: z.record(z.string(), z.unknown()).optional().describe("Arguments for the operation."),
},
async execute(input, context) {
const role = await detectRole(context.sessionID);
return route(input.action, (input.args as Record<string, unknown>) ?? {}, {
ctx,
sessionID: context.sessionID,
role,
});
},
}),
});
```
Key design decisions:
- **One tool** rather than 17 separate tools (reduces agent context bloat)
- **`action` field** selects the operation (help, list, spawn, etc.)
- **`args` field** is a loose `Record<string, unknown>` — no per-operation schema validation
- **Role detection** happens on every invocation, not cached
### 2.2 Operations (17 total)
**Coordinator operations (16 accessible)**:
| Operation | Handler File | Description |
|-----------|-------------|-------------|
| `help` | registry.ts | Full reference or per-operation help |
| `list` | worktree.ts | List git worktrees |
| `status` | worktree-status.ts | Git status per worktree |
| `dashboard` | worktree-dashboard.ts | Aggregated state+git+session view |
| `create` | worktree.ts | Create worktree branch + checkout |
| `start` | worktree-session.ts | Worktree + fresh session |
| `open` | worktree-session.ts | Session in existing worktree |
| `fork` | worktree-session.ts | Worktree + forked session (parentID) |
| `swarm` | worktree-swarm.ts | Batch worktree+session creation |
| `spawn` | worktree-spawn.ts | Async per-task worktree+session+prompt |
| `message` | registry.ts (inline) | Send message to spawned session |
| `notify` | registry.ts (inline) | Report to coordinator session |
| `sessions` | registry.ts (inline) | Query spawned session status |
| `abort` | registry.ts (inline) | Abort a session + cleanup |
| `cleanup` | registry.ts → worktree.ts | Remove/prune/merged cleanup |
| `merge` | worktree.ts | Merge worktree branch into target |
**Implementation-only operations (4)**:
| Operation | Description |
|-----------|-------------|
| `help` | Filtered help |
| `current` | Show session's worktree mapping |
| `notify` | Send message to coordinator |
| `status` | Show worktree git status |
### 2.3 Registry / Dispatch Pattern
```typescript
// src/registry.ts
type Handler = (args: ToolArgs, hctx: HandlerContext) => Promise<HandlerResult>;
type HandlerContext = {
ctx: PluginInput;
sessionID?: string;
role: "coordinator" | "implementation";
};
const COORDINATOR_OPS = new Set([
"help", "list", "status", "dashboard", "create", "start", "open", "fork",
"swarm", "spawn", "message", "notify", "sessions", "abort", "cleanup", "merge", "current"
]);
const IMPLEMENTATION_OPS = new Set(["help", "current", "notify", "status"]);
export const detectRole = async (sessionID?: string) => {
if (!sessionID) return "coordinator";
const entry = await findSessionEntry(sessionID);
if (entry?.parentSessionID) return "implementation";
return "coordinator";
};
export const route = async (action, args, hctx) => {
const handler = handlers[action];
if (!handler) return `Unknown operation: ${action}. Call worktree({action: "help"})...`;
if (!isOpAllowed(action, hctx.role)) return formatError(`Operation "${action}" not available...`);
try { return await handler(args, hctx); }
catch (err) { return `Error in ${action}: ${err.message}`; }
};
```
Key points:
- Role detection is **per-invocation** via `findSessionEntry(sessionID)` — looks up `state.json`
- If session has `parentSessionID``implementation` role (limited operations)
- If no sessionID or no parentSessionID → `coordinator` role (all operations)
- Unknown actions return help suggestion, not errors
- Handler exceptions are caught and returned as strings
---
## 3. Worktree Orchestration
### 3.1 How Worktrees Are Created
The core creation path (in `worktree.ts`):
```typescript
export const createWorktreeDetails = async (ctx, options) => {
const repoRoot = getRepoRoot(ctx); // ctx.worktree
const name = options.name?.trim() ?? "";
const branch = options.branch || normalizeBranchName(name);
const base = options.base?.trim() || "HEAD";
const worktreePath = options.path
? resolveWorktreePath(repoRoot, options.path)
: defaultWorktreePath(repoRoot, branch); // <repo>/.worktrees/<branch>
// Validate branch name
await runGit(ctx, ["check-ref-format", "--branch", branch], { cwd: repoRoot });
// Check if branch exists
const branchExists = await runGit(ctx, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
const args = branchExists.ok
? ["worktree", "add", worktreePath, branch] // existing branch
: ["worktree", "add", "-b", branch, worktreePath, base]; // new branch from base
await runGit(ctx, args, { cwd: repoRoot });
return { branch, worktreePath, base, command, branchExists };
};
```
Git commands executed:
- `git rev-parse --show-toplevel` (via `ctx.worktree``getRepoRoot`)
- `git worktree list --porcelain` (list existing)
- `git check-ref-format --branch <name>` (validate branch name)
- `git show-ref --verify --quiet refs/heads/<branch>` (check existence)
- `git worktree add [-b <branch>] <path> [<base>]` (create)
- `git worktree remove [--force] <path>` (delete)
- `git worktree prune [--dry-run]` (cleanup stale refs)
- `git status --porcelain` (check dirty state)
- `git stash --include-untracked` / `git stash pop` (merge safety)
- `git checkout <target>` / `git merge <branch>` (merge flow)
- `git branch -d/-D <branch>` (branch cleanup)
- `git push <remote> --delete <branch>` (remote branch cleanup)
### 3.2 Path Resolution
```typescript
// Default: <repo>/.worktrees/<branch>
defaultWorktreePath(repoRoot, branch) = path.join(repoRoot, ".worktrees", branch)
// Relative paths resolved under .worktrees/ (prevents traversal)
resolveWorktreePath(repoRoot, "feat/auth") /repo/.worktrees/feat/auth
// Absolute paths accepted as-is
resolveWorktreePath(repoRoot, "/custom/path") /custom/path
// Traversal blocked
resolveWorktreePath(repoRoot, "../escape") Error
```
### 3.3 Branch Name Normalization
```typescript
normalizeBranchName("Feature Auth Setup") "feature-auth-setup"
// lowercases, replaces spaces/underscores with "-",
// removes non-alphanumeric (except ./-), collapses multiple dashes
```
### 3.4 Removal Safety
- `removeWorktree` refuses dirty worktrees unless `force: true`
- Checks `git status --porcelain` before removal
- `cleanup` with `action: "merged"` lists branches merged into HEAD and deletes local branches
- Optional remote branch deletion with `remote: true`
---
## 4. Taskgraph CLI Usage
### 4.1 Finding: No Taskgraph CLI Usage
**There is zero usage of the Rust `taskgraph` CLI or any task-related dependency analysis in the open-coordinator plugin.** Comprehensive searches confirmed:
- No imports of `@alkdev/taskgraph`
- No invocations of `taskgraph` CLI binary
- No reading of task markdown files with frontmatter
- No dependency analysis, critical path, or decomposition logic
- No concept of task files, task IDs, or task dependencies
### 4.2 What "Tasks" Mean in Open Coordinator
In open-coordinator, the word "task" appears in two contexts, both of which refer to **human-provided string labels for worktree naming**, not structured task objects:
1. **`swarm`** — `tasks: string[]` parameter: An array of names used to derive branch names (e.g., `["auth-setup", "db-schema"]` → branches `wt/auth-setup`, `wt/db-schema`)
2. **`spawn`** — `tasks: string[]` parameter: Same naming convention, plus optional `prompt` template with `{{task}}` substitution
The `task` field in state entries is a simple string label:
```typescript
// state.ts
type WorktreeSessionEntry = {
worktreePath: string;
branch: string;
sessionID: string;
parentSessionID?: string;
task?: string; // ← Just a label, not a taskgraph object
status?: SessionStatus; // "active" | "completed" | "failed" | "aborted"
createdAt: string;
completedAt?: string;
};
```
### 4.3 What the AGENTS.md in open-tasks Says
The open-tasks AGENTS.md states:
> **open-coordinator** (`worktree`): git worktree orchestration, session spawning, anomaly detection
And:
> open-coordinator currently **presumes using the Rust taskgraph CLI**
This appears to be an **aspirational/planned integration** that does not yet exist in the open-coordinator codebase. The AGENTS.md in open-coordinator itself makes no reference to taskgraph.
---
## 5. Spawn vs Fork Concept
### 5.1 Session Creation Modes
There are **three distinct session creation APIs**, each with different semantics:
| Operation | Creates Worktree? | Creates Session? | Session Type | ParentID? | Prompt? |
|-----------|-------------------|------------------|-------------|-----------|---------|
| `start` | Yes (or reuse) | Yes | Fresh (`session.create`) | No | No |
| `fork` | Yes (or reuse) | Yes | Forked (`session.create` with `parentID`) | Yes | No |
| `spawn` | Yes | Yes | Fresh (`session.create` with `parentID`) | Yes | Yes (template) |
### 5.2 `start` — Fresh Session
```typescript
// worktree-session.ts: startWorktreeSession
const sessionResponse = await ctx.client.session.create({
query: { directory: target.worktreePath }, // workdir set to worktree
body: { title: `wt:${target.branch}` }, // NO parentID
});
// → Creates independent session, no context from coordinator
```
### 5.3 `fork` — Forked Session (Context Inheritance)
```typescript
// worktree-session.ts: forkWorktreeSession
const createResponse = await ctx.client.session.create({
query: { directory: target.worktreePath },
body: { title, parentID: sessionID }, // Inherits coordinator's context
});
// → Session starts with coordinator's conversation history
```
### 5.4 `spawn` — Fresh Session + Async Prompt (The Coordination Mode)
```typescript
// worktree-spawn.ts: spawnWorktrees
// 1. Create worktree for each task
// 2. Create session with parentID (for hierarchy tracking)
const createResponse = await ctx.client.session.create({
query: { directory: worktreeResult.result.worktreePath },
body: { title, parentID: parentSessionID }, // Hierarchy tracking
});
// 3. Store session mapping with parentSessionID + task
await storeSessionMapping({
worktreePath, branch, sessionID,
parentSessionID, // ← This determines "implementation" role
task: rawTask, // ← Task name stored in state
status: "active",
});
// 4. Send initial prompt if template provided
if (options.prompt) {
const promptText = substituteTemplate(options.prompt, rawTask);
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
parts: [{ type: "text", text: promptText }],
...(options.agent && { agent: options.agent }),
...(effectiveModel && { model: effectiveModel }),
},
});
}
```
### 5.5 Model Inheritance
Spawned sessions can inherit the coordinator's model:
```typescript
// worktree-spawn.ts: resolveCoordinatorModel
// Reads the coordinator's last assistant message to extract modelID + providerID
const response = await ctx.client.session.messages({ path: { id: coordinatorSessionID }, query: { limit: 20 } });
// Walks messages backwards to find the last assistant message with model info
```
This allows spawned sessions to use the same model as the coordinator by default, with an optional `model` override:
```typescript
model: { providerID: "anthropic", modelID: "claude-4-sonnet" }
```
### 5.6 The Coordination Logic
The coordination flow is:
1. **Coordinator** calls `spawn` with task names + prompt template
2. Plugin creates worktree + session + stores state mapping with `parentSessionID`
3. Plugin sends initial prompt to each session via `session.promptAsync`
4. Sessions run asynchronously in background
5. **Detection module** monitors sessions via SSE for anomalies
6. If anomalies detected → notifications sent to coordinator via `session.promptAsync`
7. Implementation sessions can `notify` coordinator using their `parentSessionID`
8. Coordinator can `message` any session, or `abort` stuck sessions
### 5.7 Swarm vs Spawn
| Feature | `swarm` | `spawn` |
|---------|---------|---------|
| Task names | Array of strings | Array of strings |
| Branch prefix | Configurable (default `wt/`) | Configurable (default `wt/`) |
| Initial prompt | None | Template with `{{task}}` substitution |
| Agent selection | None | Optional `agent` field |
| Model selection | Inherits coordinator | Inherits coordinator or explicit `model` |
| Session type | `session.create` with `parentID` | `session.create` with `parentID` |
| Open sessions UI | Optional `openSessions` flag | Not available |
| Error recovery | Skip on failure, continue | Clean up worktree+branch on failure |
---
## 6. Integration with OpenCode
### 6.1 Plugin API Usage
The plugin uses these OpenCode SDK/plugin interfaces:
```typescript
// Plugin factory
const OpenCoordinatorPlugin: Plugin = async (ctx) => {
// ctx: PluginInput
// ctx.client: OpencodeClient (session, tui, app, global APIs)
// ctx.worktree: string (repo root path)
// ctx.project: { id, path }
// ctx.directory: string
// ctx.session: { id }
// ctx.$: ShellExecutor (for git commands)
// ctx.$.cwd(path): Scoped ShellExecutor
};
```
### 6.2 OpenCode Client APIs Used
| API | Usage |
|-----|-------|
| `ctx.client.session.create()` | Create sessions (start, open, fork, swarm, spawn) |
| `ctx.client.session.promptAsync()` | Send messages/prompts to sessions (spawn, message, notify) |
| `ctx.client.session.abort()` | Abort a session (abort operation) |
| `ctx.client.session.get()` | Get session info (dashboard) |
| `ctx.client.session.messages()` | Get session messages (resolve coordinator model) |
| `ctx.client.session.update()` | Update session title |
| `ctx.client.tui.openSessions()` | Open sessions UI panel |
| `ctx.client.app.log()` | Log messages to OpenCode |
| `ctx.client.global.event()` | SSE event stream (detection) |
| `ctx.$` / `ctx.$.cwd()` | Execute shell commands (git) |
### 6.3 Plugin Hooks Registered
```typescript
// index.ts
return {
tool: createTools(ctx), // Register worktree tool
event: async ({ event }) => { // Event handler
// On session.deleted → remove state mappings + metrics
},
"tool.execute.before": async (input, output) => {
// Auto-inject workdir for bash commands when session mapped to worktree
if (input.tool === "bash" && input.sessionID) {
const entry = await findSessionEntry(input.sessionID);
if (entry && !output.args.workdir) {
output.args.workdir = entry.worktreePath;
}
}
},
"shell.env": async (input, output) => {
// Inject OPENCODE_WORKTREE_PATH + OPENCODE_WORKTREE_BRANCH env vars
if (input.sessionID) {
const entry = await findSessionEntry(input.sessionID);
if (entry) {
output.env.OPENCODE_WORKTREE_PATH = entry.worktreePath;
output.env.OPENCODE_WORKTREE_BRANCH = entry.branch;
}
}
},
"experimental.session.compacting": async (_input, output) => {
// Custom compaction prompt for spawned sessions
output.prompt = `You are compacting your own session...`;
},
};
```
Key hooks:
- **`tool.execute.before`**: Intercept bash commands → auto-set workdir to worktree path
- **`shell.env`**: Set environment variables in spawned session shells
- **`experimental.session.compacting`**: Custom prompt for context compaction
- **`event`**: Listen for `session.deleted` → cleanup state + metrics
### 6.4 Starting Detection on Plugin Load
```typescript
// index.ts
const OpenCoordinatorPlugin: Plugin = async (ctx) => {
const _detectionController = startDetection(ctx);
// ... reconciliation, hooks
};
```
The detection module starts an SSE event stream immediately on plugin load and runs indefinitely until the AbortController is triggered.
---
## 7. Configuration
### 7.1 Plugin Config
**Minimal configuration.** The plugin accepts no runtime config — it's just added to the plugin list:
```json
{
"plugin": ["@alkdev/open-coordinator"]
}
```
There's no config schema like open-tasks' TypeBox-validated config. The only external config is:
- **State file**: `~/.config/opencode/open-coordinator/state.json` (or `${XDG_CONFIG_HOME}/opencode/open-coordinator/state.json`)
- **Environment variable**: `XDG_CONFIG_HOME` for config directory override
### 7.2 CLI Config Helper
The `src/cli.ts` provides a standalone installer:
```bash
bunx open-coordinator add
# → Updates opencode.json to add the plugin
# → Supports --config, --plugin, --dry-run flags
```
Uses `jsonc-parser` to safely modify the OpenCode JSONC config file.
### 7.3 State File Format
```json
{
"entries": [
{
"worktreePath": "/repo/.worktrees/wt-auth-setup",
"branch": "wt/auth-setup",
"sessionID": "ses_abc123",
"parentSessionID": "ses_coordinator456",
"task": "auth-setup",
"status": "active",
"createdAt": "2026-04-28T10:00:00.000Z",
"completedAt": null
}
]
}
```
State operations are all file-based with atomic write (write to temp + rename):
```typescript
// state.ts: writeState
const tmpPath = path.join(tmpdir(), `open-coordinator-state-${Date.now()}-${random}.tmp`);
await writeFile(tmpPath, content);
await rename(tmpPath, statePath);
```
### 7.4 Detection Thresholds (Not Configurable)
```typescript
// detection/types.ts
DEFAULT_THRESHOLDS = {
toolErrorThreshold: 5, // >5 tool errors → HIGH_ERROR_COUNT
malformedToolThreshold: 1, // Any malformed tool → MODEL_DEGRADATION
stallThresholdMs: 60_000, // 60s no activity while busy → SESSION_STALL
stallCheckIntervalMs: 30_000, // Check every 30s
}
```
---
## 8. Task-Related Logic (or Lack Thereof)
### 8.1 No Task Files, Dependencies, or Analysis
The open-coordinator plugin has **no concept of**:
- Task files (YAML frontmatter markdown)
- Task IDs or task metadata
- Task dependencies (`dependsOn`)
- Dependency graphs
- Critical path analysis
- Risk/impact/scope assessments
- Decomposition guidance
- Parallel group computation
- Bottleneck detection
- Workflow cost estimation
### 8.2 What It Has Instead
The closest concept to "tasks" in open-coordinator:
1. **Task names as labels**: The `swarm` and `spawn` operations accept `tasks: string[]` which are used purely as branch name stems. Example: `"auth-setup"` → branch `wt/auth-setup`.
2. **Task labels in state**: A `task?: string` field stored per session mapping, used for:
- Dashboard display
- Notify message prefixes (`[auth-setup] Done!`)
- Anomaly notification formatting
3. **Prompt template substitution**: The `spawn` operation supports `prompt: "Your task: {{task}}"` which substitutes the task name into the prompt text.
4. **Sequential task processing**: `swarm` and `spawn` process tasks **sequentially** in a `for` loop — no parallelism, no dependency awareness, no ordering optimization.
### 8.3 Potential Integration Points for open-tasks
If open-tasks were to combine concepts with open-coordinator, the natural integration points would be:
1. **Task → Worktree Mapping**: Read task files from `tasks/` directory, use the task ID and metadata (scope, risk, dependencies) to drive worktree creation decisions
2. **Dependency-Aware Scheduling**: Use `@alkdev/taskgraph`'s `parallelGroups()` and `criticalPath()` to determine which tasks can be spawned in parallel vs. sequentially
3. **Decomposition-Guided Splits**: Use `shouldDecomposeTask()` to decide whether a task should be split before spawning
4. **Risk-Aware Priority**: Use task risk/impact levels to influence spawn order and model assignment
5. **Status Propagation**: Task status (pending → in-progress → completed) could be synced with session status (active → completed/failed)
---
## 9. Detection & Monitoring System
### 9.1 Architecture
```
Plugin Load → startDetection(ctx)
|
v
SSE Stream (ctx.client.global.event)
|
v
handleEvent(ctx, event, thresholds)
|
├── Extract sessionID from event
├── Check if spawned session (lookup in state.json)
├── Update SessionMetrics (tool errors, malformed tools, activity time)
├── checkAllAnomalies(metrics, thresholds)
└── If anomalies → notifyCoordinator(parentSessionID, ...)
+ setInterval (stall detection every 30s)
|
v
For each session in sessionMetrics Map:
checkAllAnomalies → detect SESSION_STALL
If stall → notifyCoordinator
```
### 9.2 Anomaly Types
| Type | Detection | Severity | Notification Action |
|------|-----------|----------|-------------------|
| `MODEL_DEGRADATION` | `tool === "tool"` in SSE events (malformed tool calls) | High | Suggests abort |
| `HIGH_ERROR_COUNT` | >5 tool errors in session | Medium | Suggests checking session |
| `SESSION_STALL` | No activity for 60s while `busy` | Medium | Suggests "please continue" message |
### 9.3 Notification Format
```
⚠️ ANOMALY DETECTED [wt/auth-setup]
Session: ses_abc123
Branch: wt/auth-setup
Issue: SESSION_STALL (medium severity)
No activity detected while session is busy.
Consider sending: "There was an error, please continue."
Run: worktree({action: "message", args: {sessionID: "ses_abc123", message: "please continue"}})
```
### 9.4 Known Issues
From `docs/known-issues.md`:
- SSE reconnection can cause listener accumulation (fixed with AbortController + sseMaxRetryAttempts: 15)
- `setInterval` for stall detection was not cleared on shutdown (fixed with abort listener)
- `sessionMetrics` Map grew unbounded (fixed with cleanup on session.deleted events)
---
## 10. Key Architectural Patterns & Design Decisions
### 10.1 Result Type Pattern
All operations return `ToolResult = { ok: true; output: string } | { ok: false; error: string }`:
```typescript
// result.ts
export type ToolResult = { ok: true; output: string } | { ok: false; error: string };
export const ok = (output: string): ToolResult => ({ ok: true, output });
export const err = (error: string): ToolResult => ({ ok: false, error });
```
Every handler returns a string — no structured data, no JSON. This maximizes LLM readability.
### 10.2 Git Command Execution
All git commands go through `runGit()`:
```typescript
export const runGit = async (ctx, args, options = {}) => {
const shell = options.cwd ? ctx.$.cwd(options.cwd) : ctx.$;
const result = await shell`git ${args}`.nothrow().quiet();
return { ok: result.exitCode === 0, stdout, stderr, exitCode, command };
};
```
This uses OpenCode's shell executor (`ctx.$`) which provides sandboxed execution.
### 10.3 Session Mapping State Machine
Sessions have an implicit state machine:
```
active → completed (notify with level="info")
active → failed (notify with level="blocking")
active → aborted (abort operation)
```
The state file tracks `status` and `completedAt` but these are advisory — the OpenCode session lifecycle is the real authority.
### 10.4 Reconciliation on Startup
```typescript
// index.ts
const reconcileResult = await reconcileState();
// → Reads state.json, checks if each worktreePath still exists on disk
// → Removes orphaned entries (worktrees deleted outside the plugin)
```
### 10.5 Cleanup on Session Deletion
```typescript
// index.ts event handler
event: async ({ event }) => {
const sessionID = getDeletedSessionId(event);
if (!sessionID) return;
deleteSessionMetrics(sessionID); // Remove from detection Map
await removeSessionMappings(sessionID); // Remove from state.json
}
```
---
## 11. Comparison: open-coordinator vs open-tasks
| Aspect | open-coordinator | open-tasks |
|--------|-----------------|------------|
| **Purpose** | Git worktree orchestration + agent session management | Task graph analysis, dependency scheduling, decomposition |
| **Core Library** | None (direct git + OpenCode SDK) | `@alkdev/taskgraph` (graphology-based) |
| **Data Source** | `state.json` (session mappings) | `tasks/` directory (YAML frontmatter markdown) |
| **Single Tool** | `worktree({action, args})` | `tasks({tool, args})` |
| **Registry Pattern** | Yes (17 operations) | Yes (15 operations) |
| **Role System** | Coordinator vs Implementation | No role system |
| **Task Concept** | Simple string labels for branches | Structured task objects with metadata |
| **Dependencies** | None | `dependsOn` graph analysis |
| **Analysis** | Anomaly detection (SSE) | Critical path, parallel groups, bottlenecks, risk |
| **Config** | None (just plugin list) | TypeBox-validated config with source options |
| **State** | File-based (`state.json`) | Task files on disk |
| **Detection** | Real-time SSE monitoring | No monitoring |
---
## 12. Recommendations for Integration
### 12.1 Complementary, Not Overlapping
Open-coordinator and open-tasks are **complementary**: one manages git worktrees + sessions, the other manages task analysis + scheduling. They don't compete — they fill different roles in the alk.dev trio.
### 12.2 Integration Opportunities
1. **Task-aware spawn**: open-tasks could provide `parallelGroups()` output to open-coordinator's `spawn` operation, creating worktrees in dependency order rather than arbitrary order.
2. **Risk-based model assignment**: Tasks with `risk: high` could automatically use more capable models in spawned sessions.
3. **Status propagation**: When a spawned session completes, open-tasks could update the task file's `status` field from `pending` to `completed`.
4. **Decomposition → swarm**: When `shouldDecomposeTask()` returns true, open-coordinator could automatically split a task into sub-tasks for parallel work.
5. **Critical path awareness**: open-tasks' `criticalPath()` could inform open-coordinator about which tasks to prioritize for spawn ordering.
### 12.3 What open-coordinator Does NOT Need from open-tasks
- Role-based access (already implemented differently)
- Tool dispatch pattern (already shares same pattern)
- Anomaly detection (already has its own)
- State persistence (different domain — sessions vs. task graphs)
---
## References
- Source: `/workspace/@alkimiadev/open-coordinator/src/` (all files read in full)
- Architecture doc: `/workspace/@alkimiadev/open-coordinator/ARCHITECTURE.md`
- AGENTS.md: `/workspace/@alkimiadev/open-coordinator/AGENTS.md`
- README: `/workspace/@alkimiadev/open-coordinator/README.md`
- Known issues: `/workspace/@alkimiadev/open-coordinator/docs/known-issues.md`
- RESEARCH.md (historical): `/workspace/@alkimiadev/open-coordinator/RESEARCH.md`
- Tests: `/workspace/@alkimiadev/open-coordinator/tests/`

View File

@@ -0,0 +1,775 @@
# Research: OpenCode `task` Tool — Deep Dive
## Objective
Understand OpenCode's built-in `task` tool and related subagent/permission infrastructure in detail, to evaluate how our `@alkdev/open-tasks` plugin (taskgraph analysis) can combine with or extend the built-in task tool.
---
## 1. Tool Definition and Parameters
**File**: `/workspace/opencode/packages/opencode/src/tool/task.ts` (166 lines)
### Parameters Schema
```typescript
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z.string()
.describe("This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)")
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
```
| Parameter | Type | Required | Description |
|---|---|---|---|
| `description` | string | yes | Short 3-5 word description of the task |
| `prompt` | string | yes | Full task instructions for the subagent |
| `subagent_type` | string | yes | Which specialized agent to spawn (e.g., `general`, `explore`, custom agents) |
| `task_id` | string | no | Resume an existing subagent session instead of creating a new one |
| `command` | string | no | The slash command that triggered this task (if applicable) |
### Execution Flow
The tool's `execute` method has this flow:
1. **Fetch config** (`Config.get()`)
2. **Permission check** — Skip if `ctx.extra?.bypassAgentCheck` is true (used for slash commands and `@agent` invocations), otherwise call `ctx.ask()` with `permission: "task"`, `patterns: [params.subagent_type]`, `always: ["*"]`
3. **Agent lookup**`Agent.get(params.subagent_type)` throws if unknown
4. **Permission inheritance** — Determine inherited permissions:
- If the agent does NOT have `task` permission → deny `task: *` on the spawned session
- If the agent does NOT have `todowrite` permission → deny `todowrite: *` on the spawned session
- Also add permissions from `config.experimental?.primary_tools` as "allow" rules on the session
5. **Session creation** — Either reuse existing session (if `task_id` provided and found) or create a new child session with:
- `parentID: ctx.sessionID` (links child to parent)
- `title: params.description + " (@agent_name subagent)"`
- Permission overrides as determined above
6. **Model resolution** — Use agent's configured model, or fall back to the caller's model
7. **Metadata update** — Set title and metadata on the tool result part
8. **Prompt resolution**`SessionPrompt.resolvePromptParts(params.prompt)` resolves file references and agent references
9. **Subagent execution** — Call `SessionPrompt.prompt()` with:
- The subagent's session ID and model
- `agent: agent.name`
- `tools` dict disabling inherited tools (e.g., `{ todowrite: false, task: false }`)
- The resolved prompt parts
10. **Result extraction** — Extract last text part from result
11. **Return** — Format output as:
```
task_id: <session_id> (for resuming to continue this task if needed)
<task_result>
<extracted text>
</task_result>
```
### Key Code Snippet — Tool Filtering by Agent Permissions
```typescript
// Lines 66-96 of task.ts
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
const session = await iife(async () => {
if (params.task_id) {
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
...(hasTodoWritePermission ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(hasTaskPermission ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", action: "allow" as const, permission: t })) ?? []),
],
})
})
```
### Key Code Snippet — Tool Disabling
```typescript
// Lines 138-141 of task.ts
tools: {
...(hasTodoWritePermission ? {} : { todowrite: false }),
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
```
This means subagents spawned by the `task` tool **cannot spawn their own subagents by default** (task is denied) unless the subagent has explicit `task` permission. This is a critical recursive-prevention mechanism.
---
## 2. Description / Prompt (task.txt)
**File**: `/workspace/opencode/packages/opencode/src/tool/task.txt` (60 lines)
### Full Text
```
Launch a new agent to handle complex, multistep tasks autonomously.
Available agent types and the tools they have access to:
{agents}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When to use the Task tool:
- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py")
When NOT to use the Task tool:
- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match quickly
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match quickly
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match quickly
- Other tasks that are not related to the agent descriptions above
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session.
3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask you for it first. Use your judgement.
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
<example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a significant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_descriptions>
<example>
user: "Please write a function that checks if a number is prime"
assistant: Sure let me write a function that checks if a number is prime
assistant: First let me use the Write tool to write a function that checks if a number is prime
...
</example>
```
### Dynamic `{agents}` Placeholder
The `{agents}` placeholder is replaced at tool initialization time with a sorted list of available non-primary agents:
```typescript
// Lines 29-36 of task.ts
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
```
This means the description shown to the LLM **filters agents by the current agent's permissions** — if the calling agent has `task: { "explore": "deny" }`, the `explore` agent won't appear in the list.
---
## 3. How Subagents Work
### Subagent Session Creation
When `task` is invoked, a subagent session is created with:
- **`parentID`**: Set to the current session's ID, creating a parent-child relationship
- **`title`**: `description + " (@agent_name subagent)"`
- **`permission`**: Merged rules that disable `task` and `todowrite` by default, plus any `primary_tools` config
### Subtask Handling in prompt.ts
**File**: `/workspace/opencode/packages/opencode/src/session/prompt.ts` (lines 553-741)
The `handleSubtask` function manages the subagent execution lifecycle:
1. Creates an **assistant message** in the **parent session** (not the subagent session) with `mode: task.agent`
2. Creates a **tool part** on that message marking the task tool as running
3. Triggers `plugin.trigger("tool.execute.before", ...)` for tool observability
4. Validates that the requested agent exists
5. Calls `taskTool.execute(taskArgs, ctx)` where ctx has `bypassAgentCheck: true`
6. On completion, updates the tool part status to `"completed"` or `"error"`
7. If the task was triggered by a command, adds a synthetic user message: "Summarize the task tool output above and continue with your task."
### The `@agent` Shortcut
When a user types `@agent_name` in their message, the system creates a `SubtaskPart`:
```typescript
// MessageV2.SubtaskPart schema
export const SubtaskPart = PartBase.extend({
type: z.literal("subtask"),
prompt: z.string(),
description: z.string(),
agent: z.string(),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }).optional(),
command: z.string().optional(),
})
```
In `resolvePart` (line 1238+), agent parts check permissions:
```typescript
if (part.type === "agent") {
const perm = Permission.evaluate("task", part.name, ag.permission)
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
return [
{ ...part, messageID: info.id, sessionID: input.sessionID },
{
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: " Use the above message and context to generate a prompt and call the task tool with subagent: " + part.name + hint,
},
]
}
```
### Context Inheritance
The subagent receives:
- The same project directory and worktree
- A fresh session (with parent reference)
- The agent's configured model and system prompt
- The prompt text passed to the task tool (resolved for file/agent references)
- Permission restrictions (no task recursion, no todowrite unless allowed)
The subagent does **NOT** inherit the parent's conversation history — it starts fresh unless `task_id` is provided to resume an existing session.
---
## 4. Tool Registration
**File**: `/workspace/opencode/packages/opencode/src/tool/registry.ts` (224 lines)
### Tool List Order
```typescript
// Lines 118-138
return [
InvalidTool,
...(question ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool, // <-- Built-in task tool
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
...custom, // <-- Plugin/tools come AFTER built-ins
]
```
### Plugin Tool Registration
**Lines 64-86**: Plugin tools are wrapped via `fromPlugin()`:
```typescript
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
}
```
### How Plugins Are Loaded
Two sources of custom tools:
1. **File-based tools** (lines 88-101): Scanned from `{tool,tools}/*.{js,ts}` in config directories
2. **Plugin-provided tools** (lines 103-108): From `plugin.tool` entries registered by loaded plugins
### There Is NO Deduplication or Override Mechanism
**Critical finding**: The tool list is built as `[..., built-in tools, ...custom]` with **no deduplication by ID**. Looking at the `register` function (lines 141-148):
```typescript
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
const s = yield* InstanceState.get(state)
const idx = s.custom.findIndex((t) => t.id === tool.id)
if (idx >= 0) {
s.custom.splice(idx, 1, tool) // Replace existing custom tool
return
}
s.custom.push(tool)
})
```
This only deduplicates within the `custom` array. **There is no mechanism for a plugin tool to override a built-in tool of the same name.** If a plugin registers a tool with `id: "task"`, it would appear as a second tool alongside the built-in `TaskTool`.
However, when the `tools()` method builds the final list (lines 157-195), it processes all tools and calls `tool.init()` for each. The AI SDK then uses these tools by their `id` field. Since `tool.id` is used as the key in the AI SDK tool map, and JavaScript maps use last-write-wins semantics, **the last tool added with a given ID will be the one that the AI SDK uses**.
Looking at lines 436-474 of prompt.ts:
```typescript
for (const item of yield* registry.tools(...)) {
// ...
tools[item.id] = tool({...}) // Last write wins!
}
```
Since plugin tools come **after** built-in tools in the array, a plugin tool with `id: "task"` would actually **override** the built-in task tool in the final tool map! The OpenCode documentation's claim that "if a plugin tool has the same name as a built-in tool, the plugin tool takes priority" is effectively correct, but the mechanism is just array ordering + last-write-wins in a JS object, not explicit deduplication.
### Verification
Actually, let me re-examine. The AI SDK uses `tool()` and stores tools in an object keyed by `id`:
```typescript
// Line 441
tools[item.id] = tool({
id: item.id as any,
// ...
})
```
Since items are iterated in order and `[...builtIn, ...custom]`, **a plugin tool with the same `id` as a built-in tool will overwrite the built-in** in the `tools` object. This confirms: **a plugin CAN shadow the built-in `task` tool**.
---
## 5. Permissions
### Permission Schema (config.ts)
**Lines 416-446**:
```typescript
export const Permission = z
.preprocess(
permissionPreprocess,
z.object({
__originalKeys: z.string().array().optional(),
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(), // <-- Task permission
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(), // Simple allow/deny/ask
question: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
doom_loop: PermissionAction.optional(),
skill: PermissionRule.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction),
)
.transform(permissionTransform)
```
### Permission Types
```typescript
// Simple: just "allow", "deny", or "ask"
export const PermissionAction = z.enum(["ask", "allow", "deny"])
// Complex: pattern-based rules
export const PermissionRule = z.union([PermissionAction, PermissionObject])
// where PermissionObject = z.record(z.string(), PermissionAction)
// e.g., { "explore": "allow", "*": "ask" }
```
### How `task` Permission Works
The `task` permission uses `PermissionRule`, meaning it supports both simple and pattern-based forms:
- `"task": "allow"` — Allow all subagent types
- `"task": "deny"` — Deny all subagent types
- `"task": { "explore": "allow", "*": "ask" }` — Allow `explore` agent, ask for others
### Permission Evaluation (evaluate.ts)
**File**: `/workspace/opencode/packages/opencode/src/permission/evaluate.ts` (15 lines)
```typescript
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
```
Key behavior:
- Rules are evaluated with **last-match-wins** (via `findLast`)
- **Default is `ask`** — if no rule matches, the user is prompted
- `Wildcard.match` supports glob patterns like `*`
### Task Permission in Practice
In `task.ts` (lines 52-60):
```typescript
await ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
```
This asks permission with:
- `permission: "task"` — The permission category
- `patterns: [params.subagent_type]` — The specific agent name (e.g., "explore")
- `always: ["*"]` — If the user says "always allow", the recorded rule will allow all patterns
The `ask()` method (permission/index.ts lines 166-201):
1. Flattens all rulesets (agent permissions + session permissions + approved persistent permissions)
2. Evaluates each pattern against the merged ruleset
3. If any pattern has `action: "deny"` → throws `DeniedError`
4. If all patterns have `action: "allow"` → proceeds silently
5. If any pattern has `action: "ask"` → prompts the user, creating a pending request
6. If user says "always" → records `{ permission, pattern: "*", action: "allow" }` to persistent storage
### Agent-Specific Permission Filtering
In `task.ts` (lines 29-36), the description dynamically filters agents:
```typescript
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
```
This means if agent X has `task: { "general": "deny" }`, the LLM won't even see `general` in the agent list when running as agent X.
---
## 6. Plugin Tool Override Capability
### Can Our Plugin Shadow the Built-in `task` Tool?
**Yes, absolutely.** Here's the evidence:
1. **Tool registration** (`registry.ts` line 137): Built-in tools come first, then `...custom` (plugin tools) are appended
2. **Tool resolution** (`prompt.ts` line 441): Tools are stored in a JS object `tools[item.id] = tool(...)`, which is **last-write-wins**
3. **No explicit deduplication**: The `register()` method only deduplicates within `custom`, not against built-ins
**Therefore**: If `@alkdev/open-tasks` registers a `tool` entry with `id: "task"`, it will overwrite the built-in `TaskTool` in the AI SDK's tool map.
### The Plugin Hook Alternative
Instead of shadowing, plugins can also use the `tool.definition` hook to modify built-in tool definitions:
```typescript
// From @opencode-ai/plugin Hooks type
"tool.definition"?: (input: { toolID: string }, output: {
description: string;
parameters: any;
}) => Promise<void>;
```
This hook is called in `resolveTools` (prompt.ts line 484):
```typescript
for (const item of yield* registry.tools(...)) {
// ...
const output = {
description: next.description,
parameters: next.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: item.id }, output)
// output may be mutated by plugins
}
```
This means a plugin could modify the `task` tool's description and parameters **without replacing it entirely**.
### Plugin Tool Interface
**File**: `/workspace/opencode/.opencode/node_modules/@opencode-ai/plugin/dist/tool.d.ts`
```typescript
type ToolContext = {
sessionID: string;
messageID: string;
agent: string;
directory: string;
worktree: string;
abort: AbortSignal;
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void;
ask(input: AskInput): Promise<void>;
};
type AskInput = {
permission: string;
patterns: string[];
always: string[];
metadata: { [key: string]: any };
};
export function tool<Args extends z.ZodRawShape>(input: {
description: string;
args: Args;
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<string>;
}): { description: string; args: Args; execute: (...) => Promise<string> };
export type ToolDefinition = ReturnType<typeof tool>;
```
Key difference from built-in tools: Plugin `execute()` returns just a `string`, not the `{ title, metadata, output }` object that built-in tools return. The registry wraps this in `fromPlugin()` to adapt.
### Important Limitation for Plugin Tools
Plugin tools receive a `ToolContext` that has `directory` and `worktree` — but they do **NOT** have access to the full `sessionID` context or ability to create sub-sessions. They are fundamentally simpler than built-in tools.
---
## 7. The `todowrite` Tool
**File**: `/workspace/opencode/packages/opencode/src/tool/todo.ts` (31 lines)
### Implementation
```typescript
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
Todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: { todos: params.todos },
}
},
})
```
### Todo Schema
**File**: `/workspace/opencode/packages/opencode/src/session/todo.ts`
```typescript
export const Info = z.object({
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
})
```
### Key Characteristics
- **Session-scoped**: Todos are stored per session in the database (SQLite via Drizzle)
- **Permission-controlled**: Requires `todowrite` permission; default is `"ask"` but many agents have it set to `"deny"`
- **Flat list**: No hierarchy, no dependencies between todos
- **Replaced on update**: `Todo.update()` does a DELETE + INSERT (delete all existing todos for the session, then insert the new list with positional ordering)
- **Status values**: `pending`, `in_progress`, `completed`, `cancelled`
- **Priority values**: `high`, `medium`, `low`
### todowrite.txt Description
The description (167 lines) instructs the LLM to use `todowrite` for complex multistep tasks with 3+ steps. Key guidelines:
1. Create todos for multistep/complex tasks
2. Mark tasks `in_progress` when starting (limit to one at a time)
3. Mark `completed` immediately after finishing
4. Cancel irrelevant tasks
5. Do NOT use for trivial single-step tasks
### How `todowrite` Differs from Our `tasks` Tool
| Feature | `todowrite` | `@alkdev/open-tasks` |
|---|---|---|
| Structure | Flat list | Graph (DAG with dependencies) |
| Persistence | Session-scoped SQLite | Markdown files with YAML frontmatter |
| Dependencies | None | `dependsOn` field |
| Analysis | None | Critical path, parallel groups, bottlenecks, risk analysis |
| Lifecycle | Within session only | Cross-session, version-controllable |
| Schema fields | `content`, `status`, `priority` | `id`, `name`, `status`, `dependsOn`, `scope`, `risk`, `impact`, `level` |
| Permission | `todowrite` (simple action) | N/A (file-based, no permission needed) |
---
## 8. Agent System
**File**: `/workspace/opencode/packages/opencode/src/agent/agent.ts` (420 lines)
### Built-in Agents
| Agent | Mode | Description | Key Permissions |
|---|---|---|---|
| `build` | primary | Default agent, executes tools based on permissions | Full access + `question: allow` + `plan_enter: allow` |
| `plan` | primary | Plan mode, disallows all edit tools | Full read + `question: allow` + `plan_exit: allow`, `edit: deny` |
| `general` | subagent | General-purpose research/execution | Default minus `todowrite: deny` |
| `explore` | subagent | Fast codebase exploration | Read-only: `grep, glob, list, bash, webfetch, websearch, codesearch, read` allowed; everything else denied |
| `compaction` | primary | Hidden, for context compaction | `*: deny` (no tools) |
| `title` | primary | Hidden, generates session titles | `*: deny` (no tools) |
| `summary` | primary | Hidden, generates summaries | `*: deny` (no tools) |
### Agent Configuration Schema
```typescript
export const Agent = z.object({
model: ModelId.optional(),
variant: z.string().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(), // @deprecated
disable: z.boolean().optional(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]).optional(),
hidden: z.boolean().optional(),
options: z.record(z.string(), z.any()).optional(),
color: z.string().optional(),
steps: z.number().int().positive().optional(),
permission: Permission.optional(),
}).catchall(z.any())
```
### Agent Modes
- **`primary`**: Can be used as the main agent in a session (like `build` or `plan`)
- **`subagent`**: Can only be used via the `task` tool (like `general` or `explore`)
- **`all`**: Can function in both roles (default for custom agents)
### Agent Resolution Flow
1. Built-in agents are hard-coded in `agent.ts`
2. User config (`opencode.json`) can override built-in agent properties or define new agents via `agent` field
3. Agent `.md` files from `.opencode/agent/` directories are loaded via `ConfigMarkdown.parse()`
4. Disabled agents (`disable: true`) are removed from the list
### The `@agent` Subtask Mechanism
When a user types `@explore some question`, the system:
1. Creates a `SubtaskPart` with `{ type: "agent", name: "explore" }`
2. Resolves it to a text part: "Use the above message and context to generate a prompt and call the task tool with subagent: explore"
3. The primary agent then calls the `task` tool with `subagent_type: "explore"` and the prompt
4. This creates a child session and runs the explore agent in it
### Maximum Steps
Agents have a `steps` property that limits the number of agentic iterations:
```typescript
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
```
When `isLastStep` is true, a `MAX_STEPS` prompt is injected: "You have reached the maximum number of steps for this agent. Please provide your final response now without making any additional tool calls."
---
## 9. Implications for `@alkdev/open-tasks`
### What We Can Do
1. **Register as a separate tool** called `tasks` (plural) alongside the built-in `task` (singular). This is the **safest approach** — no conflict, both tools coexist.
2. **Shadow the built-in `task` tool** by registering a plugin tool with `id: "task"`. This would replace the built-in subagent spawning mechanism entirely. **This is probably not what we want** — the subagent system is deeply integrated with sessions, permissions, and the UI.
3. **Use the `tool.definition` hook** to modify the built-in `task` tool's description to reference taskgraph analysis. This would make the LLM aware of our plugin without replacing anything.
4. **Combine approaches**: Register as `tasks` (our analysis tool) and use the `tool.definition` hook to enhance the `task` tool's description to mention available task analysis from `tasks`.
### What We Should NOT Do
- **Replace the `task` tool**: It's deeply wired into the session/subagent system. Replacing it would break `@agent` mentions, slash commands, and the entire subagent orchestration.
- **Conflict with `todowrite`**: Our plugin operates on a different paradigm (graph-structured markdown files vs. session-scoped flat list). They serve complementary purposes.
### Recommended Architecture
```
User interacts with OpenCode
LLM sees two tools:
- `task` (built-in) — Spawns subagents for delegation
- `tasks` (plugin) — Analyzes task graph, shows dependencies, etc.
LLM can:
- Use `tasks({ tool: "list" })` to see all tasks and their status
- Use `tasks({ tool: "critical" })` to find the critical path
- Use `task({ subagent_type: "general", prompt: "..." })` to delegate work
- Use `todowrite({ todos: [...] })` for session-level progress tracking
```
This architecture is clean because:
- `task` = delegation ("who should do this work?")
- `tasks` = analysis ("what work needs to be done and in what order?")
- `todowrite` = progress tracking ("what am I working on right now?")
---
## File Index
| File | Path | Lines | Purpose |
|---|---|---|---|
| Task tool | `/workspace/opencode/packages/opencode/src/tool/task.ts` | 166 | Subagent spawning tool definition |
| Task description | `/workspace/opencode/packages/opencode/src/tool/task.txt` | 60 | System prompt for LLM about when to use task tool |
| Todo tool | `/workspace/opencode/packages/opencode/src/tool/todo.ts` | 31 | Session-scoped todo list tool |
| Todo description | `/workspace/opencode/packages/opencode/src/tool/todowrite.txt` | 167 | System prompt for LLM about when to use todowrite |
| Todo model | `/workspace/opencode/packages/opencode/src/session/todo.ts` | 57 | Todo data model (content, status, priority) |
| Tool registry | `/workspace/opencode/packages/opencode/src/tool/registry.ts` | 224 | Tool registration and resolution |
| Tool base | `/workspace/opencode/packages/opencode/src/tool/tool.ts` | 92 | Tool interface and `define()` helper |
| Agent system | `/workspace/opencode/packages/opencode/src/agent/agent.ts` | 420 | Agent definitions, config merging, generation |
| Agent CLI | `/workspace/opencode/packages/opencode/src/cli/cmd/agent.ts` | 245 | CLI for creating/listing agents |
| Config schema | `/workspace/opencode/packages/opencode/src/config/config.ts` | ~2000 | Full config schema including permissions, agents |
| Permission index | `/workspace/opencode/packages/opencode/src/permission/index.ts` | 322 | Permission system: ask/reply/evaluate/merge |
| Permission evaluate | `/workspace/opencode/packages/opencode/src/permission/evaluate.ts` | 15 | Wildcard-based rule evaluation (last-match-wins) |
| Permission schema | `/workspace/opencode/packages/opencode/src/permission/schema.ts` | 17 | PermissionID newtype |
| Permission arity | `/workspace/opencode/packages/opencode/src/permission/arity.ts` | 163 | Bash command arity dictionary |
| Session prompt | `/workspace/opencode/packages/opencode/src/session/prompt.ts` | 1906 | Main prompt/session loop, handleSubtask, resolveTools |
| Plugin system | `/workspace/opencode/packages/opencode/src/plugin/index.ts` | 281 | Plugin loading and hook infrastructure |
| Plugin types | `@opencode-ai/plugin` (node_modules) | ~258 | ToolDefinition, Hooks, Plugin interface |
---
## References
- OpenCode repository: `/workspace/opencode`
- Plugin SDK type definitions: `@opencode-ai/plugin` package
- Our project: `/workspace/@alkdev/open-tasks`