diff --git a/docs/migration-to-agend-terminal.md b/docs/migration-to-agend-terminal.md index d630efd2..c1a11d41 100644 --- a/docs/migration-to-agend-terminal.md +++ b/docs/migration-to-agend-terminal.md @@ -231,11 +231,231 @@ channel: ## Backend invocation diff {#backend-invocation-diff} -*(Phase B — covers: `--mcp-config` vs Rust equivalent, `--append-system-prompt-file` flow, per-backend env-var injection, MCP server respawn semantics, fleet-instructions delivery channel — see also `docs/fleet-instructions-injection.md` on the TS side for the post-#55 model.)* +The biggest day-one surprise on migration is that the way each backend CLI is launched, fed instructions, and signalled is materially different between the two daemons. This section gives you the full diff so a fleet that worked under TS keeps working under Rust. + +### Two invocation models + +| Aspect | `@suzuke/agend` (TS) | `agend-terminal` (Rust) | +|---|---|---| +| Spawn surface | `tmux new-window ` per backend, with backend-specific shell quoting in `src/backend/.ts` | Direct PTY (`openpty` on Unix, ConPTY on Windows) per pane, command + args resolved from a static `BackendPreset` | +| Backend abstraction | One TypeScript class per backend implementing `CliBackend` (`buildCommand`, `writeConfig`, `getReadyPattern`, `getStartupDialogs`, …) | One enum variant + one `BackendPreset` struct in `src/backend.rs` | +| Renderer | None — pane is whatever tmux shows | Built-in vterm/Ratatui pane in the daemon's TUI; same byte stream is what the agent sees | +| Cross-platform target | macOS / Linux only (tmux dependency) | macOS / Linux / Windows (ConPTY), `which::which` honors `PATHEXT` so `claude.cmd` / `codex.ps1` resolve on Windows | +| New backend variants | Five fixed: `claude-code`, `opencode`, `gemini-cli`, `codex`, `kiro`, plus `mock` (E2E only) | Same five, plus `Backend::Shell` (generic `$SHELL`) and `Backend::Raw(path)` (any executable) — both with no preset wiring | + +The TS daemon delegates almost everything to the per-backend class; the Rust daemon centralizes everything in one preset table that every spawn path reads from. Practically that means: in TS, behavior tweaks for a backend land in `src/backend/.ts`; in Rust, they land as a field on `BackendPreset` and ripple to every call site automatically. + +### Per-backend invocation matrix + +The shape of each backend's command line is preserved. The wrapper around it changed. + +| Backend | TS invocation summary | Rust invocation summary | +|---|---|---| +| **Claude Code** | `claude --settings --mcp-config --dangerously-skip-permissions [--resume ] [--model ] [--append-system-prompt-file ]` from `src/backend/claude-code.ts:17-45`. Pre-approves `ANTHROPIC_API_KEY` in `~/.claude.json` before spawn. | `claude --dangerously-skip-permissions [--continue]`, plus `--append-system-prompt-file` and `--mcp-config` injected via `Backend::spawn_flags` when those files exist (`src/backend.rs:411-426`). Resume strategy: `ResumeMode::ContinueInCwd { flag: "--continue" }`. | +| **OpenCode** | `opencode [--session ] [--continue] [--model ]`. MCP wired via `opencode.json:mcp.` written into the working directory; instructions delivered through the `opencode.json:instructions` array pointing at `/fleet-instructions.md` (`src/backend/opencode.ts:14-73`). | `opencode [--continue]`. Resume: `ContinueInCwd { flag: "--continue" }`. **Behavior change:** instructions now land in the workspace `AGENTS.md` via marker-merge, **not** in a per-instance file referenced from `opencode.json`. See the instructions-injection sub-section below. | +| **Codex** | `codex resume --last [--dangerously-bypass-approvals-and-sandbox \| --full-auto] [-c model=""]`. MCP registered via global `~/.codex/config.toml` (`codex mcp add ` shell calls). Trust pre-approved by appending `[projects.""]` to `~/.codex/config.toml`. | `codex resume --last --dangerously-bypass-approvals-and-sandbox` on resume; `codex --dangerously-bypass-approvals-and-sandbox` (no `resume --last`) on fresh start (`fresh_args` field). Resume: `ResumeMode::NotSupported` (Codex's resume is positional, not a flag, so it lives in `args`). | +| **Gemini CLI** | `gemini --yolo [--resume latest] [--model ]`. MCP registered in `/.gemini/settings.json:mcpServers.`. Trust pre-approved via `~/.gemini/trustedFolders.json`. | `gemini --yolo`, with `ResumeMode::Fixed { args: &["--resume", "latest"] }` appending the resume flags. | +| **Kiro CLI** | `kiro-cli chat --trust-all-tools [--resume] [--model ] --require-mcp-startup`. MCP wired through a per-server **wrapper script** at `/mcp-wrapper-.sh` (mode `0o700`) that exports env vars before exec'ing the real MCP binary — works around Kiro ignoring the `env` block in `mcp.json`. | `kiro-cli chat --trust-all-tools [--resume]`. Resume: `ContinueInCwd { flag: "--resume" }`. The MCP wrapper-script workaround is gone in Rust because `mcp_config.rs` writes the env to disk in a form Kiro reads. | + +### Resume strategy diff + +TS keeps a session id per instance and re-attaches by id (`--resume `, `--session `) when one is on disk. Rust does not track session ids — it uses CLI-native "continue most recent in cwd" semantics, with one variant per backend: + +- `ResumeMode::ContinueInCwd { flag }` — Claude (`--continue`), OpenCode (`--continue`), Kiro (`--resume`). +- `ResumeMode::Fixed { args: &[..] }` — Gemini (`--resume latest`). +- `ResumeMode::NotSupported` — Codex (resume is the `resume` subcommand, baked into `args`). + +This works because Rust always spawns each agent in a unique working directory (auto-worktree for git repos), so "most recent session in cwd" maps 1:1 to the instance's own session. + +There is one rough edge: when a Claude pane is opened but never used, `claude --continue` errors out ("No conversation found to continue"). The daemon would catch this via crash-respawn, but the failure briefly flashes into the pane before recovery. `Backend::has_resumable_session(working_dir)` (Claude only, in `src/backend.rs`) walks `~/.claude/projects//*.jsonl` to detect "metadata-only" sessions and downgrades `Resume` → `Fresh` up front so the user never sees the failure flash. Other backends return `true` optimistically and rely on crash-respawn. + +### Instructions injection — `nativeInstructionsMechanism` mapping + +Bug #55 (PR #56) introduced the three-value `nativeInstructionsMechanism` field on the TS `CliBackend` interface. The Rust daemon does not expose this name; the equivalent mechanism is encoded in three `BackendPreset` fields: `instructions_path`, `instructions_shared`, `inject_instructions_on_ready`. Mapping: + +| Backend | TS `nativeInstructionsMechanism` (post-#55) | Rust equivalent | TS file location | Rust file location | +|---|---|---|---|---| +| `claude-code` | `append-flag` (`--append-system-prompt-file`) | `instructions_path = ".claude/agend.md"`, `shared = false`, `inject_on_ready = false`. Flag injected by `Backend::spawn_flags`. | `/fleet-instructions.md` | `/.claude/agend.md` (under `.claude/` but **not** `.claude/rules/` — explicit to avoid Claude double-loading) | +| `opencode` | `append-flag` (`opencode.json:instructions`) | `instructions_path = "AGENTS.md"`, `shared = true`, `inject_on_ready = false`. Marker-merge into the workspace `AGENTS.md`. | `/fleet-instructions.md` (referenced from workspace `opencode.json`) | `/AGENTS.md` (workspace project doc) | +| `gemini-cli` | `project-doc` (`GEMINI.md`) | `instructions_path = "GEMINI.md"`, `shared = true`, `inject_on_ready = false`. Marker-merge. | `/GEMINI.md` | `/GEMINI.md` | +| `codex` | `project-doc` (`AGENTS.md`) | `instructions_path = "AGENTS.md"`, `shared = true`, `inject_on_ready = false`. Marker-merge with the 32 KiB Codex limit unchanged. | `/AGENTS.md` | `/AGENTS.md` | +| `kiro` | `project-doc` (`.kiro/steering/agend-.md`) | `instructions_path = ".kiro/steering/agend.md"`, `shared = false`, `inject_on_ready = true`. Rust no longer relies on `.kiro/steering/*.md` auto-loading and instead **types the file's contents into the pane as the first user message** once Ready fires. | `/.kiro/steering/agend-.md` (per-instance file) | `/.kiro/steering/agend.md` (single file per workdir) **and** injected on Ready | +| `mock` | `none` (MCP `instructions` capability fallback) | n/a — no mock backend in Rust; use `Backend::Shell` for E2E tests. | n/a | n/a | + +Three behavior changes worth flagging during migration: + +1. **OpenCode now writes a workspace project doc (`AGENTS.md`).** TS kept fleet instructions in `/fleet-instructions.md` so users never saw an artefact. Rust treats OpenCode the same as Codex. If your repos commit `AGENTS.md`, expect the marker block to appear in the diff; if your `.gitignore` excludes `AGENTS.md`, no behavior change. +2. **Kiro switched from per-instance to per-workdir file naming.** TS wrote `.kiro/steering/agend-.md`; Rust writes `.kiro/steering/agend.md`. If two Kiro instances share a working directory under TS, they each had their own file; under Rust they share one — and Rust normally avoids this collision by giving each instance a unique worktree. +3. **Kiro instructions are now typed in as a user message.** TS `src/backend/kiro.ts:81` wrote the file with a comment claiming auto-load; the Rust team's empirical investigation found `.kiro/steering/*.md` is an IDE-only feature that the standalone CLI does not read, so Rust has the daemon paste the file's contents into the pane on Ready (`inject_instructions_on_ready = true`). Operationally this means the instructions occupy chat history rather than a system prompt slot; long custom prompts will eat context tokens at startup. PR #55 already neutralized the duplicate-injection risk on the MCP side, so there is no double-cost here. (If the TS comment was correct after all and Kiro CLI does auto-load, the migration outcome is identical — instructions still reach the model — but the channel changes from passive auto-load to active first-message inject.) + +The Bug #55 daemon-side gate (drop the five fleet-context env vars and set `AGEND_DISABLE_MCP_INSTRUCTIONS=1` whenever `nativeInstructionsMechanism !== 'none'`) lives in `src/daemon.ts:1022-1039` on the TS side. Rust does not duplicate this gate at the same layer; it gates earlier, by simply not constructing an `instructions` capability response when a backend's preset writes a file. The observable invariant — *the model never sees fleet context twice* — holds in both daemons. + +### Signal and ESC byte semantics + +The transport — does a key/byte make it from the daemon to the agent's PTY — is verified for the four backends below. The semantics — does the agent then *do* the right thing when it receives ESC or SIGINT — are tracked separately in `src/backend_harness.rs` as a per-backend capability matrix. Sprint 11 of the Rust project will run real-CLI verification; until then, the table below uses the **`pending`** marker as required by §3.5.8. + +| Backend | PTY byte transport (ESC `0x1b`, Ctrl-C `0x03`) | `interrupt` MCP tool semantics (ESC stops LLM turn) | `tool_kill` MCP tool semantics (SIGINT to fg pgid) | +|---|---|---|---| +| `kiro-cli` | `True` (proven in `verify_byte_delivery`) | `pending` (Sprint 11) | `pending` (Sprint 11) | +| `codex` | `True` | `pending` | `pending` | +| `claude` | `False` — set by the explicit Claude branch in `record_transport_results` at `src/backend_harness.rs:71-74` (initial value for every backend is `Unverified` at line 56; Claude is downgraded to `False` because "LLM context not tied to PTY buffer (known gap)" — note text at line 50) | `pending` | `pending` | +| `gemini` | `True` | `pending` | `pending` | +| `opencode` | not yet in the harness matrix (`Backend::all()` returns it; the matrix init only seeds the four above) — `pending` | `pending` | `pending` | + +What is concretely guaranteed today in Rust: + +- **Process-tree termination.** `process::kill_process_tree(pid)` (`src/process.rs`) sends `SIGTERM` to the process group, sleeps 500 ms, then sends `SIGKILL` unconditionally. Windows falls back to `TerminateProcess`. This applies to instance shutdown, replace, and crash recovery — not to mid-turn interruption. +- **ESC byte injection.** The `interrupt` MCP tool (`src/mcp/handlers.rs:969-991`) writes `0x1b` to the target agent's PTY via the daemon API. Whether the model on the other end interprets ESC as "stop generation" is the `pending` cell above. +- **SIGINT to foreground process group.** The `tool_kill` MCP tool (`src/mcp/handlers.rs:994-1031`) walks `tcgetpgrp` to find the pane's foreground pgid and sends `SIGINT`. Unix only — on Windows the tool returns `{"error": "tool_kill is only supported on Unix (Linux/macOS)"}` rather than silently no-op'ing. + +The TS daemon ships none of `interrupt`, `tool_kill`, `kill_process_tree`-style group kill, or a capability matrix at all. Cancellation in TS is whatever the backend's own quit command does (`/exit`, `/quit`, `exit`) plus the OS-level termination of the tmux pane. + +### What this means for migration + +- If you script invocations directly (e.g. spawn the binary yourself outside the daemon), only Codex changed shape (resume is now the subcommand it always was; the wrapper that passed `--resume ` is gone). +- If you commit `AGENTS.md` or `GEMINI.md` to the repo, expect a marker block to appear once you migrate. Adding `` markers to your `.gitignore` glob is not necessary — the marker block is content, not a separate file. +- If you have Kiro-specific tooling that reads `.kiro/steering/agend-.md`, switch to `.kiro/steering/agend.md`. +- If you depended on the TS MCP `instructions` capability fallback for the mock backend, port your E2E tests to use `Backend::Shell` and inject instructions via the `task` MCP tool flow. ## MCP tool API diff {#mcp-tool-api-diff} -*(Phase B — covers: full inventory of MCP tools (~20 in TS full set), name renames, argument shape changes, return-value diffs, broadcast `cost_limited` field carry-over, deferred or removed tools.)* +This is the single biggest migration topic per the Sprint 0 review and dev-lead's HIGH-FRICTION call. The diff is not a rename — Rust *splits* what TS treated as one undifferentiated communication surface into three coordination tracks, plus adds tools that have no TS counterpart. + +### Three-track coordination model + +``` + ┌───────────────── 1. work ─────────────────┐ + │ task (work board: create / claim / in_progress / verified / done) +agent ──────────┤ + │ send_to_instance, broadcast, delegate_task, request_information, report_result + ├──────── 2. comms (push/pull) ─────────────┤ + │ inbox, describe_message, describe_thread (pull side, Rust-only) + │ set_waiting_on, clear_blocked_reason (presence side, Rust-only) + │ + └─────── 3. scope freeze ───────────────────┘ + post_decision, list_decisions, update_decision +``` + +Why three tracks? In TS the agent's MCP toolbox treated all of "do work", "tell another agent", and "decide policy" as essentially the same thing — different message shapes routed through `outboundHandlers` in `src/outbound-handlers.ts`. The Rust daemon enforces a clearer layering for the same reason `git` separates the index, the working tree, and the object store: a tool whose job is to **freeze a scope decision** has different correctness invariants than one whose job is to **deliver one message** or **claim one task**, and conflating them made it impossible to reason about ordering or recovery (see `FLEET-DEV-PROTOCOL-v1.md` §1, §2 for the protocol-level argument). + +The practical effect for an agent is: + +- **Work board (`task`)** is the single source of truth for "is this work done". Status transitions `claimed → in_progress → verified → done` are rejected if you skip a state. +- **Comms** still uses `send_to_instance` / `broadcast` for push, but adds **pull** (`inbox`) and **presence** (`set_waiting_on`) so an agent can resume after a restart without losing pending mail. +- **Decisions (`post_decision`)** are the only mechanism that *binds future scope*. A reviewer who finds a scope violation cites a decision id; a violator cannot retroactively claim "we never decided that". + +### Tools that exist in both daemons + +These tools have the same name and shape across the two daemons; the diffs are in their input/output schemas and in the surrounding lifecycle. Skim the table for "schema diff", read the per-tool sub-sections only when you actually use that tool. + +| Tool | TS schema location | Rust schema location | Schema diff | +|---|---|---|---| +| `reply` | `src/outbound-schemas.ts:ReplyArgs` | `src/mcp/tools.rs:channel_tools` | none | +| `react` | `ReactArgs` | `channel_tools` | none | +| `edit_message` | `EditMessageArgs` | `channel_tools` | none | +| `download_attachment` | `DownloadAttachmentArgs` | `channel_tools` | none | +| `send_to_instance` | `SendToInstanceArgs` | `comm_tools` | Rust adds optional `thread_id`, `parent_id` for thread tracking. Both accept `request_kind ∈ {query, task, report, update}`. | +| `delegate_task` | `DelegateTaskArgs` | `comm_tools` | Rust adds `task_id`, `thread_id`, `parent_id`, `force` + `force_reason` (replaces deprecated `interrupt` + `reason`), `second_reviewer` + `second_reviewer_reason` for protocol §3.5 dual-review. | +| `report_result` | `ReportResultArgs` | `comm_tools` | Rust adds `reviewed_head` (git SHA at review time, surfaced in metadata), `thread_id`, `parent_id`. | +| `request_information` | `RequestInformationArgs` | `comm_tools` | none | +| `broadcast` | `BroadcastArgs` | `comm_tools` | none. Both exclude `report` from `request_kind` — broadcasts can't carry a per-correlation report. | +| `list_instances` | `ListInstancesArgs` | `instance_tools` | TS supports `tags` filter (`src/outbound-schemas.ts:146-148`); Rust takes no parameters (`src/mcp/tools.rs` `inputSchema.properties: {}`). If you relied on tag-based listing, drop the filter on migration and post-filter the result client-side, or use a `team` (`create_team` / `update_team`) for routing. | +| `create_instance` | `CreateInstanceArgs` | `instance_tools` | Rust adds `team` + `count` (homogeneous teams), `backends` (heterogeneous teams), `layout` ∈ `{tab, split-right, split-below}`, `target_pane`, `task` (initial task injected after spawn). The TUI-aware fields (`layout`, `target_pane`) have no TS analogue. | +| `delete_instance` | `DeleteInstanceArgs` | `instance_tools` | none | +| `replace_instance` | `ReplaceInstanceArgs` | `instance_tools` | none | +| `start_instance` | `StartInstanceArgs` | `instance_tools` | none | +| `describe_instance` | `DescribeInstanceArgs` | `instance_tools` | Rust returns the additional fields `waiting_on`, `waiting_on_since`, last heartbeat, last_polled_at, dispatch tracking — used by `set_waiting_on` / `report_health` flows below. | +| `set_display_name` | `SetDisplayNameArgs` | `instance_tools` | none | +| `set_description` | `SetDescriptionArgs` | `instance_tools` | none | +| `post_decision` | `PostDecisionArgs` | `decision_tools` | none | +| `list_decisions` | `ListDecisionsArgs` | `decision_tools` | none | +| `update_decision` | `UpdateDecisionArgs` | `decision_tools` | none | +| `task` | `TaskBoardArgs` | `task_tools` | **Status enum extended.** TS: `open / claimed / done / blocked / cancelled`. Rust: `open / claimed / in_progress / blocked / verified / done / cancelled`. Adds `due_at`, `duration` for deadlines. The new `in_progress` and `verified` states encode protocol §10.3 three-state completion (`in_progress` → `verified` → `done`). | +| `create_team` | `CreateTeamArgs` | `team_tools` | Rust adds `orchestrator` (must be a member; receives team-level routing). | +| `update_team` | `UpdateTeamArgs` | `team_tools` | Rust adds `orchestrator` (re-elect orchestrator). | +| `list_teams` / `delete_team` | as above | `team_tools` | none | +| `create_schedule` | `CreateScheduleArgs` | `schedule_tools` | **Trigger split.** TS: cron expression only. Rust: either `cron` (recurring) **or** `run_at` (ISO 8601 one-shot) — mutually exclusive. One-shots auto-disable after firing. | +| `list_schedules` / `update_schedule` / `delete_schedule` | as above | `schedule_tools` | `update_schedule` accepts either trigger field; supplying either replaces the trigger kind. | +| `deploy_template` / `teardown_deployment` / `list_deployments` | `*Args` | `deploy_tools` | none | +| `checkout_repo` / `release_repo` | `*Args` | `repo_tools` | none | + +### Tools added in Rust (no TS counterpart) + +These are the eleven tools you will likely care about most when porting an existing `@suzuke/agend` agent prompt. They are listed by the track they sit in. + +#### Comms — pull side + +| Tool | Purpose | Why it matters for migration | +|---|---|---| +| `inbox` | Drain pending inbound messages addressed to this instance. Returns `{messages: [...]}` and emits `AgentPickedUp` events on Telegram-bound bindings (✅ reaction per pickup). | Under TS, every cross-instance message arrives directly into the pane via tmux; restarting an agent meant losing whatever was mid-flight. Under Rust the inbox persists, so an agent that crashed mid-task can recover its mail by calling `inbox` on resume. | +| `describe_message` | Look up an inbox message status by ID — returns `ReadAt` (with timestamp), `UnreadExpired`, or `NotFound`. Optional `instance` argument scopes the lookup. | Lets a sender confirm whether the recipient picked up a specific message before retrying. TS had no equivalent — you guessed from radio silence. | +| `describe_thread` | Get all messages in a conversation thread, ordered by timestamp. Optional `instance` argument scopes to a specific recipient inbox. | Lets you reconstruct a multi-hop coordination trace (impl → reviewer → impl …) after the fact. Pair with the `thread_id` / `parent_id` fields now present on `send_to_instance` / `delegate_task` / `report_result`. | + +#### Comms — presence and process control + +| Tool | Purpose | Why it matters for migration | +|---|---|---| +| `set_waiting_on` | Declare what this instance is currently blocked on (`condition` string). Empty string clears. Daemon decays stale entries automatically — see `set_waiting_on` handler at `src/mcp/handlers.rs:1033-1063`. | Replaces the TS pattern where agents wrote prose into messages ("I'm waiting on the reviewer"). Now machine-readable; orchestrators can `list_instances` and see who is stuck on what. | +| `clear_blocked_reason` | Force-clear a stale blocking reason without rewriting `waiting_on`. | Used by orchestrators when the blocking condition has been satisfied but the blocked instance hasn't yet noticed (e.g. the reviewer pushed a verdict but the implementer is still spinning). | +| `report_health` | Report own liveness / state to the daemon, used by the heartbeat path. | Replaces TS's implicit "MCP server still attached" liveness signal with an explicit, structured one. | +| `interrupt` | Send ESC byte (`0x1b`) to a target agent's PTY, cancelling current LLM turn. Optional `reason` injected as a follow-up prompt after ESC. Context is preserved; the agent accepts the next prompt. | TS had no way to interrupt a running LLM turn from outside — you had to wait for the timeout or kill the pane. The semantics for whether ESC actually stops generation per backend are `pending` per the §"Signal and ESC byte semantics" sub-section above. | +| `tool_kill` | Send `SIGINT` to a target agent's PTY foreground process group, cancelling an active **tool subprocess** while preserving the agent session. Unix only. Returns `{ok: true, pgid}` on success. | Use when an agent is stuck inside a long-running shell command (`cargo build`, `pytest …`) but you want to keep the agent's chat history. TS had no analogue — the only escape was killing the whole pane and starting fresh. | + +#### TUI control + +| Tool | Purpose | Why it matters for migration | +|---|---|---| +| `move_pane` | Move an instance's pane into a different tab in the daemon TUI. Splits an existing tab's focused pane (or creates a new tab). Preserves scrollback and PTY state. | TS has no TUI to move panes within. If your TS agents called `delete_instance` + `create_instance` to "move" an agent visually, switch to `move_pane` — it preserves session, scrollback, and worktree. | + +#### CI watching + +| Tool | Purpose | Why it matters for migration | +|---|---|---| +| `watch_ci` | Watch GitHub Actions CI for a repo+branch. When CI completes (success / failure / any terminal state), an event is auto-injected into the watching agent's inbox. Honors `GITHUB_TOKEN` for higher rate limits; falls back to unauthenticated polling (60 req/hr fleet-wide) with a `warning` field set. | TS agents polled `gh pr checks --watch` from the shell, which blocked the agent and racked up token consumption. Rust off-loads polling to the daemon and surfaces only the terminal state. | +| `unwatch_ci` | Stop watching CI for a repo. | n/a — paired with `watch_ci`. | + +### Cross-instance comms — the deepest migration friction + +This is the area dev-lead asked for the most depth on, so let me walk a single concrete migration through end-to-end. + +**TS pattern (today):** +``` +agent A: send_to_instance(target='B', message='please review PR #42', request_kind='task') + ↓ TS daemon.routes via outboundHandlers['send_to_instance'] + ↓ Bug #57 cost-guard pre-check (drops if B is over budget) + ↓ targetIpc.send({type: 'fleet_inbound', targetSession: 'B', content, meta: {...}}) + ↓ B's MCP server receives fleet_inbound and types it into B's pane prefixed by [from:A] +agent B (working): sees the message arrive in chat, decides whether to drop current task and respond. +agent B (offline): the message is gone — TS does not persist `fleet_inbound` past the pane buffer. +``` + +**Rust pattern (post-migration):** +``` +agent A: send_to_instance(target='B', message='please review PR #42', request_kind='task', + thread_id='th-pr42', parent_id='m-…') + ↓ Rust daemon routes via mcp/handlers.rs send_to_instance + ↓ writes to B's inbox file under /inbox/.json (durable) + ↓ if B is bound to a Telegram topic, the Telegram sink emits a notification UX event +agent B (working): the next [AGEND-MSG] system reminder includes the message header. B may call inbox to drain. +agent B (offline / restarting): the inbox file persists; on next start, B sees the pending message via inbox. +agent A: can later call describe_message(message_id=…) or describe_thread(thread_id='th-pr42') to confirm pickup. +``` + +What you must change in your prompts and runbooks when migrating: + +1. **Stop assuming push delivery is enough.** Add `inbox` checks at agent startup if the agent has any chance of having queued mail. The Rust daemon already prepends `[AGEND-MSG]` system reminders for new mail, but explicit `inbox` calls are still needed to drain backlog and to claim messages via `AgentPickedUp` events. +2. **Adopt `thread_id` and `parent_id`.** The fleet protocol's coordination patterns (delegate → ack → report; review → finding → re-review) become trivially traceable when threads are linked. TS coordinates the same patterns via prose `correlation_id` strings; Rust still accepts `correlation_id` but adds the structured pair. +3. **Use `set_waiting_on` instead of prose.** "I'm blocked on the reviewer" in chat is unparsable; `set_waiting_on(condition='review from at-dev-4 on PR #63')` is queryable via `describe_instance` and listable across the fleet. +4. **Replace TS-era kill-and-restart patterns with `interrupt` and `tool_kill`.** If your TS prompts say "if the agent is stuck, replace it", switch to "if the agent is mid-LLM-turn, call `interrupt(target=…)`; if the agent is mid-tool-subprocess, call `tool_kill(target=…)`; replace only as a last resort." +5. **Migrate `request_kind: 'report'` flows to use `reviewed_head`.** When you call `report_result` from a reviewer, attach the git SHA at review time. The Rust merge gate (protocol §10.3 / §3 metadata fields) treats this as load-bearing for staleness detection. + +### Tool-set profiles + +TS exposes two profiles via `AGEND_TOOL_SET` (`src/channel/mcp-tools.ts:120-126`): +- `standard`: `reply, react, edit_message, send_to_instance, broadcast, list_instances, describe_instance, list_decisions, post_decision, task, set_display_name, set_description` +- `minimal`: `reply, send_to_instance, list_decisions, download_attachment` + +Rust does not currently expose tool-set profiles in `src/mcp/tools.rs` — every spawned agent sees all 45 tools. **`pending`** verification: if Rust adds a profile mechanism in Sprint 11, agents that relied on TS `minimal` for token-cost reduction may need to re-tune. Until then, treat the Rust toolbox as `full` for prompt-engineering purposes. ## Migration steps {#migration-steps} diff --git a/docs/migration-to-agend-terminal.zh-TW.md b/docs/migration-to-agend-terminal.zh-TW.md index 6e78a1bf..ea336713 100644 --- a/docs/migration-to-agend-terminal.zh-TW.md +++ b/docs/migration-to-agend-terminal.zh-TW.md @@ -229,11 +229,231 @@ channel: ## Backend invocation diff {#backend-invocation-diff} -*(Phase B —— 涵蓋:`--mcp-config` 對應到 Rust 等價物、`--append-system-prompt-file` 流程、per-backend env-var 注入、MCP server respawn 語意、fleet-instructions 傳遞通道 —— TS 端 post-#55 模型也可參考 `docs/fleet-instructions-injection.md`。)* +遷移上線第一天最容易踩到的雷,是兩個 daemon 拉起 backend CLI、餵 instructions、發訊號的方式有實質差異。本節給出完整 diff,讓在 TS 版能跑的 fleet,在 Rust 版也能繼續跑。 + +### 兩種 invocation 模型 + +| 面向 | `@suzuke/agend` (TS) | `agend-terminal` (Rust) | +|---|---|---| +| Spawn 介面 | 每個 backend 透過 `tmux new-window `,shell 引用邏輯散落在 `src/backend/.ts` | 每個 pane 直接走 PTY (Unix 用 `openpty`,Windows 用 ConPTY),command 與 args 從靜態 `BackendPreset` 解析 | +| Backend 抽象 | 每個 backend 一個 TypeScript class,實作 `CliBackend`(`buildCommand`、`writeConfig`、`getReadyPattern`、`getStartupDialogs`…) | 一個 enum variant 配一個 `BackendPreset` struct,集中在 `src/backend.rs` | +| Renderer | 沒有 — pane 內容就是 tmux 顯示的 | daemon TUI 內建 vterm/Ratatui pane;agent 看到的就是這個 byte stream | +| 跨平台目標 | 只支援 macOS / Linux(依賴 tmux) | macOS / Linux / Windows(ConPTY);`which::which` 會看 `PATHEXT`,所以 Windows 上的 `claude.cmd` / `codex.ps1` 可正確解析 | +| Backend 種類 | 五種固定:`claude-code`、`opencode`、`gemini-cli`、`codex`、`kiro`,加上 `mock`(僅 E2E 用) | 同樣五種,外加 `Backend::Shell`(通用 `$SHELL`)與 `Backend::Raw(path)`(任意執行檔)—— 兩者都沒有 preset 配線 | + +TS daemon 把幾乎所有事都委派給 per-backend class;Rust daemon 把所有事集中到一張 preset 表,每條 spawn path 都讀同一張表。實務上這意味著:在 TS 上,調整某個 backend 的行為是動 `src/backend/.ts`;在 Rust 上,是改 `BackendPreset` 的某個欄位,然後所有 call site 自動跟著變。 + +### Per-backend invocation 對照表 + +每個 backend 的 command line 形狀大致保留下來,外部包裝改變了。 + +| Backend | TS invocation 摘要 | Rust invocation 摘要 | +|---|---|---| +| **Claude Code** | `claude --settings --mcp-config --dangerously-skip-permissions [--resume ] [--model ] [--append-system-prompt-file ]`,邏輯在 `src/backend/claude-code.ts:17-45`。Spawn 前會把 `ANTHROPIC_API_KEY` 預先核可寫入 `~/.claude.json`。 | `claude --dangerously-skip-permissions [--continue]`,再透過 `Backend::spawn_flags`(`src/backend.rs:411-426`)在對應檔案存在時注入 `--append-system-prompt-file` 與 `--mcp-config`。Resume 策略:`ResumeMode::ContinueInCwd { flag: "--continue" }`。 | +| **OpenCode** | `opencode [--session ] [--continue] [--model ]`。MCP 透過 working directory 內的 `opencode.json:mcp.` 配置;instructions 走 `opencode.json:instructions` 陣列指向 `/fleet-instructions.md`(`src/backend/opencode.ts:14-73`)。 | `opencode [--continue]`。Resume:`ContinueInCwd { flag: "--continue" }`。**行為變更:** instructions 現在透過 marker-merge 寫入 workspace 的 `AGENTS.md`,**不再**走每實例獨立、由 `opencode.json` 引用的檔案。詳見下方的「Instructions 注入」小節。 | +| **Codex** | `codex resume --last [--dangerously-bypass-approvals-and-sandbox \| --full-auto] [-c model=""]`。MCP 透過全域 `~/.codex/config.toml`(呼叫 `codex mcp add `)註冊;信任授權則 append `[projects.""]` 至同檔。 | Resume 時 `codex resume --last --dangerously-bypass-approvals-and-sandbox`;fresh start 時 `codex --dangerously-bypass-approvals-and-sandbox`(透過 `fresh_args` 欄位拿掉 `resume --last`)。Resume:`ResumeMode::NotSupported`(Codex 的 resume 是子命令而非 flag,所以塞在 `args` 裡)。 | +| **Gemini CLI** | `gemini --yolo [--resume latest] [--model ]`。MCP 註冊在 `/.gemini/settings.json:mcpServers.`;信任透過 `~/.gemini/trustedFolders.json`。 | `gemini --yolo`,搭配 `ResumeMode::Fixed { args: &["--resume", "latest"] }` 補上 resume flags。 | +| **Kiro CLI** | `kiro-cli chat --trust-all-tools [--resume] [--model ] --require-mcp-startup`。MCP 透過每 server 一支 **wrapper script**(`/mcp-wrapper-.sh`,mode `0o700`)—— 這個 wrapper 先 export env,再 exec 實際的 MCP binary,繞過 Kiro 忽略 `mcp.json` 的 `env` block。 | `kiro-cli chat --trust-all-tools [--resume]`。Resume:`ContinueInCwd { flag: "--resume" }`。Rust 拿掉了 wrapper script 的 workaround,因為 `mcp_config.rs` 直接以 Kiro 認得的形式把 env 寫到磁碟。 | + +### Resume 策略 diff + +TS 版每個 instance 維護自己的 session id,靠 `--resume `、`--session ` 重新接上。Rust 版不追蹤 session id —— 用每個 backend 自己的「resume cwd 內最近一次 session」語意,三種 variant: + +- `ResumeMode::ContinueInCwd { flag }` —— Claude (`--continue`)、OpenCode (`--continue`)、Kiro (`--resume`)。 +- `ResumeMode::Fixed { args: &[..] }` —— Gemini (`--resume latest`)。 +- `ResumeMode::NotSupported` —— Codex(resume 是 `resume` 子命令,已經塞在 `args`)。 + +這個策略可行的前提是:Rust 版每個 agent 永遠 spawn 在獨立的 working directory(git repo 自動建 worktree),所以「cwd 最近 session」剛好就 1:1 對應到該實例自己的 session。 + +唯一一個邊界:Claude pane 開了但完全沒用過時,`claude --continue` 會錯誤退出("No conversation found to continue")。雖然 daemon 的 crash-respawn 路徑會接住,但失敗訊息會閃進 pane 後才被覆蓋,看起來像壞掉。`Backend::has_resumable_session(working_dir)`(僅 Claude,位於 `src/backend.rs`)會掃描 `~/.claude/projects//*.jsonl`,偵測到「只有 metadata」的 session 時把 `Resume` 預先降級為 `Fresh`,使用者就看不到那個 flash。其他 backend 樂觀回傳 `true`,倚賴 crash-respawn safety net。 + +### Instructions 注入 — `nativeInstructionsMechanism` 對應 + +Bug #55(PR #56)在 TS 端引入 `CliBackend` interface 上的三值欄位 `nativeInstructionsMechanism`。Rust 沒有同名欄位;對應的機制由 `BackendPreset` 的三個欄位編碼:`instructions_path`、`instructions_shared`、`inject_instructions_on_ready`。對應如下: + +| Backend | TS `nativeInstructionsMechanism`(PR #56 後) | Rust 對應 | TS 檔案位置 | Rust 檔案位置 | +|---|---|---|---|---| +| `claude-code` | `append-flag`(`--append-system-prompt-file`) | `instructions_path = ".claude/agend.md"`、`shared = false`、`inject_on_ready = false`。Flag 由 `Backend::spawn_flags` 注入。 | `/fleet-instructions.md` | `/.claude/agend.md`(在 `.claude/` 下但**刻意不放** `.claude/rules/`,避免 Claude 重複載入) | +| `opencode` | `append-flag`(`opencode.json:instructions`) | `instructions_path = "AGENTS.md"`、`shared = true`、`inject_on_ready = false`。對 workspace 的 `AGENTS.md` 做 marker-merge。 | `/fleet-instructions.md`(由 workspace `opencode.json` 引用) | `/AGENTS.md`(workspace project doc) | +| `gemini-cli` | `project-doc`(`GEMINI.md`) | `instructions_path = "GEMINI.md"`、`shared = true`、`inject_on_ready = false`。Marker-merge。 | `/GEMINI.md` | `/GEMINI.md` | +| `codex` | `project-doc`(`AGENTS.md`) | `instructions_path = "AGENTS.md"`、`shared = true`、`inject_on_ready = false`。Marker-merge,Codex 的 32 KiB 上限保留。 | `/AGENTS.md` | `/AGENTS.md` | +| `kiro` | `project-doc`(`.kiro/steering/agend-.md`) | `instructions_path = ".kiro/steering/agend.md"`、`shared = false`、`inject_on_ready = true`。Rust 不再仰賴 `.kiro/steering/*.md` 自動載入,而是在 Ready 觸發後**把檔案內容當作第一則 user message 打進 pane**。 | `/.kiro/steering/agend-.md`(每實例獨立檔案) | `/.kiro/steering/agend.md`(每 workdir 一個檔案)**並**在 Ready 時注入 | +| `mock` | `none`(fallback 至 MCP `instructions` capability) | n/a — Rust 沒有 mock backend;E2E 改用 `Backend::Shell`。 | n/a | n/a | + +遷移時三個值得特別留意的行為變化: + +1. **OpenCode 現在會寫 workspace project doc(`AGENTS.md`)**。TS 把 fleet instructions 留在 `/fleet-instructions.md`,使用者根本看不到。Rust 把 OpenCode 比照 Codex 處理。如果你的 repo 有 commit `AGENTS.md`,遷移後會看到 marker block 出現在 diff 中;如果 `.gitignore` 已把 `AGENTS.md` 排除,無行為變化。 +2. **Kiro 從每實例命名改為每 workdir 命名**。TS 寫 `.kiro/steering/agend-.md`,Rust 寫 `.kiro/steering/agend.md`。在 TS 下若兩個 Kiro instance 共用同一 working directory,各自會有自己的檔案;在 Rust 下會共用一個 —— 而 Rust 通常用獨立 worktree 避免這種共用。 +3. **Kiro 的 instructions 改以 user message 注入**。TS `src/backend/kiro.ts:81` 寫檔時的註解聲稱 auto-load;Rust 團隊的實證調查發現 `.kiro/steering/*.md` 是 IDE 才會用的功能、獨立 CLI 不讀取,因此 Rust 改由 daemon 在 Ready 後把檔案內容貼進 pane(`inject_instructions_on_ready = true`)。這代表 instructions 占的是 chat history,不是 system prompt slot;冗長的 customPrompt 在啟動時就會吃 context tokens。PR #55 已經把 MCP 端的重複注入風險排除,所以這裡沒有雙重消耗。(如果 TS 註解才是對的、Kiro CLI 真會 auto-load,遷移結果一樣 —— instructions 仍會抵達模型 —— 只是 channel 從被動 auto-load 變成第一則 user message 主動 inject。) + +Bug #55 在 daemon 端的 gate(當 `nativeInstructionsMechanism !== 'none'` 時,丟掉五個 fleet-context env vars 並設 `AGEND_DISABLE_MCP_INSTRUCTIONS=1`)位於 `src/daemon.ts:1022-1039`。Rust 沒在同一層複製這個 gate;它在更早的階段就 gate —— backend preset 寫了檔案就不再構建 MCP `instructions` capability response。可觀察的不變式 ——「模型永遠不會看到兩遍 fleet context」—— 在兩個 daemon 上都成立。 + +### 信號與 ESC byte 語意 + +**Transport**(按鍵或 byte 能不能從 daemon 送進 agent 的 PTY)—— 下表四個 backend 已驗證。**Semantics**(agent 收到 ESC 或 SIGINT 後,會不會做正確的事)—— 在 `src/backend_harness.rs` 內以 per-backend capability matrix 獨立追蹤。Rust 專案 Sprint 11 會做 real-CLI 驗證;在那之前,下表用 §3.5.8 規定的 **`pending`** 標記。 + +| Backend | PTY byte transport(ESC `0x1b`、Ctrl-C `0x03`) | `interrupt` MCP tool 語意(ESC 中斷 LLM turn) | `tool_kill` MCP tool 語意(SIGINT 給 fg pgid) | +|---|---|---|---| +| `kiro-cli` | `True`(由 `verify_byte_delivery` 驗證) | `pending`(Sprint 11) | `pending`(Sprint 11) | +| `codex` | `True` | `pending` | `pending` | +| `claude` | `False` —— 由 `record_transport_results` 在 `src/backend_harness.rs:71-74` 的明確 Claude 分支設定(每個 backend 的初始值在 line 56 是 `Unverified`;Claude 因為「LLM context not tied to PTY buffer (known gap)」—— line 50 的註記 —— 被降級為 `False`) | `pending` | `pending` | +| `gemini` | `True` | `pending` | `pending` | +| `opencode` | 尚未進 harness matrix(`Backend::all()` 會回傳它,但 matrix 初始化只 seed 上面四個)—— `pending` | `pending` | `pending` | + +Rust 端今天**確實保證**的事情: + +- **Process tree termination**。`process::kill_process_tree(pid)`(`src/process.rs`)對 process group 發 `SIGTERM`,sleep 500 ms,然後無條件補 `SIGKILL`。Windows 退而求其次用 `TerminateProcess`。這條路徑用於 instance shutdown、replace、crash recovery —— 不負責中斷正在進行中的 LLM turn。 +- **ESC byte 注入**。`interrupt` MCP tool(`src/mcp/handlers.rs:969-991`)透過 daemon API 把 `0x1b` 寫進目標 agent 的 PTY。對端模型是否會把 ESC 解讀為「停止生成」是上面表格中的 `pending`。 +- **SIGINT 給 foreground process group**。`tool_kill` MCP tool(`src/mcp/handlers.rs:994-1031`)透過 `tcgetpgrp` 找出 pane 的 foreground pgid,然後對它發 `SIGINT`。Unix 才支援 —— Windows 上會回傳 `{"error": "tool_kill is only supported on Unix (Linux/macOS)"}`,而非靜默 no-op。 + +TS daemon 完全沒有 `interrupt`、`tool_kill`、`kill_process_tree` 這類 group kill、capability matrix —— 這些都不存在。TS 上的「取消」只能仰賴 backend 自己的 quit command(`/exit`、`/quit`、`exit`)或 OS 層級殺掉 tmux pane。 + +### 對遷移的實質影響 + +- 如果你直接 script CLI invocation(在 daemon 之外自己 spawn binary),只有 Codex 形狀變了(resume 回到原本就是子命令的形式,TS 拿來包裝 `--resume ` 的 wrapper 不見了)。 +- 如果你的 repo 有 commit `AGENTS.md` 或 `GEMINI.md`,遷移後會看到 marker block。`.gitignore` 不需要新加 `` 之類 glob —— marker block 是檔案內容,不是另一個檔案。 +- 如果你有 Kiro 工具讀 `.kiro/steering/agend-.md`,改成讀 `.kiro/steering/agend.md`。 +- 如果你以前依賴 TS MCP `instructions` capability fallback 來測 mock backend,請把 E2E 切到 `Backend::Shell` 並改用 `task` MCP tool flow 注入 instructions。 ## MCP tool API diff {#mcp-tool-api-diff} -*(Phase B —— 涵蓋:MCP tools 完整清單 (TS full set 約 20 個)、name rename、argument 形狀變更、return 值差異、broadcast `cost_limited` 欄位接續、deferred 或移除的 tools。)* +按 Sprint 0 review 與 dev-lead 的 HIGH-FRICTION 標記,這是**整份遷移指南最重的主題**。差異不是改名 —— Rust 把 TS 視為單一 communication surface 的東西**拆成三軌 coordination tracks**,並另外加了一批沒有 TS 對應的工具。 + +### 三軌 coordination 模型 + +``` + ┌───────────────── 1. work ─────────────────┐ + │ task (work board: create / claim / in_progress / verified / done) +agent ──────────┤ + │ send_to_instance, broadcast, delegate_task, request_information, report_result + ├──────── 2. comms (push/pull) ─────────────┤ + │ inbox, describe_message, describe_thread (pull side, Rust 獨有) + │ set_waiting_on, clear_blocked_reason (presence side, Rust 獨有) + │ + └─────── 3. scope freeze ───────────────────┘ + post_decision, list_decisions, update_decision +``` + +為什麼是三軌?TS 上 agent 的 MCP 工具箱把「做工作」、「告訴另一個 agent」、「決定政策」當成同一件事 —— 在 `src/outbound-handlers.ts` 的 `outboundHandlers` 內由不同訊息 shape route。Rust daemon 強制更明確的分層,原因和 `git` 把 index、working tree、object store 拆開一樣:負責「凍結一個 scope decision」的工具和負責「投遞一條訊息」或「claim 一個 task」的工具有不同的 correctness invariants,混在一起就無法推理 ordering 或 recovery(協定層的論述見 `FLEET-DEV-PROTOCOL-v1.md` §1、§2)。 + +對 agent 而言,實務影響是: + +- **Work board(`task`)** 是「這件事做完沒」的唯一真相來源。狀態轉移 `claimed → in_progress → verified → done` 不能跳關,跳了會被拒絕。 +- **Comms** 仍然用 `send_to_instance` / `broadcast` 做 push,但加入 **pull**(`inbox`)與 **presence**(`set_waiting_on`),讓 agent 重啟後能補回未讀訊息。 +- **Decisions(`post_decision`)** 是唯一「凍結未來 scope」的機制。reviewer 找到 scope violation 時引用 decision id;違反者不能事後辯說「我們從沒決議過」。 + +### 兩端都存在的工具 + +下列工具兩個 daemon 都有,名稱與形狀大致相同;diff 在 input/output schema 與週邊 lifecycle。先掃過表格找「schema diff」,只在你實際用該工具時才細看 sub-section。 + +| 工具 | TS schema 位置 | Rust schema 位置 | Schema diff | +|---|---|---|---| +| `reply` | `src/outbound-schemas.ts:ReplyArgs` | `src/mcp/tools.rs:channel_tools` | 無 | +| `react` | `ReactArgs` | `channel_tools` | 無 | +| `edit_message` | `EditMessageArgs` | `channel_tools` | 無 | +| `download_attachment` | `DownloadAttachmentArgs` | `channel_tools` | 無 | +| `send_to_instance` | `SendToInstanceArgs` | `comm_tools` | Rust 加上選填的 `thread_id`、`parent_id` 用於 thread 追蹤。雙方都接受 `request_kind ∈ {query, task, report, update}`。 | +| `delegate_task` | `DelegateTaskArgs` | `comm_tools` | Rust 加上 `task_id`、`thread_id`、`parent_id`、`force` + `force_reason`(取代已棄用的 `interrupt` + `reason`)、`second_reviewer` + `second_reviewer_reason`,後者支援協定 §3.5 dual-review。 | +| `report_result` | `ReportResultArgs` | `comm_tools` | Rust 加上 `reviewed_head`(review 當下的 git SHA,會出現在 metadata)、`thread_id`、`parent_id`。 | +| `request_information` | `RequestInformationArgs` | `comm_tools` | 無 | +| `broadcast` | `BroadcastArgs` | `comm_tools` | 無。雙方都把 `report` 從 `request_kind` 排除 —— broadcast 不能攜帶 per-correlation report。 | +| `list_instances` | `ListInstancesArgs` | `instance_tools` | TS 支援 `tags` 過濾(`src/outbound-schemas.ts:146-148`);Rust 不接參數(`src/mcp/tools.rs` 的 `inputSchema.properties: {}`)。如果原本依賴 tag-based listing,遷移時請拿掉 filter,改成 client 端事後過濾,或改用 `team`(`create_team` / `update_team`)做路由。 | +| `create_instance` | `CreateInstanceArgs` | `instance_tools` | Rust 加上 `team` + `count`(同質 team)、`backends`(異質 team)、`layout` ∈ `{tab, split-right, split-below}`、`target_pane`、`task`(spawn 後注入的初始任務)。`layout` 與 `target_pane` 屬於 TUI 感知欄位,TS 沒有對應。 | +| `delete_instance` | `DeleteInstanceArgs` | `instance_tools` | 無 | +| `replace_instance` | `ReplaceInstanceArgs` | `instance_tools` | 無 | +| `start_instance` | `StartInstanceArgs` | `instance_tools` | 無 | +| `describe_instance` | `DescribeInstanceArgs` | `instance_tools` | Rust 多回傳 `waiting_on`、`waiting_on_since`、最後 heartbeat、last_polled_at、dispatch tracking —— 與下面 `set_waiting_on` / `report_health` 流程搭配。 | +| `set_display_name` | `SetDisplayNameArgs` | `instance_tools` | 無 | +| `set_description` | `SetDescriptionArgs` | `instance_tools` | 無 | +| `post_decision` | `PostDecisionArgs` | `decision_tools` | 無 | +| `list_decisions` | `ListDecisionsArgs` | `decision_tools` | 無 | +| `update_decision` | `UpdateDecisionArgs` | `decision_tools` | 無 | +| `task` | `TaskBoardArgs` | `task_tools` | **status enum 擴充。** TS:`open / claimed / done / blocked / cancelled`。Rust:`open / claimed / in_progress / blocked / verified / done / cancelled`。新增 `due_at`、`duration` 表達期限。新加的 `in_progress` 與 `verified` 兩個狀態對應協定 §10.3 的 three-state completion(`in_progress` → `verified` → `done`)。 | +| `create_team` | `CreateTeamArgs` | `team_tools` | Rust 加上 `orchestrator`(必須是 member;接收 team-level routing)。 | +| `update_team` | `UpdateTeamArgs` | `team_tools` | Rust 加上 `orchestrator`(重新指派 orchestrator)。 | +| `list_teams` / `delete_team` | 同上 | `team_tools` | 無 | +| `create_schedule` | `CreateScheduleArgs` | `schedule_tools` | **trigger 拆分。** TS:只接受 cron expression。Rust:可選 `cron`(recurring)**或** `run_at`(ISO 8601 one-shot)—— 兩者互斥。One-shot 觸發後或被偵測為 missed 後自動 disable。 | +| `list_schedules` / `update_schedule` / `delete_schedule` | 同上 | `schedule_tools` | `update_schedule` 兩個 trigger 欄位都接受;補哪個就替換 trigger kind。 | +| `deploy_template` / `teardown_deployment` / `list_deployments` | `*Args` | `deploy_tools` | 無 | +| `checkout_repo` / `release_repo` | `*Args` | `repo_tools` | 無 | + +### Rust 新增的工具(TS 沒有對應) + +下面這 11 個工具,是把現有 `@suzuke/agend` agent prompt 移植過來時最該關注的。按所屬軌列出。 + +#### Comms — pull side + +| 工具 | 用途 | 為什麼遷移時重要 | +|---|---|---| +| `inbox` | Drain 寄到本實例的待收訊息。回傳 `{messages: [...]}`,並對 Telegram 已綁定的 binding emit `AgentPickedUp` event(每筆 pickup 對應一個 ✅ 反應)。 | TS 上每一則跨實例訊息都會直接打進 pane(透過 tmux);agent 重啟意味著 in-flight 的訊息會丟。Rust 的 inbox 會持久化,crash 在任務中途的 agent 在 resume 時可呼叫 `inbox` 補回未讀。 | +| `describe_message` | 用 ID 查 inbox 訊息狀態 —— 回傳 `ReadAt`(含 timestamp)、`UnreadExpired` 或 `NotFound`。選填 `instance` 把 lookup 限定到該實例的 inbox。 | 寄送方可在 retry 前確認對方有沒有 pick up 這條訊息。TS 沒有對應 —— 只能從沉默猜。 | +| `describe_thread` | 取出 thread 內所有訊息,按 timestamp 排序。選填 `instance` 限定到某個收件人 inbox。 | 用來事後重建多 hop 協作 trace(impl → reviewer → impl …)。和 `send_to_instance` / `delegate_task` / `report_result` 新增的 `thread_id` / `parent_id` 欄位搭配使用。 | + +#### Comms — presence 與 process control + +| 工具 | 用途 | 為什麼遷移時重要 | +|---|---|---| +| `set_waiting_on` | 宣告本實例現在被誰擋住(`condition` 字串)。空字串清除。Daemon 自動衰減 stale 條目 —— 見 `set_waiting_on` handler 在 `src/mcp/handlers.rs:1033-1063`。 | 取代 TS 上 agent 把「我在等 reviewer」之類 prose 寫進訊息的做法。現在是 machine-readable;orchestrator 可以 `list_instances` 直接看誰被誰擋住。 | +| `clear_blocked_reason` | 在不重寫 `waiting_on` 的情況下,強制清除 stale 阻塞原因。 | Orchestrator 在阻塞條件已解決但 blocked 實例尚未察覺時使用(例如 reviewer 已給 verdict,implementer 還在等)。 | +| `report_health` | 回報自己的 liveness / state 給 daemon,配合 heartbeat path。 | 取代 TS 那種「MCP server 還連著就算活著」的隱式訊號 —— 現在改成顯式、結構化。 | +| `interrupt` | 對目標 agent 的 PTY 注入 ESC byte(`0x1b`),中斷當下 LLM turn。選填 `reason` 會在 ESC 後當作後續 prompt 注入。Context 保留,agent 接受下一個 prompt。 | TS 完全沒有從外部中斷 LLM 一輪生成的方式 —— 只能等 timeout 或殺掉 pane。各 backend 是否真把 ESC 當「停止生成」是上面「信號與 ESC byte 語意」小節裡的 `pending`。 | +| `tool_kill` | 對目標 agent 的 PTY foreground process group 發 `SIGINT`,取消活躍中的**工具子進程**而保留 agent session。Unix only。成功時回傳 `{ok: true, pgid}`。 | 在 agent 卡在長時間 shell 命令(`cargo build`、`pytest …`)但你想保留 agent chat history 時用。TS 沒有對應 —— 唯一脫困的方法是殺整個 pane 重來。 | + +#### TUI control + +| 工具 | 用途 | 為什麼遷移時重要 | +|---|---|---| +| `move_pane` | 把實例的 pane 移到 daemon TUI 內的另一個 tab。會 split 既有 tab 的 focused pane,或建立新 tab。Scrollback 與 PTY state 保留。 | TS 沒有 TUI 可移 pane。如果你 TS agent 是用 `delete_instance` + `create_instance` 來「視覺上搬位置」,請改用 `move_pane` —— 它保留 session、scrollback、worktree。 | + +#### CI watching + +| 工具 | 用途 | 為什麼遷移時重要 | +|---|---|---| +| `watch_ci` | 監看 GitHub Actions CI(指定 repo + branch)。CI 進入終端狀態(success / failure 或任何 terminal state)時,事件自動注入 watching agent 的 inbox。若 daemon env 有 `GITHUB_TOKEN` 走認證 polling;否則退化為非認證 polling(fleet 共享 60 req/hr),response 中會帶 `warning` 欄位。 | TS agent 用 `gh pr checks --watch` 從 shell 輪詢,會卡住 agent 並消耗 token。Rust 把 polling 移到 daemon,agent 只在終端狀態被通知。 | +| `unwatch_ci` | 停止監看某 repo 的 CI。 | n/a —— 與 `watch_ci` 配對。 | + +### 跨實例 comms —— 整份遷移最深的 friction + +這是 dev-lead 點名要寫最深的區塊,所以下面端到端走一個具體遷移情境。 + +**TS pattern(今天):** +``` +agent A: send_to_instance(target='B', message='please review PR #42', request_kind='task') + ↓ TS daemon 透過 outboundHandlers['send_to_instance'] 路由 + ↓ Bug #57 cost-guard 預檢(B 超出預算就丟掉) + ↓ targetIpc.send({type: 'fleet_inbound', targetSession: 'B', content, meta: {...}}) + ↓ B 的 MCP server 收到 fleet_inbound,把它打進 B 的 pane,前綴 [from:A] +agent B(在工作):在 chat 看到訊息,自行決定要不要中斷現任務回應。 +agent B(離線):訊息消失 —— TS 不會把 `fleet_inbound` 持久化到 pane buffer 之外。 +``` + +**Rust pattern(遷移後):** +``` +agent A: send_to_instance(target='B', message='please review PR #42', request_kind='task', + thread_id='th-pr42', parent_id='m-…') + ↓ Rust daemon 透過 mcp/handlers.rs 的 send_to_instance 路由 + ↓ 寫入 B 的 inbox 檔(/inbox/.json,持久化) + ↓ 若 B 已綁定 Telegram topic,Telegram sink 發 UX event +agent B(在工作):下一次 [AGEND-MSG] system reminder 會帶這條訊息 header,B 可呼叫 inbox drain。 +agent B(離線 / 重啟中):inbox 檔仍在;下次啟動 B 透過 inbox 看到 pending 訊息。 +agent A:可隨後呼叫 describe_message(message_id=…) 或 describe_thread(thread_id='th-pr42') 確認 pickup。 +``` + +遷移時 prompt 與 runbook 必須調整的事: + +1. **不要再假設 push delivery 就夠**。如果你的 agent 有可能漏掉訊息(重啟、crash、手動 stop),在啟動時加上 `inbox` check。Rust daemon 已經會在新訊息到達時用 `[AGEND-MSG]` system reminder 提醒,但要 drain backlog 與透過 `AgentPickedUp` 確認 pickup,仍需顯式 `inbox` 呼叫。 +2. **採用 `thread_id` 與 `parent_id`**。協定的協作模式(delegate → ack → report;review → finding → re-review)在 thread 鏈上是天然 traceable。TS 用 prose `correlation_id` 串相同模式;Rust 仍接受 `correlation_id`,但補上結構化的這對欄位。 +3. **用 `set_waiting_on` 取代 prose**。把「我在等 reviewer」打進 chat 機器讀不到;`set_waiting_on(condition='review from at-dev-4 on PR #63')` 可由 `describe_instance` 查詢,也可用 `list_instances` 全 fleet 列出。 +4. **把 TS 時代的「殺了重生」模式換成 `interrupt` 與 `tool_kill`**。如果你的 TS prompt 寫「agent 卡住就 replace」,改寫為「agent 卡在 LLM turn 中就 `interrupt(target=…)`;卡在 tool subprocess 中就 `tool_kill(target=…)`;replace 留給最後手段」。 +5. **`request_kind: 'report'` flow 要帶 `reviewed_head`**。reviewer 在 `report_result` 時附上 review 當下的 git SHA。Rust 的 merge gate(協定 §10.3 / §3 metadata fields)把這個欄位視為 staleness 判定的 load-bearing 訊號。 + +### Tool-set 設定檔 + +TS 透過 `AGEND_TOOL_SET`(`src/channel/mcp-tools.ts:120-126`)暴露兩個 profile: +- `standard`:`reply, react, edit_message, send_to_instance, broadcast, list_instances, describe_instance, list_decisions, post_decision, task, set_display_name, set_description` +- `minimal`:`reply, send_to_instance, list_decisions, download_attachment` + +Rust 目前在 `src/mcp/tools.rs` 沒有暴露 tool-set profile —— 每個 spawn 出來的 agent 看到全部 45 個工具。**`pending`** 確認:若 Sprint 11 在 Rust 端加 profile 機制,依賴 TS `minimal` 來壓 token cost 的 agent 可能要重調。在那之前,請把 Rust 的工具箱當 `full` 看待去 prompt-engineer。 ## Migration steps {#migration-steps}