diff --git a/CHANGELOG.md b/CHANGELOG.md index 39f5ae0..e0d936f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,63 @@ All notable changes to ike are recorded here. The format follows ## [Unreleased] -Nothing yet. +### Added + +- **Delegate a task to an agent.** `ike delegate ` (or `D` in the TUI) runs + the Claude Code CLI in the task's working directory and streams what it does. + It uses your own installation, so your authentication, settings, and + per-project `CLAUDE.md` apply. A delegated run never completes the task — + reading what it did and deciding is the point. +- **Attach a plan to a task.** `ike plan ` (or `P`) asks an agent to draft + one; `--show`, `--edit`, `--from-file`, and `--clear` manage it by hand, and + `p` in the TUI shows it. Drafting is read-only and needs no permission. + `ike delegate` follows the attached plan if there is one, and `--plan-first` + does both in one command. Plan the work now, decide later whether to do it + yourself or hand it on. +- **`ike agent enable|disable|status`**, off by default and separate from + `ike mcp`. Letting an agent edit your task list and letting ike start a process + that edits your files are different decisions. Like the MCP gate it is per data + file, never carried into an export, and outside undo history. +- **Tasks remember a working directory.** The first run stores the one you give + it or the directory you ran from, so a task stays attached to its project. + `✎` marks a task with a plan and `⣾` one an agent is working on; `esc` detaches + from a run without stopping it, and `ctrl+c` there stops it. +- **`--permission-mode` on `ike delegate`**, validated against the modes the CLI + accepts so a typo fails before a process starts rather than mid-run. Delegated + runs default to `auto`, the mode intended for unattended work. +- **`--effort` on `ike plan` and `ike delegate`, with a level chosen per run.** + Effort controls how deeply the agent thinks and how many tools it reaches for, + which moves a run's wall clock more than the model does. Drafting a plan runs + at `high` because the thinking is the product; carrying out a plan you already + reviewed runs at `medium`, because re-deriving decisions the plan records buys + nothing; a run with no plan stays at `high`. The level and the reason are + printed in the run header, and `--effort` overrides both — validated, so a typo + fails before a process starts. +- **Jump into a real Claude Code session on a task** with `-i` (`ike plan 3 -i`, + `ike delegate 3 -i`) or `c`/`C` in the TUI. ike steps aside, the agent gets the + terminal already briefed on the task and opened in its directory, and exiting + puts you back where you were. +- **The conversation belongs to the task.** ike pins a session ID the first time + and resumes it on every later visit, so you can talk something through, leave, + and come back days later to the same history instead of re-explaining it. + `--new-session` starts over; `⌁` marks a task that has one waiting. +- **A plan agreed in conversation is attached automatically.** The agent is given + a path to write it to, and ike picks it up as you come back — so + `ike plan 3 --show` reflects what you decided together. + +### Notes + +- **Permission modes are not a safety ladder.** Measured against real runs, + `acceptEdits` and `auto` both let a delegated agent run `rm`; only `manual` + denied it. Use `--permission-mode manual` for a run that stops at anything it + would otherwise have to ask about. The consent gate, not the mode, is the + practical control. +- Plan bodies are stored beside the data file as + `tasks.json.plans//.md`, not inside `tasks.json`, so they are not + copied into every undo snapshot. They do **not** yet travel with + `ike space export`. +- Deleting a task leaves its plan file, so undoing the delete restores both. + `ike plan --prune` sweeps the orphans. ## [0.1.0] - 2026-07-29 diff --git a/CLAUDE.md b/CLAUDE.md index 27e8f3d..79a1bf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -`ike` is an Eisenhower matrix task manager in Go with three frontends over one JSON store: a Bubble Tea TUI (bare `ike`), cobra CLI subcommands, and an MCP server (`ike mcp`, stdio). +`ike` is an Eisenhower matrix task manager in Go with three frontends over one JSON store: a Bubble Tea TUI (bare `ike`), cobra CLI subcommands, and an MCP server (`ike mcp`, stdio). A task can also be handed *to* an agent (`ike plan`, `ike delegate`), which runs the Claude Code CLI as a subprocess. ## Commands @@ -20,7 +20,7 @@ IKE_DATA_FILE=$(mktemp -d)/t.json go run . list # run against a scratch data f ## Architecture -Dependency direction: `cli` → `tui`/`mcpserver` → `store` → `task`. **All business logic lives in `internal/store/ops.go`** (Add/Complete/Restore/Move/Reorder/Rename/Delete/SetQuadrantLabel/Undo/Redo/List/ListArchive); the three frontends must stay thin wrappers over it. `internal/task` is the zero-dependency domain (Task struct, Quadrant 1–4 with default labels, and `Less`/`SortOrder`, the single definition of display order). +Dependency direction: `cli` → `tui`/`mcpserver` → `store`/`agent` → `task`. **All business logic lives in `internal/store/ops.go`** (Add/Complete/Restore/Move/Reorder/Rename/Delete/SetQuadrantLabel/Undo/Redo/List/ListArchive); the three frontends must stay thin wrappers over it. `internal/task` is the zero-dependency domain (Task struct, Quadrant 1–4 with default labels, and `Less`/`SortOrder`, the single definition of display order). **Every mutating op returns the post-mutation `Data` alongside its own result** — `Add` is `(task.Task, Data, error)`, `Undo` is `(string, Data, error)`. `Mutate` already produces that value, so handing it back costs nothing and spares the caller a second read. Frontends must render the outcome from it (`d.Labels.Of(q)`, `d.List(q)`) rather than calling `Load`/`QuadrantLabels` again: a follow-up read observes a *later* file state, and the helpers that used to do it (`cli.quadrantLabels`, `mcpserver.labelsOf`) both swallowed the error to stay "cosmetic". `SetMCPEnabled` is the deliberate exception — nothing renders from it. @@ -67,7 +67,31 @@ Every mutation goes through **`m.apply(d, err) bool`**, which either shows the e The CLI is **constructed, not registered**: `NewRootCmd(open)` builds the whole tree and every command has a `newXCmd(open) *cobra.Command` constructor, where `open` yields the store (lazily, inside `RunE`, so `ike --help` still works with a broken `IKE_DATA_FILE`). Command bodies wrap in `withStore` and print to `cmd.OutOrStdout()`. Do not go back to `init()` plus a package-level `rootCmd`: that made flag variables process-global and left every `RunE` untestable — `internal/cli` sat at 18.9% coverage with `ike mcp enable|disable` at 0%, i.e. the consent toggle was tested everywhere except where users invoke it. Tests build a tree over a scratch file with `runCLI`. -Store tests are split by concern: `ops_test.go` (tasks, ordering, ranks), `history_test.go` (undo/redo), `labels_test.go`, `mcp_test.go` (the gate), `store_test.go` + `durability_test.go` (the write path). Keep new tests in the matching file rather than growing one past ~1000 lines. +Store tests are split by concern: `ops_test.go` (tasks, ordering, ranks), `history_test.go` (undo/redo), `labels_test.go`, `mcp_test.go` (the gate), `agent_test.go` (the delegation gate), `plans_test.go` (plan sidecars, `SetDir`), `store_test.go` + `durability_test.go` (the write path). Keep new tests in the matching file rather than growing one past ~1000 lines. + +**No test runs the real `claude`.** A run costs money, needs credentials, and would tie the suite to how a model happens to word a plan. `internal/agent`, `internal/cli`, and `internal/tui` each point `IKE_AGENT_CMD` at their own test binary re-executed with a marker in the environment, replaying a canned stream — no shell script to keep executable, no second language, no network. Two of the TUI tests drive the real Bubble Tea command loop against that fake (exec, `waitForEvent` re-issuing per event, `savePlanCmd`), because folding messages into the model correctly is not the same as the commands actually producing them. + +**Plan bodies live beside the data file, not in it** (`internal/store/plans.go`, `.plans//.md`). `Snapshot` copies `Tasks` wholesale into as many as 40 snapshots, so a few KB of markdown per task would be amplified across the whole history — the same blow-up `Snapshot.ArchiveEntry` exists to have fixed once. Only `Task.PlanAt` is persisted in the matrix, and it is what frontends render the `✎` mark from: the matrix redraws on every keypress, so a stat per row per frame would be a poor trade for a symbol. Beside the *data file* rather than under `XDG_STATE_HOME` because a plan is user data — it must follow `--file` and `IKE_DATA_FILE`, so two matrices cannot share one set of plans. Plans are **not** carried by `ike space export`; that is a known gap. `dir` and `plan_at` were added within v4 as `omitempty` rather than bumping the version, by the `redo` reasoning above: an older binary drops them, costing a remembered directory and a mark a re-plan restores, which is the harmless category rather than the archive-wipe one. + +The plan write happens inside the same `Mutate` callback that stamps `PlanAt`, through **`mutateSpace`** — `Mutate` with the resolved space name handed to the callback. Plans are filed per space and `Data.Space` is derived by `dataFor` only *after* `fn` returns, so resolving separately would land outside the lock, where an `ike space use` in between would file a plan under the wrong space. The bytes go through **`writeBytesAtomic`**, lifted out of `writeFileAtomic` so the sidecars get the same four durability properties rather than a second untested copy; `mutateFile` still owns the lock, the re-read, and the gate, and `durability_test.go` passing unchanged is what proves the lift was faithful. **`Delete` deliberately does not remove the plan file** — `Delete` is undoable, so removing it would make undo silently lossy, and since `NextID` is monotonic an orphan can never be picked up by a later task. `PrunePlans` is the explicit sweep. + +**`task.SanitizeBlock` is the multi-line sibling of `SanitizeDisplay`, and the two are not interchangeable.** `SanitizeDisplay` replaces every rune below `0x20`, newline included, so using it on a plan or a line of agent output renders the whole thing as one line of U+FFFD; `SanitizeBlock` keeps `\n` and `\t` and replaces the rest. They are separate functions rather than one with a flag so neither call site can pick the wrong one silently — a single-line field sanitized with `SanitizeBlock` would let a newline forge an extra listing row. Agent output is the most untrusted text ike renders, and `internal/agent` puts every field through `SanitizeBlock` at the single point they leave the package, so no frontend has to remember to and the CLI and TUI cannot disagree about whether it happened. + +**Delegation is gated separately from MCP** (`File.AgentEnabled`, `ike agent enable|disable|status`). Letting an agent edit the task list and letting ike start a process that edits files are different decisions with different blast radii, so consenting to one is not consenting to the other. Everything else mirrors the MCP gate: off by default, out of `Snapshot` so undo cannot reopen it, reached through `mutateFile`/`readFile` so it works when the current space is missing, and never written into an export. It differs in one way — the MCP gate is re-checked on every read and mutation because a session outlives its check, while a delegated run is started by a command that just read the flag, so it is checked **once, at launch, in `internal/cli`** (and freshly in `tui.startAgent`, not from the polled `Data`, so a revocation in another terminal does not wait for the 2s tick). `Data.AgentAllowed` is derived alongside `MCPAllowed` for the ambient footer line only: display, never decision. + +**`internal/agent` is the only package that starts a process**, and stays a pure runner the way `mcpserver` is a pure transport — it knows nothing about the store. Four things there are load-bearing and were each verified against a real run rather than assumed: `--verbose` is mandatory alongside `--output-format stream-json` or the stream carries nothing; `cmd.Stdin` is left nil (so `/dev/null`) because the CLI otherwise waits 3s for input and a child sharing ike's stdin would eat the TUI's keystrokes; an **unrecognized event type is skipped, never an error** (an ordinary run already carries `rate_limit_event` and `system/thinking_tokens`, and the CLI adds more between releases), as is a non-JSON line; and a `result` event wins over a non-zero exit, with stderr surfaced only when the process dies without one. Thinking blocks usually arrive with a signature and empty text, so the emptiness check is what stops blank transcript rows. The child gets its own process group — split into `procgroup_unix.go` and a non-Unix fallback so the tree keeps compiling everywhere, which `.goreleaser.yaml` claims and CI's cross-build step does not check — because killing only the parent would leave the agent's own tools running against the user's files. Tests replay `testdata/toolrun.jsonl`, captured from a real run, against a fake CLI that is the test binary re-executed. + +**Permission modes do not form a safety ladder, and the names invite a wrong guess.** Measured against real headless runs asking the agent to `rm` a file: `manual` denied it, `acceptEdits` allowed it, `auto` allowed it, `bypassPermissions` allows it by definition. So `acceptEdits` is *not* a middle setting that withholds the shell — an earlier version of this feature shipped documentation claiming it was, which was wrong. `manual` is the only mode that restrains a delegated run: with nobody to ask, it denies anything needing approval and the denials come back in the transcript. The default is `auto`, the mode intended for unattended work. **Do not re-derive this with a harmless command** — anything the harness classifies as safe (`echo`, `ls`) runs under every mode including `manual`, so a test with `echo hi` shows no difference between any of them and proves nothing. `agent.ValidatePermissionMode` rejects a typo before a process is started, and rejects `plan` for a delegated run because it would silently make it read-only, which is what `ike plan` is for. + +**Effort is chosen per run, and the choice is always on screen.** `agent.ResolveEffort(mode, requested, hasPlan)` is the single definition: `high` to plan (the thinking is the product), `medium` to execute a task that has a plan attached (the approach was decided and reviewed, so re-deriving it buys nothing), `high` to execute one that does not. An explicit `--effort` wins and comes back with an **empty reason**, which is what tells a frontend to print the level alone rather than inventing an explanation for a flag someone typed. It is a pure function precisely so `args`/`sessionArgs` and the CLI and TUI headers can each call it instead of the spec running at one level while the header claims another — the same reason ops return the post-mutation `Data`. `--effort` is therefore *always* passed to the CLI, never omitted: falling back to the CLI's own default would silently erase the plan/execute distinction. Unlike the permission-mode table this is a judgement about how much thinking a run has left to do, **not** a measurement — so it is printed, and it is overridable, and `ValidateEffort` rejects a typo before a process starts. + +**An interactive session is not a `Run`.** A `Run` owns a pipe and parses NDJSON; a session owns the *terminal*, so `agent.InteractiveCommand` returns an `*exec.Cmd` and starts nothing — the CLI hands it the real terminal and the TUI passes it to `tea.ExecProcess`, which releases the terminal, runs it, and restores the TUI. Its stdio is left nil on purpose, because that is the signal for `ExecProcess` to wire the terminal in; setting it would break the handover. It also gets **no process group**, unlike a headless run: it is the foreground job and must receive the ctrl+c typed at it, which its own group would prevent. + +The conversation is pinned to the task by `Task.SessionID`. Claude Code lets the caller *choose* a session ID rather than only reporting one back, so ike mints a UUID, **stores it before starting the agent** (a session ike started but did not record would be unreachable), passes `--session-id` the first time and `--resume` after. `SetSession` deliberately does not `pushUndo`: the ID points at a conversation living outside ike, and undoing to a previous one would resume something the user moved on from — closer to reopening revoked access than to reversing an edit. **`--resume` still needs a prompt**; with none it fails with "Provide a prompt to continue the conversation" and the session never opens, which is invisible until someone tries to come back to one. Hence `resumePrompt` — a nudge, not a re-brief. + +A plan agreed in conversation comes back through a **draft file**, `.plans//.draft.md`. Stable per task, not a temp path, because the instruction naming it lives in the conversation's history and has to still be valid next week. `PlanDraftPath` creates the directory as well as naming it — a task with no plan yet has no plan directory, and handing an agent a path inside one that does not exist loses the plan at the last step. `AdoptPlanDraft` routes it through `SetPlan` so it still gets validation, the atomic write, the stamp, and an undo entry, and **leaves a draft it could not store in place**: it is the only copy of what the agent just wrote, so deleting it on a validation failure would destroy it. + +The TUI's run view goes through `listView`/`renderList` like every other full-screen list: a cursor pinned to the last row *is* following the tail, and `moveCursor` gives scrollback for nothing. A run outlives its view — `esc` detaches and it keeps accumulating, because a run takes minutes — so `Model.run` is checked for a live run in `taskMark` and in `Quit`. Runs carry a **sequence number**; without it, events already in flight when a run is canceled would land in its successor's transcript. `ctrl+c` in the run view stops the run rather than quitting ike, and quitting ike stops the run rather than orphaning it. Invalid data from outside ike is repaired on read, not trusted: `clampQuadrants` moves any task whose quadrant is outside 1–4 into `Eliminate`. `Add`/`Move` validate, so only a hand edit or another writer produces one — and left alone it was invisible in the TUI, in `ike list`, and to `normalizeRanks` (so it never even got a rank), while still round-tripping through every write and still appearing in `--json`. Snapshots are clamped too, so undoing into one cannot bring it back. diff --git a/README.md b/README.md index 45fe53b..5642418 100644 --- a/README.md +++ b/README.md @@ -151,13 +151,21 @@ Run `ike` with no arguments. | `s` | space picker (`enter` switch, `n` new, `r` rename, `d` twice delete) | | `]` / `[` | next / previous space | | `f` | data file picker (`o` there types a path) | +| `p` | show the plan attached to the selected task | +| `P` | ask an agent to draft a plan | +| `c` | open a Claude Code session and talk the plan through (resumes the task's) | +| `C` | same, but to work on it together | +| `D` | delegate the task to an agent, or reattach to a run already going | | `?` | toggle help | | `q` | quit | Changes made by the CLI or MCP server while the TUI is open appear within ~2 seconds. A dim `◆ mcp` marker sits in the footer while AI agent access is enabled; see -[MCP](#mcp). No marker means nothing but you can reach the matrix. +[MCP](#mcp). No marker means nothing but you can reach the matrix. After a title, +`✎` means the task has a plan attached, `⌁` that it has a conversation you can +pick back up, and `⣾` that an agent is working on it right now (see +[Delegating a task](#delegating-a-task)). ## CLI @@ -177,6 +185,10 @@ ike label # show the four quadrant headings ike label 1 "Firefighting" # rename a quadrant ike label 1 --reset # restore its default name +ike plan 3 # draft a plan for task 3: see "Delegating a task" +ike delegate 3 # hand task 3 to an agent +ike agent status # whether ike may run an agent + ike space # spaces: see "Spaces" below ike list -s work # act on one space just this once ike --file /path/to.json list # act on a different data file @@ -269,6 +281,167 @@ Or in any MCP client config: { "mcpServers": { "ike": { "command": "ike", "args": ["mcp"] } } } ``` +## Delegating a task + +MCP lets an agent manage your matrix. This is the other direction: handing a +task *to* an agent. It runs the [Claude Code](https://claude.com/claude-code) +CLI — your own installation, so your authentication, settings, and per-project +`CLAUDE.md` all apply. + +There are two steps, and you can stop after the first. + +**Draft a plan.** `ike plan 3` (or `P` in the TUI) asks an agent to explore the +task's working directory and write a plan, which is then attached to the task. +This is read-only — it runs in Claude Code's plan mode and cannot change +anything — so it needs no permission. + +```sh +ike plan 3 # draft one, streaming as it works +ike plan 3 --show # print it +ike plan 3 --edit # open it in $EDITOR +ike plan 3 --from-file notes.md # attach one you wrote yourself +ike plan 3 --clear # remove it +``` + +The plan is yours. Read it, argue with it, edit it — then either do the work +yourself, or hand it on. + +**Or talk it through.** `ike plan 3 -i` (or `c` in the TUI) hands the terminal to +a real Claude Code session, opened in the task's directory and already briefed on +it. Exit the session and you're back in ike exactly where you were. + +The conversation belongs to the task. Run it again next week and you resume the +same session — full history, nothing re-explained: + +```sh +ike plan 3 -i # first time: a new conversation, briefed on the task +ike plan 3 -i # later: picks up where you left off +ike plan 3 -i --new-session # start over deliberately +``` + +When you agree on a plan, the agent writes it to a path ike gave it, and ike +attaches it to the task as you come back — so `ike plan 3 --show` reflects what +you actually decided together. A conversation that ends without a plan attaches +nothing, which is most of them. + +`⌁` marks a task with a conversation waiting to be picked up. + +**Hand it on.** `ike delegate 3` (or `D`) runs an agent that carries the task +out, following the attached plan if there is one. + +```sh +ike agent enable # required before any delegated run +ike delegate 3 # follow the attached plan, streaming +ike delegate 3 -i # supervise it in a real session instead +ike delegate 3 --plan-first # draft a plan, then carry it out +ike delegate 3 --dir ~/dev/thing # set the working directory +ike delegate 3 --model opus # choose a model +ike delegate 3 --effort max # choose how hard it works +``` + +`-i` works here too, and resumes the same per-task conversation: the agent does +the work while you watch and answer its questions, rather than reporting back +afterwards. Being present doesn't remove the gate — `ike agent enable` is still +required, because it's still ike starting an agent that edits your files. + +**Delegation is off by default**, separately from MCP access. Letting an agent +edit your task list and letting ike start a process that edits your *files* are +different decisions, so agreeing to one is not agreeing to the other. Like the +MCP gate, the setting is per data file, survives restarts, is never carried into +an export, and is not part of undo history — no sequence of `ike undo` can +re-open it. + +**A delegated run never completes the task** — read what it did and decide. + +### How hard the agent works + +Effort controls how deeply the agent thinks, how many tools it reaches for, and +how much it says on the way. It usually matters more than the model: a run's wall +clock is dominated by how many turns it takes, and effort is what moves that. + +ike picks a level per run rather than using one setting for everything, because +the two things it delegates are not the same shape of work — and it prints what +it chose, and why, in the run header: + +| run | effort | why | +|---|---|---| +| `ike plan 3` | `high` | drafting a plan — the thinking *is* the product | +| `ike delegate 3` with a plan attached | `medium` | following an attached plan; the approach is already decided and reviewed | +| `ike delegate 3` with no plan | `high` | no plan to follow, so it has to work the approach out as well as do it | + +``` +$ ike delegate 3 +delegating 3 Fix the flaky reorder test + in /Users/you/dev/thing + · effort medium — following an attached plan +``` + +`--effort low|medium|high|xhigh|max` overrides it, on both `ike plan` and +`ike delegate`, and the header then just states the level. A mistyped level fails +before any process starts. Attaching a plan is therefore also the cheapest way to +make a delegated run cheaper: the run stops paying to re-derive decisions the +plan already records. + +The TUI has no flag for this and always uses the recommendation, shown in the +same line at the top of the run view. + +### What a delegated run is allowed to do + +Runs are unattended, so nothing can prompt you part-way through. The default is +`--permission-mode auto`, which lets the agent change files *and run commands* in +the working directory. + +Do not read the mode names as a safety ladder — they are not. Measured against a +real run, asking an agent to `rm` a file: + +| `--permission-mode` | result | +|---|---| +| `manual` | denied — the file survived | +| `acceptEdits` | allowed — the file was deleted | +| `auto` (default) | allowed — the file was deleted | +| `bypassPermissions` | allowed, by definition | + +So `acceptEdits` is **not** a middle setting that withholds the shell. +`manual` is the one mode that meaningfully restrains a delegated run: with nobody +to ask, it denies anything needing approval, and the denials come back in the +transcript. Use it when you want the agent to work and then stop at the first +thing you would have wanted to be asked about. + +```sh +ike delegate 3 --permission-mode manual +``` + +One trap worth knowing if you test this yourself: commands the harness +classifies as safe — `echo`, `ls` — run under *every* mode including `manual`, +so a harmless command shows no difference between any of them. + +The practical control is therefore the gate — whether ike starts an agent at all +— rather than the mode it starts it in. + +Each task remembers a **working directory**. The first run stores the one you +give it, or the directory you ran from, so `cd ~/dev/thing && ike delegate 3` +does the obvious thing and later runs go back to the same project wherever you +start them. + +In the TUI a run takes over the screen and streams. `esc` detaches and the run +keeps going — the task shows `⣾` in the matrix and `D` on it reattaches. `ctrl+c` +in the run view stops the run. Quitting ike stops it too: the agent is a child +process, and ike will not leave one running with nothing reading it. + +> **The same caveat as MCP.** The gate is a consent mechanism, not a security +> boundary. It decides whether *ike* starts an agent; it does nothing about what +> that agent then does, which is governed by Claude Code's own permissions. And +> anyone who can run commands as you can run `claude` directly. + +Plans are stored beside the data file, one file per task, in +`tasks.json.plans//.md` — not inside `tasks.json`, so a few KB of +markdown per task is not copied into every undo snapshot. Deleting a task leaves +its plan, so undoing the delete brings both back; `ike plan --prune` sweeps the +ones left behind. Note that plans do **not** travel with `ike space export` yet. + +Delegation needs `claude` on your `PATH`; `ike agent status` says whether it +found it. Set `IKE_AGENT_CMD` to point at a different binary or a wrapper. + ## Data Tasks live in `$XDG_DATA_HOME/ike/tasks.json` (default diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..1163043 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,347 @@ +// Package agent runs the Claude Code CLI as a subprocess and streams what it +// does back as events. +// +// It is the only package in ike that starts a process. Everything else — which +// task is being delegated, whether the user has consented, what to do with the +// result — belongs to the caller, in the same way internal/mcpserver is a pure +// transport with the business logic behind it in the store. +// +// The CLI is used rather than an SDK or the API directly because it brings the +// user's own setup with it: their authentication, their settings, their +// per-project CLAUDE.md. A task delegated from ike should behave the way the +// same request typed into `claude` would. +package agent + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "slices" + "strings" + "sync" +) + +// Mode is what a run is for. +type Mode int + +const ( + // ModePlan drafts a plan. It runs with --permission-mode plan, so it can + // read the working directory but not change it. + ModePlan Mode = iota + // ModeExecute carries the task out. + ModeExecute +) + +// DefaultPermissionMode is what an execute run uses unless told otherwise. +// +// auto is the mode meant for unattended work, which is what a delegated run is. +// +// Be clear about what it does and does not restrain, because the names invite a +// wrong guess. Measured against a real headless run, asking the agent to +// `rm` a file: +// +// manual denied — the file survived +// acceptEdits allowed — the file was deleted +// auto allowed — the file was deleted +// bypassPermissions allowed, by definition +// +// So acceptEdits does *not* hold the shell back; it is not a middle setting +// between "edits" and "everything". Note also that a command the harness +// classifies as safe — `echo hi`, `ls` — runs under every mode including +// manual, so testing with a harmless command shows no difference between any of +// them and proves nothing. +// +// manual is the only mode that meaningfully restrains a delegated run: it +// denies anything needing approval, since a headless run has nobody to ask, and +// the denials come back in the transcript. Reach for it when you want the agent +// to work and then stop at the first thing you would want to be asked about. +// +// The real control on a delegated run is therefore the consent gate — whether +// ike starts an agent at all — rather than the mode it starts it in. +const DefaultPermissionMode = "auto" + +// PermissionModes are the values the CLI accepts, in its own order. +// +// Validated here rather than passed straight through so a typo fails before a +// process is started, with a message naming the alternatives. Left to the CLI, +// `--permission-mode acceptedits` becomes an exec that dies on an unknown +// option, surfacing as a failed run rather than a mistyped flag. +var PermissionModes = []string{"acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"} + +// ValidatePermissionMode checks a user-supplied mode for an execute run. An +// empty mode means DefaultPermissionMode. +func ValidatePermissionMode(mode string) error { + if mode == "" { + return nil + } + if mode == "plan" { + // Accepted by the CLI, but it would make a delegated run read-only — + // which is `ike plan`, and is confusing enough as a silent outcome to + // be worth naming. + return errors.New("--permission-mode plan would make the run read-only; " + + "use `ike plan ` to draft a plan instead") + } + if slices.Contains(PermissionModes, mode) { + return nil + } + return fmt.Errorf("unknown permission mode %q; expected one of %s", + mode, strings.Join(slices.DeleteFunc(slices.Clone(PermissionModes), + func(m string) bool { return m == "plan" }), ", ")) +} + +// binEnv overrides the binary ike runs. It exists for tests, and for anyone +// whose claude is not on PATH or who wants to wrap it. +const binEnv = "IKE_AGENT_CMD" + +var ( + errNoDir = errors.New("a delegated run needs a working directory") + errNoSession = errors.New("a session needs an id; generate one with NewSessionID") +) + +// Spec describes one run. +type Spec struct { + Mode Mode + // Title is the task being delegated. + Title string + // Quadrant is the task's display label, which tells the agent how the user + // classified the work. + Quadrant string + // Plan is the attached plan: revised by a ModePlan run, followed by a + // ModeExecute one. It may be empty. + Plan string + // Dir is the working directory. It must be absolute and exist; the store's + // CheckDir is what guarantees that. + Dir string + // PermissionMode overrides DefaultPermissionMode on an execute run. + PermissionMode string + // Model optionally overrides the model, e.g. "opus" or "sonnet". + Model string + // Effort optionally overrides how hard the agent works. Empty lets + // ResolveEffort choose from the mode and whether a plan is attached. + Effort string +} + +// Run is a live agent process. +type Run struct { + cmd *exec.Cmd + events chan Event + cancel context.CancelFunc + + // stderr is kept so a process that dies without a result event can say why. + stderr *strings.Builder + + once sync.Once +} + +// Events returns the run's event stream. It is closed when the run ends, which +// is the signal that no more events are coming. +func (r *Run) Events() <-chan Event { return r.events } + +// Cancel stops the run. It is safe to call more than once, and on a run that +// has already finished. +func (r *Run) Cancel() { r.once.Do(func() { r.kill() }) } + +// Start launches the agent and begins streaming. +// +// The returned Run is live: read Events until it closes. Start itself only +// fails on problems visible before the process is running — no binary, a bad +// spec — so anything after that arrives as a KindError event rather than a +// second error return. +func Start(ctx context.Context, s Spec) (*Run, error) { + bin, err := lookBinary() + if err != nil { + return nil, err + } + if s.Dir == "" { + return nil, errNoDir + } + + ctx, cancel := context.WithCancel(ctx) + cmd := exec.CommandContext(ctx, bin, args(s)...) + cmd.Dir = s.Dir + // Left nil on purpose, which gives the child /dev/null. The CLI waits for + // stdin when it is a terminal — an ordinary run prints "no stdin data + // received in 3s" and pauses for exactly that long — and a child sharing + // ike's stdin would be reading the keys meant for the TUI. + cmd.Stdin = nil + // Its own process group, so Cancel can signal the whole tree. The agent + // spawns subprocesses of its own, and killing only the parent would leave + // them running against the user's files after the run looked stopped. + setProcessGroup(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, err + } + var stderr strings.Builder + cmd.Stderr = &limitedWriter{w: &stderr, n: maxStderr} + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("starting %s: %w", bin, err) + } + + r := &Run{ + cmd: cmd, + events: make(chan Event, eventBuffer), + cancel: cancel, + stderr: &stderr, + } + go r.stream(stdout) + return r, nil +} + +const ( + // eventBuffer keeps the reader ahead of a frontend that is mid-render, so + // the agent is never blocked by the speed of the screen. + eventBuffer = 64 + // maxStderr bounds what is kept from stderr. It is only ever shown when a + // run dies without a result, and a runaway process could otherwise fill + // memory with warnings. + maxStderr = 8 << 10 + // maxLine bounds one line of stdout. The default scanner limit is 64K, + // which a single tool_result event exceeds easily. + maxLine = 4 << 20 +) + +// stream reads the process's stdout to completion, then reaps it. +func (r *Run) stream(stdout io.ReadCloser) { + defer close(r.events) + defer r.cancel() + + sawResult := false + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64<<10), maxLine) + for sc.Scan() { + for _, e := range parseLine(sc.Text()) { + if e.Kind == KindResult { + sawResult = true + } + r.events <- e + } + } + // A scan error is worth reporting, but not if it is just the pipe closing + // because the run was canceled. + if err := sc.Err(); err != nil && !sawResult { + r.events <- Event{Kind: KindError, Text: clean("reading the agent's output: " + err.Error())} + } + + err := r.cmd.Wait() + if sawResult { + // The agent said how it went, which is the more useful answer. A + // non-zero exit after a result event just repeats it. + return + } + if err == nil { + return + } + r.events <- Event{Kind: KindError, Text: clean(exitMessage(err, r.stderr.String()))} +} + +// exitMessage explains a process that ended without saying how it went. +func exitMessage(err error, stderr string) string { + msg := "the agent exited without finishing: " + err.Error() + // stderr is where the CLI puts its own diagnostics — a bad flag, an auth + // problem, a directory it will not trust — so it usually holds the actual + // reason. It is shown only here, because in a normal run it is noise. + if s := strings.TrimSpace(stderr); s != "" { + msg += "\n" + s + } + return msg +} + +// kill stops the agent and everything it started. +// +// Best effort throughout: the process may already have exited, and cancel() +// closes the context either way, so there is nothing useful to report here. +func (r *Run) kill() { + r.cancel() + if r.cmd.Process == nil { + return + } + killProcessGroup(r.cmd) +} + +// args builds the command line. +// +// Every flag here is load-bearing: +// - -p is what makes the run non-interactive. +// - --output-format stream-json is the machine-readable stream. +// - --verbose is required *with* it; without --verbose the stream carries +// nothing useful. +// - --permission-mode plan is what makes a planning run read-only. +// +// The prompt is passed as an argument rather than on stdin so that stdin stays +// closed; see the note in Start. +func args(s Spec) []string { + out := []string{ + "-p", prompt(s), + "--output-format", "stream-json", + "--verbose", + } + switch s.Mode { + case ModePlan: + out = append(out, "--permission-mode", "plan") + case ModeExecute: + mode := s.PermissionMode + if mode == "" { + mode = DefaultPermissionMode + } + out = append(out, "--permission-mode", mode) + } + if s.Model != "" { + out = append(out, "--model", s.Model) + } + // Always passed, because ike always has an answer: ResolveEffort falls back + // to a recommendation rather than to "unset". Leaving it off for a run with + // no --effort would mean the plan/execute distinction silently stopped + // mattering, which is the point of choosing at all. + level, _ := ResolveEffort(s.Mode, s.Effort, s.Plan != "") + out = append(out, "--effort", level) + return out +} + +// lookBinary finds the claude CLI. +func lookBinary() (string, error) { + if override := os.Getenv(binEnv); override != "" { + return override, nil + } + bin, err := exec.LookPath("claude") + if err != nil { + // The bare LookPath error is "executable file not found in $PATH", + // which does not tell someone who has never installed it what to do. + // Multi-line and sentence-punctuated against the usual convention for + // error strings, for the reason mcpDisabledMsg gives: the whole value + // of the message is the command it names. + //nolint:staticcheck // ST1005: formatted for a human, not a caller. + return "", fmt.Errorf("ike delegates by running the Claude Code CLI, and `claude` "+ + "is not on your PATH.\nInstall it from https://claude.com/claude-code, or set %s "+ + "to its full path.", binEnv) + } + return bin, nil +} + +// limitedWriter keeps the first n bytes written to it and discards the rest. +type limitedWriter struct { + w *strings.Builder + n int +} + +func (l *limitedWriter) Write(p []byte) (int, error) { + full := len(p) + if room := l.n - l.w.Len(); room > 0 { + if len(p) > room { + p = p[:room] + } + l.w.Write(p) + } + // The full length, captured before the truncation above: reporting the + // number of bytes actually kept would look like a short write, and the + // child would treat its own stderr as broken. + return full, nil +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..87fabdb --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,683 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The tests drive a fake `claude` rather than the real one: a run costs money, +// needs credentials, and would make the suite depend on a model's wording. The +// fake is this test binary re-executed with a marker in its environment — the +// standard trick — so there is no shell script to keep executable and no +// second language in the repo. +// +// What it replays is a real transcript. testdata/toolrun.jsonl was captured +// from an actual `claude -p --output-format stream-json --verbose` run that +// read a file, ran a command, and wrote a file, so the parser is checked +// against the format as it really arrives rather than as this package imagines +// it. + +const ( + helperEnv = "IKE_TEST_HELPER" + helperFixture = "IKE_TEST_FIXTURE" + helperExit = "IKE_TEST_EXIT" + helperStderr = "IKE_TEST_STDERR" + helperSleep = "IKE_TEST_SLEEP" +) + +// TestMain lets this binary stand in for the claude CLI when the marker is set. +func TestMain(m *testing.M) { + if os.Getenv(helperEnv) == "" { + os.Exit(m.Run()) + } + + if s := os.Getenv(helperSleep); s != "" { + // Long enough that the test cancels first, and bounded so a failing + // test cannot wedge the suite. + time.Sleep(time.Minute) + } + if f := os.Getenv(helperFixture); f != "" { + b, err := os.ReadFile(f) + if err != nil { + panic(err) + } + os.Stdout.Write(b) + } + if e := os.Getenv(helperStderr); e != "" { + os.Stderr.WriteString(e) + } + if os.Getenv(helperExit) == "1" { + os.Exit(1) + } + os.Exit(0) +} + +// fakeAgent points Start at this test binary, configured by env. +func fakeAgent(t *testing.T, env map[string]string) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(binEnv, exe) + t.Setenv(helperEnv, "1") + for k, v := range env { + t.Setenv(k, v) + } +} + +// collect drains a run to completion. +func collect(t *testing.T, r *Run) []Event { + t.Helper() + var out []Event + done := make(chan struct{}) + go func() { + defer close(done) + for e := range r.Events() { + out = append(out, e) + } + }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("the run did not finish") + } + return out +} + +func fixture(t *testing.T, name string) string { + t.Helper() + p, err := filepath.Abs(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + return p +} + +func startRun(t *testing.T, s Spec) *Run { + t.Helper() + if s.Dir == "" { + s.Dir = t.TempDir() + } + if s.Title == "" { + s.Title = "a task" + } + r, err := Start(context.Background(), s) + if err != nil { + t.Fatal(err) + } + t.Cleanup(r.Cancel) + return r +} + +// The end-to-end shape of a real run: it starts, does some work with tools, and +// finishes with a result. +func TestRunStreamsARealTranscript(t *testing.T) { + fakeAgent(t, map[string]string{helperFixture: fixture(t, "toolrun.jsonl")}) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + if len(events) == 0 { + t.Fatal("no events") + } + if events[0].Kind != KindStarted { + t.Errorf("first event kind = %v, want KindStarted", events[0].Kind) + } + if events[0].SessionID == "" { + t.Error("the start event should carry the session ID") + } + if events[0].Model == "" { + t.Error("the start event should carry the model") + } + + last := events[len(events)-1] + if last.Kind != KindResult { + t.Fatalf("last event kind = %v, want KindResult", last.Kind) + } + if last.IsError { + t.Error("the fixture is a successful run") + } + if last.Text == "" { + t.Error("the result should carry the agent's closing summary") + } + if last.CostUSD <= 0 { + t.Error("the result should carry the run's cost") + } + + // The tools the fixture run actually used, in order. + var tools []string + for _, e := range events { + if e.Kind == KindTool { + tools = append(tools, e.Tool) + } + } + want := []string{"Read", "Bash", "Write"} + if strings.Join(tools, ",") != strings.Join(want, ",") { + t.Errorf("tools = %v, want %v", tools, want) + } + + var text int + for _, e := range events { + if e.Kind == KindText { + text++ + } + } + if text == 0 { + t.Error("the fixture contains text blocks; none were surfaced") + } + + // The fixture's thinking blocks carry a signature and no text — reasoning + // withheld, which is the common case in a real run. They must not become + // blank transcript lines. + for _, e := range events { + if e.Kind == KindThinking && strings.TrimSpace(e.Text) == "" { + t.Error("an empty thinking block became a transcript line") + } + } +} + +// A thinking block that does carry text should surface, and one that does not +// should be dropped. Both shapes occur in the same run. +func TestThinkingBlocks(t *testing.T) { + lines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"","signature":"abc"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"weighing two options"}]}}`, + `{"type":"result","subtype":"success","result":"done"}`, + }, "\n") + "\n" + + p := filepath.Join(t.TempDir(), "stream.jsonl") + if err := os.WriteFile(p, []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + fakeAgent(t, map[string]string{helperFixture: p}) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + var thoughts []string + for _, e := range events { + if e.Kind == KindThinking { + thoughts = append(thoughts, e.Text) + } + } + if len(thoughts) != 1 || thoughts[0] != "weighing two options" { + t.Errorf("thinking events = %q, want just the one with text", thoughts) + } +} + +// The stream carries event types this package has never heard of, and the CLI +// adds more between releases. Skipping them must not end the run. +func TestUnknownEventTypesAreSkipped(t *testing.T) { + lines := strings.Join([]string{ + `{"type":"system","subtype":"init","session_id":"abc","model":"claude-opus-5"}`, + `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"}}`, + `{"type":"system","subtype":"thinking_tokens","tokens":123}`, + `{"type":"some_future_event","payload":{"nested":[1,2,3]}}`, + `{"type":"user","message":{"content":[{"type":"tool_result","content":"huge"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}`, + `{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.01}`, + }, "\n") + "\n" + + p := filepath.Join(t.TempDir(), "stream.jsonl") + if err := os.WriteFile(p, []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + fakeAgent(t, map[string]string{helperFixture: p}) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + var kinds []Kind + for _, e := range events { + kinds = append(kinds, e.Kind) + } + want := []Kind{KindStarted, KindText, KindResult} + if len(kinds) != len(want) { + t.Fatalf("kinds = %v, want %v", kinds, want) + } + for i := range want { + if kinds[i] != want[i] { + t.Fatalf("kinds = %v, want %v", kinds, want) + } + } +} + +// A stray non-JSON write — from a wrapper script, a plugin, a shell profile — +// must not end a run that is otherwise fine. +func TestGarbageLinesDoNotEndTheRun(t *testing.T) { + lines := strings.Join([]string{ + `Warning: something entirely unstructured`, + `{"type":"system","subtype":"init","session_id":"abc"}`, + `{"type":"assistant","message":{"content":[{"type":"text",`, // truncated JSON + ``, + ` `, + `{"type":"assistant","message":{"content":[{"type":"text","text":"still here"}]}}`, + `{"type":"result","subtype":"success","result":"done"}`, + }, "\n") + "\n" + + p := filepath.Join(t.TempDir(), "stream.jsonl") + if err := os.WriteFile(p, []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + fakeAgent(t, map[string]string{helperFixture: p}) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + var sawText, sawResult bool + for _, e := range events { + if e.Kind == KindText && e.Text == "still here" { + sawText = true + } + if e.Kind == KindResult { + sawResult = true + } + if e.Kind == KindError { + t.Errorf("a garbage line produced an error event: %q", e.Text) + } + } + if !sawText || !sawResult { + t.Error("the run did not survive the garbage lines") + } +} + +// Agent output is the most untrusted text ike renders. An escape sequence in it +// must not reach the screen. +func TestAgentTextIsSanitized(t *testing.T) { + // A tool name and a result carrying an escape and a carriage return. + lines := `{"type":"assistant","message":{"content":[{"type":"text","text":"real\u001b[2K\rFAKE\n\nsecond line"}]}}` + "\n" + + `{"type":"result","subtype":"success","result":"done\u001b]0;pwned\u0007"}` + "\n" + + p := filepath.Join(t.TempDir(), "stream.jsonl") + if err := os.WriteFile(p, []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + fakeAgent(t, map[string]string{helperFixture: p}) + + for _, e := range collect(t, startRun(t, Spec{Mode: ModeExecute})) { + if strings.ContainsRune(e.Text, 0x1b) || strings.ContainsRune(e.Text, '\r') { + t.Errorf("%v event text still carries control characters: %q", e.Kind, e.Text) + } + // Line structure survives — that is the whole difference between + // SanitizeBlock and SanitizeDisplay. + if e.Kind == KindText && !strings.Contains(e.Text, "\n\nsecond line") { + t.Errorf("sanitizing flattened the text: %q", e.Text) + } + } +} + +// A process that dies without a result event has to explain itself, and stderr +// is where the CLI puts the reason. +func TestFailureWithoutAResultReportsStderr(t *testing.T) { + fakeAgent(t, map[string]string{ + helperExit: "1", + helperStderr: "error: unknown option '--nonsense'", + }) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + if len(events) != 1 || events[0].Kind != KindError { + t.Fatalf("events = %+v, want one KindError", events) + } + if !strings.Contains(events[0].Text, "--nonsense") { + t.Errorf("the error should carry stderr, got %q", events[0].Text) + } +} + +// A non-zero exit *after* the agent reported its own result is not a second +// failure to report — the result already said how it went. +func TestResultWinsOverExitCode(t *testing.T) { + p := filepath.Join(t.TempDir(), "stream.jsonl") + line := `{"type":"result","subtype":"error_during_execution","is_error":true,"result":"I could not finish"}` + "\n" + if err := os.WriteFile(p, []byte(line), 0o600); err != nil { + t.Fatal(err) + } + fakeAgent(t, map[string]string{helperFixture: p, helperExit: "1"}) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + if len(events) != 1 { + t.Fatalf("events = %+v, want just the result", events) + } + if events[0].Kind != KindResult || !events[0].IsError { + t.Errorf("event = %+v, want a failed KindResult", events[0]) + } +} + +// A result subtype other than "success" is a failure even when is_error is +// absent — the CLI uses subtypes like error_during_execution and +// error_max_turns. +func TestNonSuccessSubtypeIsAFailure(t *testing.T) { + p := filepath.Join(t.TempDir(), "stream.jsonl") + line := `{"type":"result","subtype":"error_max_turns","result":"ran out of turns"}` + "\n" + if err := os.WriteFile(p, []byte(line), 0o600); err != nil { + t.Fatal(err) + } + fakeAgent(t, map[string]string{helperFixture: p}) + events := collect(t, startRun(t, Spec{Mode: ModeExecute})) + + if len(events) != 1 || !events[0].IsError { + t.Errorf("events = %+v, want a failed result", events) + } +} + +func TestCancelStopsTheRun(t *testing.T) { + fakeAgent(t, map[string]string{helperSleep: "1"}) + r := startRun(t, Spec{Mode: ModeExecute}) + + go func() { + time.Sleep(50 * time.Millisecond) + r.Cancel() + }() + + // The channel closing is the contract: a canceled run ends its stream. + done := make(chan struct{}) + go func() { + defer close(done) + for range r.Events() { + } + }() + select { + case <-done: + case <-time.After(20 * time.Second): + t.Fatal("Cancel did not stop the run") + } + + // Calling it again, and after the run has ended, must be safe. + r.Cancel() + r.Cancel() +} + +func TestCancellingTheContextStopsTheRun(t *testing.T) { + fakeAgent(t, map[string]string{helperSleep: "1"}) + ctx, cancel := context.WithCancel(context.Background()) + r, err := Start(ctx, Spec{Mode: ModeExecute, Title: "a task", Dir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + collect(t, r) +} + +func TestStartRequiresADirectory(t *testing.T) { + fakeAgent(t, nil) + if _, err := Start(context.Background(), Spec{Mode: ModeExecute, Title: "a task"}); err == nil { + t.Error("a run with no working directory should be refused") + } +} + +// The run happens where the task says it does. +func TestRunUsesTheSpecifiedDirectory(t *testing.T) { + dir := t.TempDir() + fakeAgent(t, nil) + r := startRun(t, Spec{Mode: ModeExecute, Dir: dir}) + collect(t, r) + + if r.cmd.Dir != dir { + t.Errorf("cmd.Dir = %q, want %q", r.cmd.Dir, dir) + } +} + +// Someone who has never installed the CLI should be told what to install, not +// handed exec.LookPath's wording. +func TestMissingBinaryExplainsItself(t *testing.T) { + t.Setenv(binEnv, "") + t.Setenv("PATH", t.TempDir()) + + _, err := Start(context.Background(), Spec{Mode: ModeExecute, Title: "a task", Dir: t.TempDir()}) + if err == nil { + t.Fatal("expected an error with no claude on PATH") + } + if !strings.Contains(err.Error(), "claude.com/claude-code") || !strings.Contains(err.Error(), binEnv) { + t.Errorf("error = %q; it should say how to install it and how to override it", err) + } +} + +func TestArgs(t *testing.T) { + joined := func(s Spec) string { return strings.Join(args(s), " ") } + + plan := joined(Spec{Mode: ModePlan, Title: "t", Dir: "/tmp"}) + // --verbose is not optional: without it, --output-format stream-json emits + // nothing useful. + for _, want := range []string{"-p", "--output-format stream-json", "--verbose", "--permission-mode plan"} { + if !strings.Contains(joined(Spec{Mode: ModePlan}), want) { + t.Errorf("plan args missing %q: %s", want, plan) + } + } + + run := joined(Spec{Mode: ModeExecute}) + if !strings.Contains(run, "--permission-mode "+DefaultPermissionMode) { + t.Errorf("execute args = %s, want the default permission mode", run) + } + if strings.Contains(run, "--model") { + t.Error("no model should be passed unless one was asked for") + } + + custom := joined(Spec{Mode: ModeExecute, PermissionMode: "bypassPermissions", Model: "opus"}) + if !strings.Contains(custom, "--permission-mode bypassPermissions") || !strings.Contains(custom, "--model opus") { + t.Errorf("overrides not applied: %s", custom) + } +} + +func TestValidatePermissionMode(t *testing.T) { + for _, mode := range []string{"", "auto", "acceptEdits", "manual", "dontAsk", "bypassPermissions"} { + if err := ValidatePermissionMode(mode); err != nil { + t.Errorf("ValidatePermissionMode(%q) = %v, want it accepted", mode, err) + } + } + + // A typo must fail before a process is started, naming the alternatives — + // left to the CLI it would die on an unknown option and look like a failed + // run rather than a mistyped flag. + err := ValidatePermissionMode("acceptedits") + if err == nil { + t.Fatal("a misspelled mode should be refused") + } + if !strings.Contains(err.Error(), "acceptEdits") { + t.Errorf("error = %q, it should list the valid modes", err) + } + + // plan is a real mode, but on a delegated run it would silently make it + // read-only — which is what `ike plan` is for. + err = ValidatePermissionMode("plan") + if err == nil { + t.Fatal("plan should be refused for a delegated run") + } + if !strings.Contains(err.Error(), "ike plan") { + t.Errorf("error = %q, it should point at `ike plan`", err) + } + if strings.Contains(ValidatePermissionMode("nope").Error(), "plan,") { + t.Error("plan should not be offered as an alternative, having just been refused") + } +} + +// The default is the mode meant for unattended work. This is a reminder more +// than a check: it is not a restraint, and the mode names invite the opposite +// guess — see DefaultPermissionMode's comment for the measurements. +func TestDefaultPermissionModeIsAuto(t *testing.T) { + if DefaultPermissionMode != "auto" { + t.Errorf("DefaultPermissionMode = %q, want auto", DefaultPermissionMode) + } + if !strings.Contains(strings.Join(args(Spec{Mode: ModeExecute}), " "), "--permission-mode auto") { + t.Error("an execute run should carry the default mode") + } +} + +func TestNewSessionID(t *testing.T) { + seen := map[string]bool{} + for range 100 { + id, err := NewSessionID() + if err != nil { + t.Fatal(err) + } + // --session-id requires a valid UUID, so the shape is not cosmetic. + if len(id) != 36 { + t.Fatalf("id = %q, want 36 characters", id) + } + for _, i := range []int{8, 13, 18, 23} { + if id[i] != '-' { + t.Fatalf("id = %q, want dashes at the UUID positions", id) + } + } + if id[14] != '4' { + t.Errorf("id = %q, want version 4", id) + } + if !strings.ContainsRune("89ab", rune(id[19])) { + t.Errorf("id = %q, want an RFC 4122 variant nibble", id) + } + if seen[id] { + t.Fatalf("id %q was generated twice", id) + } + seen[id] = true + } +} + +// A new conversation pins the ID ike chose; a later visit resumes it. That is +// what makes the conversation a property of the task rather than of one visit. +func TestSessionArgsPinThenResume(t *testing.T) { + const id = "d3c56c08-c430-455d-8f2c-849ff6294610" + s := Session{Mode: ModePlan, Title: "ship v2", Dir: "/tmp", SessionID: id} + + first := strings.Join(sessionArgs(s), "\x00") + if !strings.Contains(first, "--session-id\x00"+id) { + t.Errorf("a new session should pin the id: %q", first) + } + if strings.Contains(first, "--resume") { + t.Error("a new session must not try to resume") + } + if !strings.Contains(first, "ship v2") { + t.Error("a new session should carry the opening brief") + } + + s.Resume = true + again := strings.Join(sessionArgs(s), "\x00") + if !strings.Contains(again, "--resume\x00"+id) { + t.Errorf("a later visit should resume: %q", again) + } + // No brief on resume: the conversation already knows the task, and + // restating it would undo the point of resuming. + if strings.Contains(again, "ship v2") { + t.Errorf("a resumed session should not repeat the brief: %q", again) + } + // But there must still be *a* prompt. --resume with none fails outright + // with "Provide a prompt to continue the conversation", and the session + // never opens — which is invisible until you try to come back to one. + if !strings.Contains(again, resumePrompt) { + t.Errorf("a resumed session needs a prompt of some kind: %q", again) + } +} + +// Talking a plan through must not be able to change the directory it is about, +// the same guarantee a headless planning run gives. +func TestPlanSessionIsReadOnly(t *testing.T) { + got := strings.Join(sessionArgs(Session{ + Mode: ModePlan, SessionID: "x", PermissionMode: "bypassPermissions", + }), " ") + if !strings.Contains(got, "--permission-mode plan") { + t.Errorf("args = %q, want plan mode", got) + } + if strings.Contains(got, "bypassPermissions") { + t.Error("a permission override must not apply to a planning conversation") + } +} + +// The brief names the draft path, and that instruction stays in the +// conversation's history — which is why the path has to be stable per task. +func TestSessionPromptNamesTheDraftPath(t *testing.T) { + const draft = "/data/tasks.json.plans/default/3.draft.md" + got := sessionPrompt(Session{Mode: ModePlan, Title: "ship v2", Dir: "/tmp", DraftPath: draft}) + if !strings.Contains(got, draft) { + t.Errorf("the brief should name where to leave the plan:\n%s", got) + } + if !strings.Contains(got, "ship v2") { + t.Error("the brief should name the task") + } + + // Without a draft path there is nothing to say about one. + if strings.Contains(sessionPrompt(Session{Mode: ModePlan, Dir: "/tmp"}), "write it") { + t.Error("no draft path means no instruction about writing one") + } +} + +// The terminal belongs to the caller: the CLI hands over the real one and the +// TUI lets tea.ExecProcess do it, which only fills in streams still nil. +func TestInteractiveCommandLeavesTheTerminalToTheCaller(t *testing.T) { + fakeAgent(t, nil) + dir := t.TempDir() + + c, err := InteractiveCommand(context.Background(), Session{ + Mode: ModePlan, Title: "ship v2", Dir: dir, SessionID: "abc", + }) + if err != nil { + t.Fatal(err) + } + if c.Stdin != nil || c.Stdout != nil || c.Stderr != nil { + t.Error("a session's streams must be left for the caller to supply") + } + if c.Dir != dir { + t.Errorf("cmd.Dir = %q, want %q", c.Dir, dir) + } + // No process group: this is the foreground job and must receive the ctrl+c + // you type at it, which its own group would prevent. + if c.SysProcAttr != nil { + t.Error("a session must not be put in its own process group") + } +} + +func TestInteractiveCommandRequiresDirAndSession(t *testing.T) { + fakeAgent(t, nil) + if _, err := InteractiveCommand(context.Background(), Session{SessionID: "abc"}); err == nil { + t.Error("a session with no working directory should be refused") + } + if _, err := InteractiveCommand(context.Background(), Session{Dir: t.TempDir()}); err == nil { + t.Error("a session with no id should be refused — it would be unresumable") + } + if _, err := InteractiveCommand(context.Background(), Session{ + Mode: ModeExecute, Dir: t.TempDir(), SessionID: "abc", PermissionMode: "nonsense", + }); err == nil { + t.Error("a bad permission mode should be refused") + } +} + +// A planning run must not be able to change the directory it is exploring. +func TestPlanModeIsReadOnly(t *testing.T) { + a := args(Spec{Mode: ModePlan, PermissionMode: "bypassPermissions"}) + joined := strings.Join(a, " ") + if !strings.Contains(joined, "--permission-mode plan") { + t.Errorf("plan args = %s, want --permission-mode plan", joined) + } + if strings.Contains(joined, "bypassPermissions") { + t.Error("a PermissionMode override must not apply to a planning run; " + + "drafting a plan is supposed to be read-only") + } +} + +func TestLimitedWriter(t *testing.T) { + var b strings.Builder + w := &limitedWriter{w: &b, n: 10} + + n, err := w.Write([]byte("12345")) + if n != 5 || err != nil { + t.Fatalf("Write = %d, %v", n, err) + } + // Over the limit: the writer must still report the full length, or the + // child sees a short write and stops. + n, err = w.Write([]byte("678901234567890")) + if n != 15 || err != nil { + t.Fatalf("Write = %d, %v; a short write would stall the child", n, err) + } + if b.String() != "1234567890" { + t.Errorf("kept %q, want the first 10 bytes", b.String()) + } +} + +// Guards against exec.Cmd being replaced with something that shares ike's +// stdin. The TUI owns the terminal, and a child reading from it would eat the +// user's keystrokes. +func TestChildDoesNotInheritStdin(t *testing.T) { + fakeAgent(t, nil) + r := startRun(t, Spec{Mode: ModeExecute}) + collect(t, r) + + if r.cmd.Stdin != nil { + t.Error("the child must not share ike's stdin") + } +} diff --git a/internal/agent/effort.go b/internal/agent/effort.go new file mode 100644 index 0000000..3b7c608 --- /dev/null +++ b/internal/agent/effort.go @@ -0,0 +1,79 @@ +package agent + +import ( + "fmt" + "slices" + "strings" +) + +// Effort is how hard the agent works: how deeply it thinks, how many tools it +// reaches for, how much it says on the way. It is a finer dial than the model, +// and on a delegated run it is usually the one that matters — a run's wall +// clock is dominated by how many turns it takes, and effort is what moves that. +// +// ike picks a level rather than leaving the CLI's own default (high) in place +// for every run, because the two things ike delegates are not the same shape of +// work. Drafting a plan *is* the reasoning; carrying out a plan that has +// already been reviewed is mostly follow-through, and paying to re-derive +// decisions the plan already records buys nothing. +// +// The choice is a judgement about how much thinking a run has left to do, not a +// measurement — so it is always visible in the run header, and --effort always +// wins. Anything ike guesses about a task should be arguable by the person +// whose task it is. + +// EffortLevels are the values the CLI accepts, in its own order — least effort +// first, so the slice doubles as the scale. +// +// Validated here rather than passed straight through, for the reason +// PermissionModes gives: left to the CLI, `--effort mid` becomes an exec that +// dies on a bad option, and a mistyped flag surfaces as a failed run. +var EffortLevels = []string{"low", "medium", "high", "xhigh", "max"} + +// ValidateEffort checks a user-supplied level. Empty means "no preference", +// which is what lets ResolveEffort choose. +func ValidateEffort(level string) error { + if level == "" || slices.Contains(EffortLevels, level) { + return nil + } + return fmt.Errorf("unknown effort %q; expected one of %s", + level, strings.Join(EffortLevels, ", ")) +} + +// The reasons, as constants so the frontends and the tests name the same +// strings rather than three copies of the same sentence drifting apart. +const ( + effortDrafting = "drafting a plan" + effortNoPlan = "no plan to follow" + effortFollowing = "following an attached plan" +) + +// ResolveEffort reports the effort a run will use and why, given what the user +// asked for and what the run has to work with. +// +// An explicit level comes back with no reason: there is nothing to explain +// about a flag someone typed, and an empty reason is what tells a caller to +// print the level on its own. +// +// It is a pure function of its arguments precisely so the frontends can call it +// to render what is about to happen while args builds the same answer for the +// command line. Two calls, one definition — the alternative is a spec that runs +// at one level and a header that claims another. +func ResolveEffort(mode Mode, requested string, hasPlan bool) (level, reason string) { + if requested != "" { + return requested, "" + } + switch { + case mode == ModePlan: + // The plan is the product. Thinking is the work, not overhead on it. + return "high", effortDrafting + case hasPlan: + // The approach was decided and reviewed already; this run is carrying + // it out. Stepping down is the whole point of having attached a plan. + return "medium", effortFollowing + default: + // No plan, so the agent has to work out the approach as well as do it — + // which is the planning run's job folded into this one. + return "high", effortNoPlan + } +} diff --git a/internal/agent/effort_test.go b/internal/agent/effort_test.go new file mode 100644 index 0000000..1943cc2 --- /dev/null +++ b/internal/agent/effort_test.go @@ -0,0 +1,138 @@ +package agent + +import ( + "strings" + "testing" +) + +func TestValidateEffort(t *testing.T) { + // Empty is not an error: it is how a caller says "no preference", which is + // what lets ResolveEffort choose. + for _, level := range []string{"", "low", "medium", "high", "xhigh", "max"} { + if err := ValidateEffort(level); err != nil { + t.Errorf("ValidateEffort(%q) = %v, want it accepted", level, err) + } + } + + // A typo must fail before a process is started, naming the alternatives — + // left to the CLI it dies on an unknown option and looks like a failed run + // rather than the mistyped flag it is. + err := ValidateEffort("mid") + if err == nil { + t.Fatal("a bad effort level should be refused") + } + for _, want := range []string{"medium", "xhigh", "max"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, it should list %q as an alternative", err, want) + } + } + + // Levels the CLI would reject, and the two shapes of near-miss most likely + // to be typed: a level from another scale, and one with the wrong casing. + for _, level := range []string{"none", "highest", "High", "XHIGH", "1"} { + if err := ValidateEffort(level); err == nil { + t.Errorf("ValidateEffort(%q) = nil, want it refused", level) + } + } +} + +// The recommendation is the feature: a plan run and a run following a reviewed +// plan are different shapes of work, and this is where ike says so. +func TestResolveEffort(t *testing.T) { + for _, tc := range []struct { + name string + mode Mode + requested string + hasPlan bool + wantLevel string + wantReason string + }{ + {"planning is the reasoning", ModePlan, "", false, "high", effortDrafting}, + {"revising a plan is still planning", ModePlan, "", true, "high", effortDrafting}, + {"no plan means working it out too", ModeExecute, "", false, "high", effortNoPlan}, + {"an attached plan is follow-through", ModeExecute, "", true, "medium", effortFollowing}, + } { + t.Run(tc.name, func(t *testing.T) { + level, reason := ResolveEffort(tc.mode, tc.requested, tc.hasPlan) + if level != tc.wantLevel { + t.Errorf("level = %q, want %q", level, tc.wantLevel) + } + if reason != tc.wantReason { + t.Errorf("reason = %q, want %q", reason, tc.wantReason) + } + }) + } + + // Whatever ike would have picked, an explicit level wins — the point of + // showing the recommendation is that it can be argued with. + for _, mode := range []Mode{ModePlan, ModeExecute} { + for _, hasPlan := range []bool{false, true} { + level, reason := ResolveEffort(mode, "low", hasPlan) + if level != "low" { + t.Errorf("level = %q, want the requested low", level) + } + // No reason, because there is nothing to explain about a flag + // somebody typed — and an empty reason is what tells a frontend to + // print the level on its own. + if reason != "" { + t.Errorf("reason = %q, want none for an explicit level", reason) + } + } + } +} + +// Both command lines must actually carry the level, or the recommendation is +// something ike prints and then does not do. +func TestEffortReachesTheCommandLine(t *testing.T) { + joined := func(parts []string) string { return strings.Join(parts, " ") } + + run := joined(args(Spec{Mode: ModeExecute})) + if !strings.Contains(run, "--effort high") { + t.Errorf("execute args = %q, want the recommendation for a run with no plan", run) + } + withPlan := joined(args(Spec{Mode: ModeExecute, Plan: "step one"})) + if !strings.Contains(withPlan, "--effort medium") { + t.Errorf("execute args = %q, want the step down for an attached plan", withPlan) + } + if plan := joined(args(Spec{Mode: ModePlan})); !strings.Contains(plan, "--effort high") { + t.Errorf("plan args = %q, want high", plan) + } + custom := joined(args(Spec{Mode: ModeExecute, Plan: "step one", Effort: "max"})) + if !strings.Contains(custom, "--effort max") { + t.Errorf("execute args = %q, want the override", custom) + } + + // A session resolves the same way, so talking a task through and delegating + // it do not silently run at different depths. + s := Session{Mode: ModeExecute, SessionID: "id", Plan: "step one"} + if got := joined(sessionArgs(s)); !strings.Contains(got, "--effort medium") { + t.Errorf("session args = %q, want the same recommendation a run gets", got) + } + s.Effort = "low" + if got := joined(sessionArgs(s)); !strings.Contains(got, "--effort low") { + t.Errorf("session args = %q, want the override", got) + } +} + +// A bad level must be caught where a session is built, the way a bad permission +// mode is: InteractiveCommand is the point past which the terminal is gone. +func TestInteractiveCommandRefusesABadEffort(t *testing.T) { + // Stubbed because InteractiveCommand looks for the binary before it + // validates anything, so without this the test passes or fails on whether + // the machine running it happens to have claude installed — and would be + // asserting on lookBinary's message rather than on the effort level. + t.Setenv(binEnv, "/nonexistent/claude") + + _, err := InteractiveCommand(t.Context(), Session{ + Mode: ModeExecute, + Dir: t.TempDir(), + SessionID: "id", + Effort: "mid", + }) + if err == nil { + t.Fatal("a bad effort level should be refused") + } + if !strings.Contains(err.Error(), "medium") { + t.Errorf("error = %q, it should list the valid levels", err) + } +} diff --git a/internal/agent/interactive.go b/internal/agent/interactive.go new file mode 100644 index 0000000..1a176b7 --- /dev/null +++ b/internal/agent/interactive.go @@ -0,0 +1,180 @@ +package agent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os/exec" + "strings" +) + +// An interactive session is the other half of this package: the same agent, on +// the same task, in the same directory, but talking to you rather than +// streaming into a transcript. +// +// It is deliberately not a Run. A Run owns a pipe and parses NDJSON; a session +// owns the terminal, and ike's job is to get out of its way — build the command, +// hand it over, and pick up whatever it left behind. So this returns an +// *exec.Cmd rather than starting anything: the CLI runs it directly and the TUI +// gives it to tea.ExecProcess, which needs the command itself to release and +// restore the terminal around it. +// +// The conversation is pinned to the task by a session ID ike chooses up front, +// which is what lets you leave and come back to it days later instead of +// briefing a fresh agent every time. + +// Session describes one interactive visit to a task. +type Session struct { + // Mode picks the opening brief: ModePlan to talk a plan through, ModeExecute + // to supervise the work. + Mode Mode + // Title, Quadrant, Plan and Dir are as they are on a Spec. + Title string + Quadrant string + Plan string + Dir string + // SessionID is the conversation to resume. Empty starts a new one, and the + // caller is expected to have generated the ID with NewSessionID and stored + // it, so the next visit can resume rather than starting over again. + SessionID string + // Resume says whether SessionID names a conversation that already exists. + // A new conversation passes the same ID to --session-id instead. + Resume bool + // DraftPath is where the agent is asked to leave a plan you agree on, for + // the caller to adopt afterwards. Empty leaves that out of the brief. + DraftPath string + // PermissionMode overrides the default. It matters far less here than for a + // headless run: you are present, so the agent can simply ask. + PermissionMode string + // Model optionally overrides the model. + Model string + // Effort optionally overrides how hard the agent works, as on a Spec. It is + // resolved the same way, so a conversation about a task that already has a + // plan starts where a delegated run on it would. + Effort string +} + +// NewSessionID mints a conversation ID. +// +// A version 4 UUID, which is the shape --session-id requires. Hand-rolled from +// crypto/rand rather than adding a dependency for sixteen bytes and a bit of +// formatting. +func NewSessionID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generating a session id: %w", err) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 1 + h := hex.EncodeToString(b[:]) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32], nil +} + +// InteractiveCommand builds the command for a live session. +// +// Stdin, stdout and stderr are left unset on purpose. The caller supplies the +// real terminal — directly in the CLI, or through tea.ExecProcess in the TUI, +// which only fills in streams that are still nil. Setting them here would take +// that choice away and, in the TUI's case, break the handover. +func InteractiveCommand(ctx context.Context, s Session) (*exec.Cmd, error) { + bin, err := lookBinary() + if err != nil { + return nil, err + } + if s.Dir == "" { + return nil, errNoDir + } + if s.SessionID == "" { + return nil, errNoSession + } + if err := ValidatePermissionMode(s.PermissionMode); err != nil { + return nil, err + } + if err := ValidateEffort(s.Effort); err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, bin, sessionArgs(s)...) + cmd.Dir = s.Dir + // No process group here, unlike a headless run. This process *is* the + // foreground job: it must receive the ctrl+c you type at it, which is + // exactly what putting it in its own group would prevent. + return cmd, nil +} + +// sessionArgs builds the command line for a session. +func sessionArgs(s Session) []string { + var out []string + if s.Resume { + // A nudge rather than a brief. Everything about the task is already in + // the conversation, and re-sending it would restate what the agent + // just spent a session learning — but --resume does need *a* prompt: + // without one it fails with "Provide a prompt to continue the + // conversation" and the session never opens. + out = append(out, "--resume", s.SessionID, resumePrompt) + } else { + // --session-id, not a flag that reports one back. Choosing the ID means + // ike can store it before the session has even started, so a session + // that ends badly is still resumable. + out = append(out, "--session-id", s.SessionID, sessionPrompt(s)) + } + switch { + case s.Mode == ModePlan: + // Plan mode here for the same reason as a headless planning run: talking + // a plan through should not be able to change the directory it is about. + out = append(out, "--permission-mode", "plan") + case s.PermissionMode != "": + out = append(out, "--permission-mode", s.PermissionMode) + } + if s.Model != "" { + out = append(out, "--model", s.Model) + } + level, _ := ResolveEffort(s.Mode, s.Effort, s.Plan != "") + out = append(out, "--effort", level) + return out +} + +// resumePrompt reopens a conversation. Short on purpose: the history holds the +// task, the plan, and wherever the last session got to, so the only useful +// thing to say is that we are back. +const resumePrompt = "We're picking this task back up. Briefly remind me where we got to, " + + "then wait for me." + +// sessionPrompt is the opening brief for a new conversation. +func sessionPrompt(s Session) string { + var b strings.Builder + if s.Mode == ModePlan { + b.WriteString("Let's work out how to do one task from my Eisenhower matrix. " + + "Talk it through with me — ask about anything ambiguous rather than guessing.\n\n") + } else { + b.WriteString("Let's work on one task from my Eisenhower matrix. I'm here, so ask me " + + "rather than guessing when something is ambiguous.\n\n") + } + b.WriteString(describe(s.spec())) + + if s.Plan != "" { + b.WriteString("\nThis plan is already attached to the task:\n\n") + b.WriteString(fence(s.Plan)) + } + + if s.DraftPath != "" { + // Named explicitly, and the instruction stays in the conversation's + // history — which is why the path has to be stable across sessions. + fmt.Fprintf(&b, "\nWhen we have agreed on a plan, write it as markdown to:\n\n %s\n\n"+ + "ike picks it up from there and attaches it to the task, so that file is the "+ + "plan — not a copy of it. Don't write it until we've agreed, and don't write "+ + "anything else there.\n", s.DraftPath) + } + if s.Mode == ModePlan { + b.WriteString("\nStart by looking around the working directory and telling me what " + + "you think the task involves.\n") + } + return b.String() +} + +// spec adapts a Session to the shape describe expects, so a task is presented +// the same way whether it is being streamed or talked about. +func (s Session) spec() Spec { + return Spec{Title: s.Title, Quadrant: s.Quadrant, Dir: s.Dir} +} diff --git a/internal/agent/parse.go b/internal/agent/parse.go new file mode 100644 index 0000000..4cb15cc --- /dev/null +++ b/internal/agent/parse.go @@ -0,0 +1,162 @@ +package agent + +import ( + "encoding/json" + "strings" + + "github.com/jonascript/ike/internal/task" +) + +// Kind classifies an Event. The set is deliberately small: it is what a +// transcript needs to be readable, not a mirror of the CLI's wire format. +type Kind int + +const ( + // KindStarted is the run beginning, carrying the session ID and model. + KindStarted Kind = iota + // KindText is a block of prose from the agent. + KindText + // KindThinking is a block of the agent's reasoning. + KindThinking + // KindTool is the agent invoking a tool. + KindTool + // KindResult is the run finishing, successfully or not. + KindResult + // KindError is ike's own failure to run or read the agent, never the + // agent's own output. + KindError +) + +// Event is one thing worth showing the user about a run. +// +// Text is already sanitized for a terminal: every field on the way out of this +// package has been through task.SanitizeBlock, because this is model-chosen +// text bound for a screen and this package is the choke point it all passes +// through. Doing it here rather than at each frontend means the CLI and the TUI +// cannot disagree about whether it happened. +type Event struct { + Kind Kind + // Text is the prose of a KindText, KindThinking or KindResult event, or the + // message of a KindError. + Text string + // Tool is the tool name on a KindTool event. + Tool string + // SessionID identifies the run, and is set on KindStarted. It is what + // `claude --resume` takes, so it is worth showing even though ike does not + // resume runs itself yet. + SessionID string + // Model is the model the run is using, set on KindStarted. + Model string + // IsError marks a KindResult that failed. + IsError bool + // CostUSD is the run's cost, set on KindResult. + CostUSD float64 +} + +// wire is the subset of the CLI's stream-json envelope ike reads. +// +// Only these fields are declared. The format carries a great deal more — +// per-message token accounting, cache statistics, rate-limit state, plugin and +// skill inventories — and decoding into a struct that names just what is used +// means a new field upstream is ignored rather than breaking the parse. +type wire struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + Model string `json:"model"` + + // assistant / user events + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + Name string `json:"name"` + } `json:"content"` + } `json:"message"` + + // result events + IsError bool `json:"is_error"` + Result string `json:"result"` + CostUSD float64 `json:"total_cost_usd"` +} + +// parseLine turns one line of the agent's stdout into zero or more events. +// +// Zero is the common case for lines ike does not care about, and that is the +// important behavior: an unrecognized type is skipped, never an error. The +// stream carries event types this package has never heard of — `rate_limit_event` +// and `system/thinking_tokens` both appear in an ordinary run — and the CLI adds +// more between releases. A parser that rejected the unfamiliar would break on a +// `claude` upgrade, which is not something ike gets to control. +// +// A line that is not JSON at all is skipped for the same reason. stdout is +// documented as NDJSON and in practice is (the "no stdin data received" +// warning goes to stderr), but a stray write from a wrapper script or a plugin +// must not end a run that is otherwise fine. +func parseLine(line string) []Event { + line = strings.TrimSpace(line) + if line == "" || !strings.HasPrefix(line, "{") { + return nil + } + var w wire + if err := json.Unmarshal([]byte(line), &w); err != nil { + return nil + } + + switch w.Type { + case "system": + if w.Subtype != "init" { + return nil // thinking_tokens and whatever else arrives later + } + return []Event{{ + Kind: KindStarted, + SessionID: clean(w.SessionID), + Model: clean(w.Model), + }} + + case "assistant": + // One message can hold several blocks — a thought, then prose, then a + // tool call — and each is its own line in the transcript. + var out []Event + for _, b := range w.Message.Content { + switch b.Type { + case "text": + if t := clean(b.Text); t != "" { + out = append(out, Event{Kind: KindText, Text: t}) + } + case "thinking": + // Often empty. A thinking block frequently arrives carrying + // only a signature, with the reasoning itself withheld — an + // ordinary run is full of them. Emitting those would put blank + // lines through the transcript, so the emptiness check is doing + // real work rather than guarding a case that cannot happen. + if t := clean(b.Thinking); t != "" { + out = append(out, Event{Kind: KindThinking, Text: t}) + } + case "tool_use": + out = append(out, Event{Kind: KindTool, Tool: clean(b.Name)}) + } + } + return out + + case "result": + return []Event{{ + Kind: KindResult, + Text: clean(w.Result), + IsError: w.IsError || w.Subtype != "success", + CostUSD: w.CostUSD, + }} + } + + // user events (tool results) are deliberately dropped: a transcript showing + // every file the agent read is mostly noise, and a tool result can be + // megabytes. The tool_use line already says what happened. + return nil +} + +// clean is task.SanitizeBlock, applied at the one point every field leaves this +// package. Agent output is the most untrusted text ike renders — bytes chosen +// by a model, printed into a terminal — and an escape sequence in it could +// otherwise repaint the screen the user is auditing the run with. +func clean(s string) string { return task.SanitizeBlock(s) } diff --git a/internal/agent/procgroup_other.go b/internal/agent/procgroup_other.go new file mode 100644 index 0000000..f91212e --- /dev/null +++ b/internal/agent/procgroup_other.go @@ -0,0 +1,22 @@ +//go:build !unix + +package agent + +import "os/exec" + +// ike ships for Linux and macOS only — .goreleaser.yaml says why — but the +// tree is kept compiling everywhere, and CI's cross-build step is cheap +// precisely because nothing platform-specific creeps in unguarded. This file +// is what keeps that true now that delegation starts a process. +// +// The fallback is a real one rather than a stub: there is no process group to +// set, and killing the process itself is the closest equivalent available. It +// leaves the agent's own children running, which is exactly the shortcoming +// the Unix version exists to avoid — another reason those are the supported +// platforms. + +func setProcessGroup(*exec.Cmd) {} + +func killProcessGroup(cmd *exec.Cmd) { + _ = cmd.Process.Kill() +} diff --git a/internal/agent/procgroup_unix.go b/internal/agent/procgroup_unix.go new file mode 100644 index 0000000..c9c809b --- /dev/null +++ b/internal/agent/procgroup_unix.go @@ -0,0 +1,28 @@ +//go:build unix + +package agent + +import ( + "os/exec" + "syscall" +) + +// setProcessGroup puts the agent in a process group of its own, so the whole +// tree can be signaled at once. +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// killProcessGroup signals the agent and everything it started. +// +// The negative PID is what addresses the group rather than the one process. +// That matters here more than it usually does: the agent runs shell commands +// and spawns tools of its own, and killing only the parent would leave those +// running against the user's files after the run appeared to stop. +// +// SIGTERM rather than SIGKILL, so the CLI can shut its own children down +// cleanly. A run that ignores it is reaped by the CommandContext cancellation +// the caller has already triggered. +func killProcessGroup(cmd *exec.Cmd) { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) +} diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go new file mode 100644 index 0000000..a2f8957 --- /dev/null +++ b/internal/agent/prompts.go @@ -0,0 +1,96 @@ +package agent + +import ( + "fmt" + "strings" +) + +// The prompts live together in one file because they are the product, in the +// way the jsonschema tags on the MCP tools are: they are the whole of what the +// agent knows about the task, and reviewing a change to them means reading them +// side by side. +// +// Both are written to a task that is often one line long. An ike task is a +// title in a quadrant — "fix the flaky login test" — so the prompt's job is to +// supply the context that terseness leaves out: which directory the work is in, +// how the user classified its urgency, and what has already been decided. + +// prompt builds the text for a run. +func prompt(s Spec) string { + if s.Mode == ModePlan { + return planPrompt(s) + } + return executePrompt(s) +} + +// planPrompt asks for a plan and nothing else. +// +// The output is stored verbatim as the task's plan, so the instruction to skip +// the preamble is not politeness: an "I'll help you with that!" opening would +// be saved as the first line of the plan and shown every time it is opened. +func planPrompt(s Spec) string { + var b strings.Builder + b.WriteString("Draft an implementation plan for one task from my Eisenhower matrix.\n\n") + b.WriteString(describe(s)) + + if s.Plan != "" { + b.WriteString("\nThere is already a plan attached. Revise it — keep what still " + + "holds, change what does not:\n\n") + b.WriteString(fence(s.Plan)) + } + + b.WriteString("\nExplore the working directory as much as you need to. Then reply with " + + "ONLY the plan, as markdown, with no preamble and no closing remarks — your " + + "entire reply is stored as the plan and shown to me verbatim.\n\n") + b.WriteString("Keep it under about 40 lines and cover: the goal in a sentence, the " + + "concrete steps in order, the files to be touched, and how to check it worked. " + + "Where a real decision has to be made, say what you chose and why, so I can " + + "disagree with it before any code is written.\n") + return b.String() +} + +// executePrompt asks for the work to be done. +func executePrompt(s Spec) string { + var b strings.Builder + b.WriteString("Carry out one task from my Eisenhower matrix.\n\n") + b.WriteString(describe(s)) + + if s.Plan != "" { + b.WriteString("\nI have already reviewed and attached this plan. Follow it. If you " + + "find it is wrong, stop and say so rather than quietly doing something " + + "else — I approved this, not a substitute:\n\n") + b.WriteString(fence(s.Plan)) + } else { + b.WriteString("\nNo plan is attached, so work out the approach yourself.\n") + } + + b.WriteString("\nWhen you are done, finish with two or three sentences saying what you " + + "actually changed. If you could not finish, say what is left and why — I am " + + "reading this to decide whether the task is done, so an optimistic summary is " + + "worse than no summary.\n") + return b.String() +} + +// describe states the task itself, shared by both prompts so they cannot +// disagree about how a task is presented. +func describe(s Spec) string { + var b strings.Builder + fmt.Fprintf(&b, "Task: %s\n", s.Title) + if s.Quadrant != "" { + // The quadrant is how the user classified the work, and the label may + // be one they wrote themselves, so it carries their own words for it. + fmt.Fprintf(&b, "Quadrant: %s\n", s.Quadrant) + } + fmt.Fprintf(&b, "Working directory: %s\n", s.Dir) + return b.String() +} + +// fence wraps a plan so its markdown cannot be read as part of the +// instructions around it. A plan is full of headings and list items, and +// dropping it in raw would leave the model guessing where it ended. +func fence(plan string) string { + // A fence long enough not to be closed by anything inside the plan, which + // may itself contain fenced code blocks. + const f = "``````" + return f + "markdown\n" + strings.TrimRight(plan, "\n") + "\n" + f + "\n" +} diff --git a/internal/agent/testdata/toolrun.jsonl b/internal/agent/testdata/toolrun.jsonl new file mode 100644 index 0000000..0bd53a3 --- /dev/null +++ b/internal/agent/testdata/toolrun.jsonl @@ -0,0 +1,17 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","ToolSearch","WebFetch","WebSearch","Workflow","Write"],"mcp_servers":[{"name":"claude.ai Mermaid Chart","status":"needs-auth"}],"model":"claude-opus-5[1m]","permissionMode":"acceptEdits","slash_commands":["guided-tour","orchestration","thermo-nuclear-code-quality-review","walkthrough","deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","run","run-skill-generator","agents","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","model","__remote-workflow","workflow-launch-exec","reload-skills","rename","review","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","goal","design","design-consent","design-revoke","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.220","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["guided-tour","orchestration","thermo-nuclear-code-quality-review","walkthrough","deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","run","run-skill-generator"],"plugins":[{"name":"redis-development","path":"/Users/jonathancrockett/.claude/plugins/cache/claude-plugins-official/redis-development/3d6f25505ea2-7f70108f","source":"redis-development@claude-plugins-official"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"cb22d238-c1a4-4b2b-ad7d-acc09d748892","memory_paths":{"auto":"/Users/jonathancrockett/.claude/projects/-private-tmp-claude-501--Users-jonathancrockett-dev-4db9aec3-3720-4df1-8668-44b45229d878-scratchpad-fixture/memory/"},"fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiSnf9tbt6rsXxYrSjc","type":"message","role":"assistant","content":[{"type":"text","text":"I'll read the file first."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6902,"cache_read_input_tokens":15273,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6902},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"1c6d8836-c140-415d-9cc8-048dc7a69c96","timestamp":"2026-08-01T08:30:12.664Z","request_id":"req_011CdbiSmJ3yCYh8uWzTdNdK"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785584400,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"1ed980a3-9137-4656-b3f6-1ab182f8edf6","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiSnf9tbt6rsXxYrSjc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018h9XjU5TNjMNcazMpXZ54G","name":"Read","input":{"file_path":"/private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/notes.txt"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6902,"cache_read_input_tokens":15273,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6902},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"8bfe7b9e-2ef3-4739-88d9-c6e6ff42dbfe","timestamp":"2026-08-01T08:30:17.369Z","request_id":"req_011CdbiSmJ3yCYh8uWzTdNdK"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018h9XjU5TNjMNcazMpXZ54G","type":"tool_result","content":"1\thello\n2\tworld\n3\t"}]},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"d0b0eb52-5461-4017-ae90-ab3ed399832e","timestamp":"2026-08-01T08:30:17.417Z","tool_use_result":{"type":"text","file":{"filePath":"/private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/notes.txt","content":"hello\nworld\n","numLines":3,"startLine":1,"totalLines":3}}} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"a709fba5-ebd7-4ea8-8a14-6dd8f9da0e36","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":150,"uuid":"f1ba4797-424d-4191-b832-77b1faedbe8b","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiTFvVaWLpEdgPUJijP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS+AQKhwEIEBgCKkATTn0u4NlAKv1LqWChgx3j60quoFbaadooj7BWHo6ZqOLiI9ImrXHkpj1t4QXgQsuq5D1lv8YwdTHTZoRvSd/fMg1jbGF1ZGUtb3B1cy01OAFCCHRoaW5raW5nWiQwYmMwNDA3MC1iNDFlLTRiOGYtYmZlNS02ODYxOTZjNDljZTYSDOaq9xDLL+OcVed2KhoMwtNW3ofsjww3HFKDIjBysJKq9lHUUUwwYdwnc78k5Lh7Y1hOTRchugckoz78jyfK0XKGzBCFRd79x3umvaYqnQMxXler+rhkCIxaFvorbjxOp4ADGFPjK3nrTnCFfpYilrXYSHiAvwUmuotIMdxcnSdoiFWAKVnMxlIpWDxh/MU8gAOpBEkuoYN81x4EAkD28ucmxDVsjTrFOTHec7IHJnWrbLvgrwOo1TV+CZOgb+pFwI4cN221kfJJjEMqUJCvrdRlwarpQJnlhETf9hGZxKDY0jeFvO3IBMndxWTFft15L5eBMum+mwxMZ6+ALP1fMj56453JNsQjinP9Kl1KhhiOWxT+JDfWGtJWXNm3CEnit0TBvXfQfi1EV1pvbfmfeKwS+zXmjXhGP0YI+KmA7nW1H9BtZtk4Ofwfz1JHrX+fDUoA753DLCU6fX4CvkSYJDidGVoHM167Sw099MH13IIjx7pjed1ypSceftUzuDH/apAhhmytpokfKHOjKIVGHWb+KFztRLxEj36WwZBblyOtX5noUOEm7hnc+ReCPMSREmp6AzDgPuRbJFOr8xLLeyElRArZfIkx/vscJKmXwPXrE8XdBj4L253zoiVyX1my6dRXZER4+DpxfrDeghgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":138,"cache_read_input_tokens":22175,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":138},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"038b24d4-dba4-4bdc-a481-ed5800eb5141","timestamp":"2026-08-01T08:30:20.950Z","request_id":"req_011CdbiTEvDa5bM1m6JH6LTL"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiTFvVaWLpEdgPUJijP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RmGquNRdrH2iJxB9acBFwB","name":"Bash","input":{"command":"wc -l < /private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/notes.txt; echo \"---\"; od -c /private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/notes.txt | tail -5","description":"Count lines and inspect bytes of notes.txt"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":138,"cache_read_input_tokens":22175,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":138},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"0c81feab-f899-46e3-8202-15f76f512bb5","timestamp":"2026-08-01T08:30:22.628Z","request_id":"req_011CdbiTEvDa5bM1m6JH6LTL"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RmGquNRdrH2iJxB9acBFwB","type":"tool_result","content":" 2\n---\n0000000 h e l l o \\n w o r l d \\n \n0000014","is_error":false}]},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"c690d76f-afae-4322-8581-a8408ec2c030","timestamp":"2026-08-01T08:30:22.755Z","tool_use_result":{"stdout":" 2\n---\n0000000 h e l l o \\n w o r l d \\n \n0000014","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"95d530ba-16f9-4b94-9df8-b970e191f044","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":98,"estimated_tokens_delta":48,"uuid":"4a547418-1a35-41ce-b51a-73bd2e98bc1d","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiTf1ZG2Lap1YmfYuQV","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/wIKhwEIEBgCKkAFjeDE7Eh0peobSpcZkxOAYu55NYRRtqN6yFULPD/TTgstBp1EgJrJ6u+X3oqRmqWJWBcK9ffgZf+JiRwvEZY8Mg1jbGF1ZGUtb3B1cy01OAFCCHRoaW5raW5nWiQwYmMwNDA3MC1iNDFlLTRiOGYtYmZlNS02ODYxOTZjNDljZTYSDEgLstAWZiMNt+f5RxoMy+bQObAhDvGrWhnSIjCfAvL16qqsfQjiNmlJC/1VnqYnVYuwSIsflF26zUB4+3v42fBn6Ouayzm3VaTE25kqpAFIxvPrqjIYwInwkHyGh3dmLzWrH07A1S+13HAIlx7FTzVwtNzzCcPaREJLhcq5rs+qv7UbTf4rQjQKwyzGYeg+WOXXhL0qMSVfRDbe96L/a1kVpK867G3kAb+TOyCWG/EPNJehhrStg6Ofb3jcJAdI+7P7Bk3URFrneW6x1BWpsAkw+KWwGVT9iVEsnfP9VKBIfR9s2Dfdxco53yC+cl8gUWb2yhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":445,"cache_read_input_tokens":22313,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":445},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"48437470-66c4-4225-a74c-161d49fe200b","timestamp":"2026-08-01T08:30:25.482Z","request_id":"req_011CdbiTcm7jGj36cRG2z3oA"} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiTf1ZG2Lap1YmfYuQV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Acbm9Qd14zPgNrSgWNiXrM","name":"Write","input":{"file_path":"/private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/out.txt","content":"2\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":445,"cache_read_input_tokens":22313,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":445},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"8898eaae-62b2-4fb9-b9ea-f336c32159c6","timestamp":"2026-08-01T08:30:26.254Z","request_id":"req_011CdbiTcm7jGj36cRG2z3oA"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Acbm9Qd14zPgNrSgWNiXrM","type":"tool_result","content":"File created successfully at: /private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/out.txt (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"562cfdb7-349f-4d4b-8e18-3c30d27d692b","timestamp":"2026-08-01T08:30:26.311Z","tool_use_result":{"type":"create","filePath":"/private/tmp/claude-501/-Users-jonathancrockett-dev/4db9aec3-3720-4df1-8668-44b45229d878/scratchpad/fixture/out.txt","content":"2\n","structuredPatch":[],"originalFile":null,"userModified":false}} +{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011CdbiTuG3xwhZHxWxXSZ9R","type":"message","role":"assistant","content":[{"type":"text","text":"`notes.txt` has **2 lines** — `hello` and `world`, with a trailing newline (verified with `wc -l` and `od -c`; the Read view's empty line 3 is just the trailing newline).\n\nWrote `out.txt` containing `2`."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":290,"cache_read_input_tokens":22758,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":290},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","uuid":"ab3cd1f4-7834-444d-b2eb-5a8c092a6df7","timestamp":"2026-08-01T08:30:28.927Z","request_id":"req_011CdbiTspyuJdECbv5dAfCT"} +{"is_error":false,"duration_api_ms":20143,"num_turns":4,"stop_reason":"end_turn","session_id":"27ab17e9-d91f-49f3-97c2-71896d04e0e3","total_cost_usd":0.13983149999999997,"usage":{"input_tokens":8,"cache_creation_input_tokens":7775,"cache_read_input_tokens":82519,"output_tokens":806,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":7775,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":87,"cache_read_input_tokens":22758,"cache_creation_input_tokens":290,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":290},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":542,"outputTokens":18,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.000632,"contextWindow":200000,"maxOutputTokens":32000,"canonicalModel":"claude-haiku-4-5","provider":"firstParty"},"claude-opus-5[1m]":{"inputTokens":8,"outputTokens":806,"cacheReadInputTokens":82519,"cacheCreationInputTokens":7775,"webSearchRequests":0,"costUSD":0.13919949999999998,"contextWindow":1000000,"maxOutputTokens":64000,"canonicalModel":"claude-opus-5","provider":"firstParty"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":null,"result":"`notes.txt` has **2 lines** — `hello` and `world`, with a trailing newline (verified with `wc -l` and `od -c`; the Read view's empty line 3 is just the trailing newline).\n\nWrote `out.txt` containing `2`.","ttft_ms":1684,"ttft_stream_ms":1172,"time_to_request_ms":32,"type":"result","duration_ms":17983,"uuid":"f7192af5-f455-4963-a48c-a179713bca31"} diff --git a/internal/cli/agent.go b/internal/cli/agent.go new file mode 100644 index 0000000..500023a --- /dev/null +++ b/internal/cli/agent.go @@ -0,0 +1,715 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/jonascript/ike/internal/agent" + "github.com/jonascript/ike/internal/store" + "github.com/jonascript/ike/internal/task" +) + +// agentDisabledMsg explains how to switch delegation on. It is worded like +// mcpDisabledMsg and for the same reason: the whole value of the message is +// that it names the command to run. +// +// It says what is being consented to rather than just that a flag is off. +// "Access is disabled" would undersell it — the thing being allowed is ike +// starting a process that edits files. +const agentDisabledMsg = "Delegation is off for this matrix.\n" + + "`ike delegate` runs the Claude Code CLI in a task's working directory, where it\n" + + "can read and change files. ike does not do that until you allow it:\n\n" + + " ike agent enable\n\n" + + "Run `ike agent status` to see the current setting and which data file it applies to.\n" + + "Drafting a plan with `ike plan` is read-only and needs no permission." + +func newAgentCmd(open opener) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Control whether ike may run an agent on your tasks", + Long: "Delegation runs the Claude Code CLI as a subprocess, in the working\n" + + "directory a task carries, where it can read and change files.\n\n" + + "It is off until you allow it:\n\n" + + " ike agent enable\n\n" + + "The setting is remembered per data file, and `ike agent disable` revokes it.\n" + + "It is separate from `ike mcp enable`: letting an agent edit your task list is\n" + + "not the same decision as letting ike start one.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, + } + cmd.AddCommand(newAgentSetCmd(open, true), newAgentSetCmd(open, false), newAgentStatusCmd(open)) + return cmd +} + +// newAgentSetCmd builds `ike agent enable` or `ike agent disable`, as one +// function for the reason newMCPSetCmd gives: two near-identical command +// bodies is how the two drift apart. +func newAgentSetCmd(open opener, on bool) *cobra.Command { + use, short := "disable", "Revoke ike's permission to run an agent" + if on { + use, short = "enable", "Allow ike to run an agent on a task's working directory" + } + return &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + RunE: withStore(open, func(cmd *cobra.Command, args []string, s *store.Store) error { + changed, err := s.SetAgentEnabled(on) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if !changed { + fmt.Fprintf(out, "delegation was already %s for %s\n", onOff(on), s.Path()) + return nil + } + fmt.Fprintf(out, "delegation is now %s for %s\n", onOff(on), s.Path()) + if on { + // Stated plainly rather than reassuringly. The mode names invite + // the guess that the default holds something back, and it does + // not: acceptEdits and auto both let a delegated agent run `rm`. + fmt.Fprintf(out, "Runs use --permission-mode %s, which lets the agent change files "+ + "and run\ncommands in that directory. Use --permission-mode manual for a run that\n"+ + "stops at anything it would otherwise have to ask about.\n", + agent.DefaultPermissionMode) + } + return nil + }), + } +} + +// newAgentStatusCmd reports the setting, and like `ike mcp status` answers even +// after delegation has been revoked. +func newAgentStatusCmd(open opener) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether delegation is enabled, and for which data file", + Args: cobra.NoArgs, + RunE: withStore(open, func(cmd *cobra.Command, args []string, s *store.Store) error { + enabled, err := s.AgentEnabled() + if err != nil { + return err + } + hint := "ike agent enable" + if enabled { + hint = "ike agent disable" + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "delegation: %s\n", onOff(enabled)) + fmt.Fprintf(out, "data file: %s\n", s.Path()) + // Whether the binary is actually there is the other half of "will + // this work", and finding out now beats finding out mid-run. + fmt.Fprintf(out, "claude: %s\n", claudeStatus()) + fmt.Fprintf(out, "change it: %s\n", hint) + return nil + }), + } +} + +func claudeStatus() string { + if override := os.Getenv("IKE_AGENT_CMD"); override != "" { + return override + " (from IKE_AGENT_CMD)" + } + p, err := exec.LookPath("claude") + if err != nil { + return "not found on PATH — see https://claude.com/claude-code" + } + return p +} + +func onOff(on bool) string { + if on { + return "on" + } + return "off" +} + +func newPlanCmd(open opener) *cobra.Command { + var ( + show, edit, clear, prune bool + interactive, fresh bool + fromFile, dir string + model, effort string + ) + cmd := &cobra.Command{ + Use: "plan ", + Short: "Draft, show, or edit the plan attached to a task", + Long: "With no flags, asks the agent to draft a plan and attaches it to the task.\n" + + "That run is read-only: it explores the working directory but cannot change it,\n" + + "so it needs no permission.\n\n" + + "With -i it opens a real Claude Code session instead, so you can talk the plan\n" + + "through. The conversation is remembered: run it again and you pick up where you\n" + + "left off. Whatever you agree on is attached to the task when you exit.\n\n" + + "A plan is yours to keep or hand on. `ike delegate` follows it if there is one.", + Args: cobra.MaximumNArgs(1), + RunE: withStore(open, func(cmd *cobra.Command, args []string, s *store.Store) error { + if prune { + if len(args) != 0 { + return errors.New("--prune sweeps every orphaned plan; it takes no task id") + } + n, err := s.PrunePlans() + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "removed %s\n", plural(n, "orphaned plan")) + return nil + } + if len(args) != 1 { + return errors.New("which task? pass a task id, or --prune to sweep orphaned plans") + } + id, err := parseID(args[0]) + if err != nil { + return err + } + // Checked up front, so a typo costs nothing even on the flag + // combinations below that never start an agent at all. + if err := agent.ValidateEffort(effort); err != nil { + return err + } + + switch { + case clear: + t, d, err := s.ClearPlan(id) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "cleared the plan for %d %s%s\n", + t.ID, t.DisplayTitle(), inSpace(cmd, d)) + return nil + + case show: + body, err := s.Plan(id) + if err != nil { + return err + } + if body == "" { + return fmt.Errorf("task %d has no plan; run `ike plan %d` to draft one", id, id) + } + fmt.Fprint(cmd.OutOrStdout(), ensureNewline(body)) + return nil + + case fromFile != "": + b, err := os.ReadFile(fromFile) + if err != nil { + return err + } + return savePlan(cmd, s, id, string(b)) + + case edit: + return editPlan(cmd, s, id) + + case interactive: + return session(cmd, s, id, agent.ModePlan, + runOpts{dir: dir, model: model, effort: effort, fresh: fresh}) + } + + return draftPlan(cmd, s, id, runOpts{dir: dir, model: model, effort: effort}) + }), + } + cmd.Flags().BoolVar(&show, "show", false, "print the attached plan") + cmd.Flags().BoolVar(&edit, "edit", false, "open the plan in $EDITOR") + cmd.Flags().BoolVar(&clear, "clear", false, "remove the attached plan") + cmd.Flags().BoolVar(&prune, "prune", false, "delete plan files left behind by deleted tasks") + cmd.Flags().StringVar(&fromFile, "from-file", "", "attach a plan written elsewhere") + cmd.Flags().StringVar(&dir, "dir", "", "working directory to explore (remembered on the task)") + cmd.Flags().StringVar(&model, "model", "", "model to draft with, e.g. opus or sonnet") + cmd.Flags().StringVar(&effort, "effort", "", effortFlagHelp) + cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, + "open a Claude Code session and talk the plan through, resuming last time's") + cmd.Flags().BoolVar(&fresh, "new-session", false, + "start a new conversation instead of resuming the task's") + return cmd +} + +func newDelegateCmd(open opener) *cobra.Command { + var ( + dir, permission string + model, effort string + planFirst bool + interactive, freshSesson bool + ) + cmd := &cobra.Command{ + Use: "delegate ", + Short: "Hand a task to an agent and watch it work", + Long: "Runs the Claude Code CLI in the task's working directory, following the\n" + + "attached plan if there is one, and streams what it does.\n\n" + + "With -i it hands you the terminal instead, so you can supervise and answer\n" + + "questions. The conversation is remembered per task: run it again and you pick\n" + + "up where you left off rather than briefing a new agent.\n\n" + + "This needs permission, because the agent can change files and run commands:\n\n" + + " ike agent enable\n\n" + + "Runs are unattended, so nothing can prompt you mid-run. --permission-mode\n" + + "manual makes the agent stop at anything it would have asked about; the\n" + + "default lets it work without stopping.\n\n" + + "The task is not completed automatically — read what it did and decide.", + Args: cobra.ExactArgs(1), + RunE: withStore(open, func(cmd *cobra.Command, args []string, s *store.Store) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + // Checked before the gate and before any process, so a typo costs + // nothing and says what the alternatives are. + if err := agent.ValidatePermissionMode(permission); err != nil { + return err + } + if err := agent.ValidateEffort(effort); err != nil { + return err + } + // The gate, checked before anything else happens. A delegated run + // has no session to revoke mid-flight the way an MCP client does, + // so unlike the MCP gate this one check is the whole of it. + enabled, err := s.AgentEnabled() + if err != nil { + return err + } + if !enabled { + //nolint:staticcheck // ST1005: formatted for a human, not a caller. + return errors.New(agentDisabledMsg) + } + + if interactive { + // Supervised: you are at the terminal, so the agent can ask + // rather than deciding on your behalf. + return session(cmd, s, id, agent.ModeExecute, runOpts{ + dir: dir, permission: permission, model: model, + effort: effort, fresh: freshSesson, + }) + } + + if planFirst { + if err := draftPlan(cmd, s, id, runOpts{dir: dir, model: model, effort: effort}); err != nil { + return err + } + dir = "" // already resolved and stored by the planning run + fmt.Fprintln(cmd.OutOrStdout()) + } + + t, plan, err := prepare(cmd, s, id, dir) + if err != nil { + return err + } + d, err := s.Load() + if err != nil { + return err + } + + // The header before the transcript, so what ike chose on your + // behalf is on screen next to the work it produced rather than + // only inferrable from how long the run took. + fmt.Fprintf(cmd.OutOrStdout(), "delegating %d %s\n in %s\n%s\n\n", + t.ID, t.DisplayTitle(), t.Dir, + effortNote(agent.ModeExecute, effort, plan != "")) + + return stream(cmd, agent.Spec{ + Mode: agent.ModeExecute, + Title: t.Title, + Quadrant: d.Labels.Of(t.Quadrant), + Plan: plan, + Dir: t.Dir, + PermissionMode: permission, + Model: model, + Effort: effort, + }) + }), + } + cmd.Flags().StringVar(&dir, "dir", "", "working directory to run in (remembered on the task)") + cmd.Flags().StringVar(&permission, "permission-mode", "", + "one of acceptEdits, auto, bypassPermissions, manual, dontAsk (default "+ + agent.DefaultPermissionMode+"; manual is the one that restrains a run)") + cmd.Flags().StringVar(&model, "model", "", "model to run with, e.g. opus or sonnet") + cmd.Flags().StringVar(&effort, "effort", "", effortFlagHelp) + cmd.Flags().BoolVar(&planFirst, "plan-first", false, "draft a plan, then carry it out") + cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, + "open a Claude Code session and supervise the work, resuming last time's") + cmd.Flags().BoolVar(&freshSesson, "new-session", false, + "start a new conversation instead of resuming the task's") + return cmd +} + +// session hands the terminal to an interactive agent, then picks up whatever it +// left behind. +// +// The conversation is pinned to the task: the ID is generated and stored before +// the agent starts, so even a session that ends badly is resumable, and every +// later visit continues it rather than briefing a new agent from scratch. +func session(cmd *cobra.Command, s *store.Store, id int, mode agent.Mode, opts runOpts) error { + t, plan, err := prepare(cmd, s, id, opts.dir) + if err != nil { + return err + } + d, err := s.Load() + if err != nil { + return err + } + + resume := t.HasSession() && !opts.fresh + if !resume { + sid, err := agent.NewSessionID() + if err != nil { + return err + } + // Stored first. A session ike started but did not record would be + // unreachable afterwards — the conversation would exist with no way + // back to it. + updated, _, err := s.SetSession(id, sid) + if err != nil { + return err + } + t = updated + } + + draft, err := s.PlanDraftPath(id) + if err != nil { + return err + } + + c, err := agent.InteractiveCommand(cmd.Context(), agent.Session{ + Mode: mode, + Title: t.Title, + Quadrant: d.Labels.Of(t.Quadrant), + Plan: plan, + Dir: t.Dir, + SessionID: t.SessionID, + Resume: resume, + DraftPath: draft, + PermissionMode: opts.permission, + Model: opts.model, + Effort: opts.effort, + }) + if err != nil { + return err + } + // The real terminal, because this is a conversation. + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + + out := cmd.OutOrStdout() + verb := "resuming the conversation about" + if !resume { + verb = "starting a conversation about" + } + fmt.Fprintf(out, "%s %d %s\n in %s\n%s\n come back to it any time with the same command\n\n", + verb, t.ID, t.DisplayTitle(), t.Dir, effortNote(mode, opts.effort, plan != "")) + + if err := c.Run(); err != nil { + // An agent you quit out of is the ordinary ending, and exit status is + // not a reliable way to tell that from a real failure — so the draft is + // still adopted below rather than the error stopping everything. + fmt.Fprintf(cmd.ErrOrStderr(), "the session ended with: %v\n", err) + } + return adoptDraft(cmd, s, id) +} + +// runOpts are the flags a session shares with a streamed run. Passed as one +// value rather than as a growing tail of strings, because dir, model and effort +// are all strings and a caller that transposes two of them still compiles. +// +// Not every field applies to every caller — a planning run has no permission +// mode to set and no conversation to resume — but the alternative is a second +// struct that has to be kept in step with this one. +type runOpts struct { + dir string + permission string + model string + effort string + fresh bool +} + +// effortFlagHelp describes --effort. It names the levels and says what ike does +// with the flag left off, since that is the case almost every run is in. +const effortFlagHelp = "how hard the agent works: low, medium, high, xhigh, max " + + "(default: high to plan, medium to carry out an attached plan)" + +// effortNote is the run header's line about effort: what ike settled on and, +// when ike chose it rather than being told, why. +// +// Printed on every run rather than only when ike guessed, so the level is +// always a fact on screen instead of something to be reconstructed from the +// flags. It resolves through the same ResolveEffort the command line is built +// from, so the header cannot claim one thing while the agent runs at another. +func effortNote(mode agent.Mode, requested string, hasPlan bool) string { + level, why := agent.ResolveEffort(mode, requested, hasPlan) + if why == "" { + return " · effort " + level + } + return " · effort " + level + " — " + why +} + +// adoptDraft attaches a plan the agent left behind, if it left one. +func adoptDraft(cmd *cobra.Command, s *store.Store, id int) error { + t, d, got, err := s.AdoptPlanDraft(id) + if err != nil { + return err + } + if !got { + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "\nattached the plan you agreed on to %d %s%s\n see it with: ike plan %d --show\n", + t.ID, t.DisplayTitle(), inSpace(cmd, d), t.ID) + return nil +} + +// draftPlan runs a planning agent and attaches what it produces. +// +// It needs no gate: the run is --permission-mode plan, so it can read the +// working directory but not change it. +func draftPlan(cmd *cobra.Command, s *store.Store, id int, opts runOpts) error { + t, existing, err := prepare(cmd, s, id, opts.dir) + if err != nil { + return err + } + d, err := s.Load() + if err != nil { + return err + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "planning %d %s\n in %s\n%s\n\n", t.ID, t.DisplayTitle(), t.Dir, + effortNote(agent.ModePlan, opts.effort, existing != "")) + + // The plan is the run's final result rather than everything it said, so it + // is captured here instead of being reassembled from the transcript. + var plan string + err = streamInto(cmd, agent.Spec{ + Mode: agent.ModePlan, + Title: t.Title, + Quadrant: d.Labels.Of(t.Quadrant), + Plan: existing, + Dir: t.Dir, + Model: opts.model, + Effort: opts.effort, + }, func(e agent.Event) { + if e.Kind == agent.KindResult && !e.IsError { + plan = e.Text + } + }) + if err != nil { + return err + } + if strings.TrimSpace(plan) == "" { + return errors.New("the agent finished without producing a plan") + } + fmt.Fprintln(out) + return savePlan(cmd, s, id, plan) +} + +// prepare resolves the task and its working directory, storing the directory +// if this is the first run or --dir was given. +// +// Falling back to the process's own directory is what makes `cd ~/dev/thing && +// ike delegate 3` do the obvious thing. It is stored rather than used once, so +// the same task run later from anywhere goes back to the same project — which +// is the whole reason the field exists. +func prepare(cmd *cobra.Command, s *store.Store, id int, dir string) (task.Task, string, error) { + d, err := s.Load() + if err != nil { + return task.Task{}, "", err + } + var t task.Task + found := false + for _, candidate := range d.Tasks { + if candidate.ID == id { + t, found = candidate, true + break + } + } + if !found { + return task.Task{}, "", fmt.Errorf("no active task with id %d", id) + } + + if dir == "" && t.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return task.Task{}, "", err + } + dir = cwd + fmt.Fprintf(cmd.ErrOrStderr(), "using the current directory, and remembering it: %s\n", dir) + } + if dir != "" { + source := "--dir" + if !cmd.Flags().Changed("dir") { + source = "the current directory" + } + updated, _, err := s.SetDir(id, source, dir) + if err != nil { + return task.Task{}, "", err + } + t = updated + } + + plan, err := s.Plan(id) + if err != nil { + return task.Task{}, "", err + } + return t, plan, nil +} + +func savePlan(cmd *cobra.Command, s *store.Store, id int, body string) error { + t, d, err := s.SetPlan(id, strings.TrimSpace(body)) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "attached a plan to %d %s%s\n see it with: ike plan %d --show\n", + t.ID, t.DisplayTitle(), inSpace(cmd, d), t.ID) + return nil +} + +// editPlan opens the plan in $EDITOR and stores whatever comes back. +// +// It goes through a temp file rather than handing the editor the sidecar path, +// so an editor that is killed, or one that writes a partial file, cannot leave +// the stored plan truncated — and so the result still passes validation before +// it is saved. +func editPlan(cmd *cobra.Command, s *store.Store, id int) error { + editor := firstNonEmpty(os.Getenv("VISUAL"), os.Getenv("EDITOR")) + if editor == "" { + return errors.New("set $EDITOR (or $VISUAL) to edit a plan, " + + "or use `ike plan --from-file`") + } + body, err := s.Plan(id) + if err != nil { + return err + } + + f, err := os.CreateTemp("", "ike-plan-*.md") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) + if _, err := f.WriteString(body); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + + // The editor is interactive, so it gets the real terminal. + ed := exec.Command(editor, tmp) //nolint:gosec // the user's own $EDITOR, by definition + ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr() + if err := ed.Run(); err != nil { + return fmt.Errorf("running %s: %w", editor, err) + } + + edited, err := os.ReadFile(tmp) + if err != nil { + return err + } + if strings.TrimSpace(string(edited)) == strings.TrimSpace(body) { + fmt.Fprintln(cmd.OutOrStdout(), "no change") + return nil + } + return savePlan(cmd, s, id, string(edited)) +} + +// stream runs an agent and prints the transcript. +func stream(cmd *cobra.Command, spec agent.Spec) error { + return streamInto(cmd, spec, nil) +} + +// streamInto is stream with a hook, so the planning command can keep the final +// result without printing the transcript twice. +func streamInto(cmd *cobra.Command, spec agent.Spec, watch func(agent.Event)) error { + run, err := agent.Start(cmd.Context(), spec) + if err != nil { + return err + } + // A Ctrl-C reaches the whole process group anyway, but cobra's context is + // what carries a cancellation the caller arranged, and honoring it here is + // what stops a delegated run outliving `ike`. + defer run.Cancel() + + out := cmd.OutOrStdout() + // A failure is returned rather than printed, so cobra reports it once and + // the exit status is honest for anything scripting around ike. render + // deliberately prints nothing for these two, or the reason would appear + // twice — once in the transcript and again after "Error:". + var failure string + for e := range run.Events() { + if watch != nil { + watch(e) + } + render(out, e) + switch { + case e.Kind == agent.KindResult && e.IsError: + failure = "the agent did not finish the task: " + oneLine(e.Text) + case e.Kind == agent.KindError: + failure = e.Text + } + } + if failure != "" { + //nolint:staticcheck // ST1005: the agent's own words, shown to a human. + return errors.New(failure) + } + return nil +} + +// render writes one event as a line of transcript. +// +// Text is already sanitized — internal/agent does it at the single point +// everything leaves that package — so nothing here has to remember to. +func render(w io.Writer, e agent.Event) { + switch e.Kind { + case agent.KindStarted: + if e.Model != "" { + fmt.Fprintf(w, " · %s\n", e.Model) + } + case agent.KindThinking: + fmt.Fprintf(w, " · %s\n", firstLine(e.Text)) + case agent.KindTool: + fmt.Fprintf(w, " → %s\n", e.Tool) + case agent.KindText: + fmt.Fprintf(w, "%s\n", e.Text) + case agent.KindResult: + // A failed result is not printed here: streamInto returns it as the + // command's error, and cobra prints that. Printing both would show the + // reason twice. + if !e.IsError && e.CostUSD > 0 { + fmt.Fprintf(w, "\n · done ($%.2f)\n", e.CostUSD) + } + case agent.KindError: + // Likewise returned rather than printed. + } +} + +// oneLine flattens a multi-line message for use in an error, which is printed +// as a single line. +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// firstLine keeps a long block to one line of transcript. Thinking is shown as +// a hint that work is happening rather than as something to read. +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + const max = 100 + if len([]rune(s)) > max { + s = string([]rune(s)[:max]) + "…" + } + return s +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func ensureNewline(s string) string { + if strings.HasSuffix(s, "\n") { + return s + } + return s + "\n" +} diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go new file mode 100644 index 0000000..7a29036 --- /dev/null +++ b/internal/cli/agent_test.go @@ -0,0 +1,671 @@ +package cli + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/jonascript/ike/internal/agent" + "github.com/jonascript/ike/internal/store" +) + +// These tests never run the real claude CLI. As in internal/agent, the fake is +// this test binary re-executed with a marker in its environment, replaying a +// canned stream — so there is no network, no cost, and no dependence on how a +// model happens to word a plan. + +const ( + helperEnv = "IKE_CLI_TEST_HELPER" + helperFixture = "IKE_CLI_TEST_FIXTURE" + helperExit = "IKE_CLI_TEST_EXIT" + helperArgs = "IKE_CLI_TEST_ARGS" + helperCwd = "IKE_CLI_TEST_CWD" + helperDraft = "IKE_CLI_TEST_DRAFT" +) + +func TestMain(m *testing.M) { + if os.Getenv(helperEnv) == "" { + os.Exit(m.Run()) + } + // Record how ike invoked us, so a test can assert on the command line and + // the working directory without reaching into internal/agent. + if p := os.Getenv(helperArgs); p != "" { + _ = os.WriteFile(p, []byte(strings.Join(os.Args[1:], "\x00")), 0o600) + } + if p := os.Getenv(helperCwd); p != "" { + cwd, _ := os.Getwd() + _ = os.WriteFile(p, []byte(cwd), 0o600) + } + // Stand in for an interactive session that agreed on a plan: write it to + // the draft path ike named in the opening brief, the way a real agent + // would, so the handoff is exercised end to end. Finding the path by + // reading it back out of the prompt also checks that the brief really names + // somewhere ike looks. + if body := os.Getenv(helperDraft); body != "" { + re := regexp.MustCompile(`\S+\.draft\.md`) + for _, a := range os.Args[1:] { + if path := re.FindString(a); path != "" { + _ = os.WriteFile(path, []byte(body), 0o600) + } + } + } + if f := os.Getenv(helperFixture); f != "" { + b, err := os.ReadFile(f) + if err != nil { + panic(err) + } + os.Stdout.Write(b) + } + if os.Getenv(helperExit) == "1" { + os.Exit(1) + } + os.Exit(0) +} + +// stream writes a canned agent transcript and points ike at the fake. +func fakeStream(t *testing.T, events ...string) { + t.Helper() + p := filepath.Join(t.TempDir(), "stream.jsonl") + if err := os.WriteFile(p, []byte(strings.Join(events, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("IKE_AGENT_CMD", exe) + t.Setenv(helperEnv, "1") + t.Setenv(helperFixture, p) +} + +// planStream is a successful planning run whose result is the plan. +func planStream(plan string) []string { + return []string{ + `{"type":"system","subtype":"init","session_id":"abc","model":"claude-opus-5"}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"Exploring."}]}}`, + `{"type":"result","subtype":"success","is_error":false,"result":` + quote(plan) + `,"total_cost_usd":0.02}`, + } +} + +func quote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + return `"` + s + `"` +} + +// withCwd runs the body in dir, since `ike delegate` with no --dir uses the +// process's own directory. +func withCwd(t *testing.T, dir string) { + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(old) }) +} + +func TestAgentGateDefaultsOffAndToggles(t *testing.T) { + p := scratch(t) + + out := mustRunCLI(t, p, "agent", "status") + if !strings.Contains(out, "delegation: off") { + t.Errorf("status = %q, want delegation off by default", out) + } + + out = mustRunCLI(t, p, "agent", "enable") + if !strings.Contains(out, "delegation is now on") { + t.Errorf("enable = %q", out) + } + // Enabling twice reports no change rather than claiming one. + out = mustRunCLI(t, p, "agent", "enable") + if !strings.Contains(out, "was already on") { + t.Errorf("second enable = %q", out) + } + if out := mustRunCLI(t, p, "agent", "status"); !strings.Contains(out, "delegation: on") { + t.Errorf("status = %q", out) + } + if out := mustRunCLI(t, p, "agent", "disable"); !strings.Contains(out, "delegation is now off") { + t.Errorf("disable = %q", out) + } +} + +// The two gates are separate decisions and neither implies the other. +func TestAgentGateIsSeparateFromMCP(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "mcp", "enable") + + if out := mustRunCLI(t, p, "agent", "status"); !strings.Contains(out, "delegation: off") { + t.Errorf("`ike mcp enable` also turned delegation on: %q", out) + } + mustRunCLI(t, p, "agent", "enable") + if out := mustRunCLI(t, p, "mcp", "status"); !strings.Contains(out, "MCP access: on") { + t.Errorf("`ike agent enable` disturbed the MCP gate: %q", out) + } +} + +// Refusing has to say how to allow it — that is the whole job of the message. +func TestDelegateRefusedWhenGateIsOff(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t, planStream("x")...) + + _, err := runCLI(t, p, "delegate", "1") + if err == nil { + t.Fatal("delegate should be refused with the gate off") + } + if !strings.Contains(err.Error(), "ike agent enable") { + t.Errorf("error = %q, it must name the command to run", err) + } +} + +// Drafting a plan is read-only, so it deliberately does not need the gate. +func TestPlanDoesNotNeedTheGate(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t, planStream("## Goal\n\nShip it.")...) + + out := mustRunCLI(t, p, "plan", "1", "--dir", dir) + if !strings.Contains(out, "attached a plan") { + t.Errorf("plan = %q", out) + } + if got := mustRunCLI(t, p, "plan", "1", "--show"); !strings.Contains(got, "Ship it.") { + t.Errorf("--show = %q", got) + } +} + +func TestPlanRoundTripsThroughTheCLI(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t, planStream("## Goal\n\nShip it.\n\n- step one")...) + mustRunCLI(t, p, "plan", "1", "--dir", dir) + + // The plan survives as written, newlines and all — the point of + // SanitizeBlock over SanitizeDisplay. + out := mustRunCLI(t, p, "plan", "1", "--show") + if !strings.Contains(out, "## Goal\n\nShip it.\n\n- step one") { + t.Errorf("--show = %q, want the plan with its line structure", out) + } + + // Clearing removes it. + mustRunCLI(t, p, "plan", "1", "--clear") + if _, err := runCLI(t, p, "plan", "1", "--show"); err == nil { + t.Error("--show should fail once the plan is cleared") + } +} + +func TestPlanFromFile(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + + src := filepath.Join(t.TempDir(), "plan.md") + if err := os.WriteFile(src, []byte("## Written by hand\n"), 0o600); err != nil { + t.Fatal(err) + } + // A plan you wrote yourself needs no agent at all. + mustRunCLI(t, p, "plan", "1", "--from-file", src) + if out := mustRunCLI(t, p, "plan", "1", "--show"); !strings.Contains(out, "Written by hand") { + t.Errorf("--show = %q", out) + } +} + +func TestPlanShowWithNoPlanExplainsItself(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + + _, err := runCLI(t, p, "plan", "1", "--show") + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "ike plan 1") { + t.Errorf("error = %q, it should say how to draft one", err) + } +} + +// The first run remembers where it happened, so the same task run later from +// anywhere goes back to the same project. +func TestDirectoryIsRememberedFromTheFirstRun(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t, planStream("a plan")...) + + mustRunCLI(t, p, "plan", "1", "--dir", dir) + + s := store.OpenAt(p) + d, err := s.Load() + if err != nil { + t.Fatal(err) + } + if d.Tasks[0].Dir != dir { + t.Errorf("Dir = %q, want %q", d.Tasks[0].Dir, dir) + } + + // A later run needs no --dir, and lands in the same place. + cwdFile := filepath.Join(t.TempDir(), "cwd") + t.Setenv(helperCwd, cwdFile) + mustRunCLI(t, p, "agent", "enable") + mustRunCLI(t, p, "delegate", "1") + + got, err := os.ReadFile(cwdFile) + if err != nil { + t.Fatal(err) + } + // macOS temp dirs are symlinked through /private, so compare resolved. + want, _ := filepath.EvalSymlinks(dir) + gotResolved, _ := filepath.EvalSymlinks(string(got)) + if gotResolved != want { + t.Errorf("the run happened in %q, want %q", gotResolved, want) + } +} + +// With no --dir and nothing stored, the current directory is used and kept — +// what makes `cd ~/dev/thing && ike delegate 3` do the obvious thing. +func TestDirectoryFallsBackToTheCurrentDirectory(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t, planStream("a plan")...) + withCwd(t, dir) + + mustRunCLI(t, p, "plan", "1") + + d, err := store.OpenAt(p).Load() + if err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(dir) + got, _ := filepath.EvalSymlinks(d.Tasks[0].Dir) + if got != want { + t.Errorf("Dir = %q, want the current directory %q", got, want) + } +} + +func TestDelegateRejectsABadDirectory(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + fakeStream(t, planStream("a plan")...) + + if _, err := runCLI(t, p, "delegate", "1", "--dir", "relative/path"); err == nil { + t.Error("a relative --dir should be refused") + } + if _, err := runCLI(t, p, "delegate", "1", "--dir", "~/dev"); err == nil { + t.Error("an unexpanded ~ should be refused") + } + if _, err := runCLI(t, p, "delegate", "1", "--dir", filepath.Join(t.TempDir(), "nope")); err == nil { + t.Error("a directory that does not exist should be refused") + } +} + +// A planning run must be read-only whatever else is asked for, and an execute +// run must carry the default permission mode. +func TestRunFlagsMatchTheMode(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + fakeStream(t, planStream("a plan")...) + + argsFile := filepath.Join(t.TempDir(), "args") + t.Setenv(helperArgs, argsFile) + + mustRunCLI(t, p, "plan", "1", "--dir", dir) + if got := readArgs(t, argsFile); !strings.Contains(got, "--permission-mode plan") { + t.Errorf("planning args = %q, want plan mode", got) + } + + mustRunCLI(t, p, "delegate", "1") + got := readArgs(t, argsFile) + if !strings.Contains(got, "--permission-mode "+agent.DefaultPermissionMode) { + t.Errorf("delegate args = %q, want the default permission mode", got) + } + // The attached plan is handed to the run, or delegation would ignore the + // thing it exists to act on. + if !strings.Contains(got, "a plan") { + t.Errorf("delegate args = %q, want the attached plan in the prompt", got) + } + + mustRunCLI(t, p, "delegate", "1", "--permission-mode", "manual") + if got := readArgs(t, argsFile); !strings.Contains(got, "--permission-mode manual") { + t.Errorf("delegate args = %q, want the override", got) + } +} + +// A mistyped mode must fail before a process starts, or it surfaces as a failed +// run rather than as the mistyped flag it is. +func TestBadPermissionModeIsRefusedBeforeRunning(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + + argsFile := filepath.Join(t.TempDir(), "args") + fakeStream(t, planStream("a plan")...) + t.Setenv(helperArgs, argsFile) + + _, err := runCLI(t, p, "delegate", "1", "--dir", dir, "--permission-mode", "acceptedits") + if err == nil { + t.Fatal("a misspelled permission mode should be refused") + } + if !strings.Contains(err.Error(), "acceptEdits") { + t.Errorf("error = %q, it should list the valid modes", err) + } + if _, statErr := os.Stat(argsFile); statErr == nil { + t.Error("the agent was started despite the bad flag") + } + + // plan would silently make a delegated run read-only. + _, err = runCLI(t, p, "delegate", "1", "--dir", dir, "--permission-mode", "plan") + if err == nil || !strings.Contains(err.Error(), "ike plan") { + t.Errorf("error = %v, want it to point at `ike plan`", err) + } +} + +func readArgs(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return strings.ReplaceAll(string(b), "\x00", " ") +} + +// A failed run has to fail the command, or a script around ike would read a +// broken delegation as a successful one. +func TestFailedRunFailsTheCommand(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + fakeStream(t, + `{"type":"system","subtype":"init","session_id":"abc"}`, + `{"type":"result","subtype":"error_during_execution","is_error":true,"result":"I could not do it"}`, + ) + + _, err := runCLI(t, p, "delegate", "1", "--dir", dir) + if err == nil { + t.Fatal("a failed run should fail the command") + } + if !strings.Contains(err.Error(), "I could not do it") { + t.Errorf("error = %q, want the agent's own reason", err) + } +} + +// A planning run that produces nothing must not attach an empty plan. +func TestPlanRefusesAnEmptyResult(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t, + `{"type":"system","subtype":"init","session_id":"abc"}`, + `{"type":"result","subtype":"success","is_error":false,"result":" "}`, + ) + + if _, err := runCLI(t, p, "plan", "1", "--dir", dir); err == nil { + t.Error("an empty plan should not be attached") + } + if _, err := runCLI(t, p, "plan", "1", "--show"); err == nil { + t.Error("nothing should have been attached") + } +} + +// The transcript is agent-chosen text going to a terminal. It must not be able +// to repaint the screen the run is being audited with. +func TestTranscriptIsSanitized(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + fakeStream(t, + `{"type":"assistant","message":{"content":[{"type":"text","text":"real\u001b[2K\rFAKE"}]}}`, + `{"type":"result","subtype":"success","is_error":false,"result":"done"}`, + ) + + out := mustRunCLI(t, p, "delegate", "1", "--dir", dir) + if strings.ContainsRune(out, 0x1b) || strings.ContainsRune(out, '\r') { + t.Errorf("transcript = %q, still carries control characters", out) + } +} + +func TestPlanPrune(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "plan", "1", "--from-file", writeTemp(t, "a plan")) + + // Deleting leaves the plan, so undo is not lossy. + mustRunCLI(t, p, "rm", "1") + if out := mustRunCLI(t, p, "plan", "--prune"); !strings.Contains(out, "1 orphaned plan") { + t.Errorf("prune = %q", out) + } + if out := mustRunCLI(t, p, "plan", "--prune"); !strings.Contains(out, "0 orphaned plans") { + t.Errorf("second prune = %q", out) + } +} + +// --prune sweeps the whole file, so pairing it with a task id is a mistake +// worth naming rather than quietly ignoring one of the two. +func TestPrunePlusIDIsRefused(t *testing.T) { + p := scratch(t) + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + if _, err := runCLI(t, p, "plan", "1", "--prune"); err == nil { + t.Error("--prune with a task id should be refused") + } +} + +func TestPlanWithNoArgsExplainsItself(t *testing.T) { + p := scratch(t) + _, err := runCLI(t, p, "plan") + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "--prune") { + t.Errorf("error = %q, it should name both ways to call it", err) + } +} + +func writeTemp(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "plan.md") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// The conversation is a property of the task: the first visit pins a session, +// and every later one resumes it rather than briefing a new agent. +func TestInteractiveSessionIsPinnedThenResumed(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t) + argsFile := filepath.Join(t.TempDir(), "args") + t.Setenv(helperArgs, argsFile) + + mustRunCLI(t, p, "plan", "1", "-i", "--dir", dir) + + d, err := store.OpenAt(p).Load() + if err != nil { + t.Fatal(err) + } + sid := d.Tasks[0].SessionID + if sid == "" { + t.Fatal("the first visit should pin a session; without one it is unresumable") + } + first := readArgs(t, argsFile) + if !strings.Contains(first, "--session-id "+sid) { + t.Errorf("first visit args = %q, want the pinned id", first) + } + if !strings.Contains(first, "ship v2") { + t.Error("the first visit should carry the opening brief") + } + + // Second visit: same conversation, no repeated brief. + mustRunCLI(t, p, "plan", "1", "-i") + again := readArgs(t, argsFile) + if !strings.Contains(again, "--resume "+sid) { + t.Errorf("second visit args = %q, want it to resume", again) + } + if strings.Contains(again, "ship v2") { + t.Errorf("a resumed session should not repeat the brief: %q", again) + } + + // The stored ID does not drift. + d, _ = store.OpenAt(p).Load() + if d.Tasks[0].SessionID != sid { + t.Error("resuming changed the task's session") + } + + // --new-session starts over deliberately. + mustRunCLI(t, p, "plan", "1", "-i", "--new-session") + d, _ = store.OpenAt(p).Load() + if d.Tasks[0].SessionID == sid { + t.Error("--new-session should have started a different conversation") + } +} + +// A plan agreed in conversation is attached when you come back, which is what +// makes the session part of ike rather than just a shell-out. +func TestInteractiveSessionAdoptsTheAgreedPlan(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t) + t.Setenv(helperDraft, "## Agreed\n\nDo the thing.\n") + + out := mustRunCLI(t, p, "plan", "1", "-i", "--dir", dir) + if !strings.Contains(out, "attached the plan you agreed on") { + t.Errorf("out = %q, want the adopted plan reported", out) + } + if got := mustRunCLI(t, p, "plan", "1", "--show"); !strings.Contains(got, "Do the thing.") { + t.Errorf("--show = %q", got) + } + + // Consumed, so a later session that agrees on nothing does not re-adopt it. + t.Setenv(helperDraft, "") + mustRunCLI(t, p, "plan", "1", "--clear") + mustRunCLI(t, p, "plan", "1", "-i") + if _, err := runCLI(t, p, "plan", "1", "--show"); err == nil { + t.Error("a stale draft was adopted a second time") + } +} + +// Supervising still needs permission — being present does not remove the +// decision to let ike start an agent that edits files. +func TestInteractiveDelegateStillNeedsTheGate(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + fakeStream(t) + + if _, err := runCLI(t, p, "delegate", "1", "-i", "--dir", dir); err == nil { + t.Fatal("an interactive delegate should be refused with the gate off") + } + mustRunCLI(t, p, "agent", "enable") + mustRunCLI(t, p, "delegate", "1", "-i", "--dir", dir) +} + +// --plan-first chains the two runs, so one command drafts and then acts. +// ike chooses an effort level from what the run has to work with, and the run +// has to actually use it: a header that says one thing while the agent runs at +// another is worse than no header. +func TestEffortIsRecommendedThenOverridable(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + fakeStream(t, planStream("## The drafted plan")...) + + argsFile := filepath.Join(t.TempDir(), "args") + t.Setenv(helperArgs, argsFile) + + // Planning is the reasoning, so it does not step down. + out := mustRunCLI(t, p, "plan", "1", "--dir", dir) + if !strings.Contains(out, "effort high — drafting a plan") { + t.Errorf("plan header = %q, want the level and the reason", out) + } + if got := readArgs(t, argsFile); !strings.Contains(got, "--effort high") { + t.Errorf("planning args = %q, want the recommendation on the command line", got) + } + + // That run attached a plan, so delegating now is follow-through. + out = mustRunCLI(t, p, "delegate", "1") + if !strings.Contains(out, "effort medium — following an attached plan") { + t.Errorf("delegate header = %q, want the step down and its reason", out) + } + if got := readArgs(t, argsFile); !strings.Contains(got, "--effort medium") { + t.Errorf("delegate args = %q, want the recommendation on the command line", got) + } + + // With the plan cleared there is nothing to follow, so the run has to work + // the approach out as well as do it. + mustRunCLI(t, p, "plan", "1", "--clear") + out = mustRunCLI(t, p, "delegate", "1") + if !strings.Contains(out, "effort high — no plan to follow") { + t.Errorf("delegate header = %q, want the reason for staying high", out) + } + + // An explicit level wins, and is reported without a reason — there is + // nothing to explain about a flag somebody typed. + out = mustRunCLI(t, p, "delegate", "1", "--effort", "low") + if !strings.Contains(out, "effort low") || strings.Contains(out, "effort low —") { + t.Errorf("delegate header = %q, want the bare level for an explicit flag", out) + } + if got := readArgs(t, argsFile); !strings.Contains(got, "--effort low") { + t.Errorf("delegate args = %q, want the override", got) + } +} + +// A mistyped level must fail before a process starts, for the same reason a +// mistyped permission mode does. +func TestBadEffortIsRefusedBeforeRunning(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + + argsFile := filepath.Join(t.TempDir(), "args") + fakeStream(t, planStream("a plan")...) + t.Setenv(helperArgs, argsFile) + + for _, args := range [][]string{ + {"delegate", "1", "--dir", dir, "--effort", "mid"}, + {"plan", "1", "--dir", dir, "--effort", "mid"}, + } { + _, err := runCLI(t, p, args...) + if err == nil { + t.Fatalf("%v: a misspelled effort level should be refused", args) + } + if !strings.Contains(err.Error(), "medium") { + t.Errorf("%v: error = %q, it should list the valid levels", args, err) + } + } + if _, statErr := os.Stat(argsFile); statErr == nil { + t.Error("the agent was started despite the bad flag") + } +} + +func TestPlanFirstChainsBothRuns(t *testing.T) { + p := scratch(t) + dir := t.TempDir() + mustRunCLI(t, p, "add", "ship v2", "-q", "1") + mustRunCLI(t, p, "agent", "enable") + fakeStream(t, planStream("## The drafted plan")...) + + out := mustRunCLI(t, p, "delegate", "1", "--dir", dir, "--plan-first") + if !strings.Contains(out, "attached a plan") { + t.Errorf("out = %q, want the planning run to have attached a plan", out) + } + // And the plan is stored, so it survives for a later run too. + if got := mustRunCLI(t, p, "plan", "1", "--show"); !strings.Contains(got, "The drafted plan") { + t.Errorf("--show = %q", got) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6e49d49..b8db397 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -97,6 +97,9 @@ func NewRootCmd(outer opener) *cobra.Command { newLabelCmd(open), newArchiveCmd(open), newMCPCmd(open), + newAgentCmd(open), + newPlanCmd(open), + newDelegateCmd(open), // The space commands name their target as an argument, so they take the // opener without --space applied — but they still honor --file, since // listing or importing into another document is exactly the point. diff --git a/internal/store/agent_test.go b/internal/store/agent_test.go new file mode 100644 index 0000000..00f0b52 --- /dev/null +++ b/internal/store/agent_test.go @@ -0,0 +1,123 @@ +package store + +import ( + "path/filepath" + "testing" + + "github.com/jonascript/ike/internal/task" +) + +func TestAgentDisabledByDefault(t *testing.T) { + s := testStore(t) + on, err := s.AgentEnabled() + if err != nil { + t.Fatal(err) + } + if on { + t.Error("a fresh matrix should have delegation off") + } + // Ordinary writes must not switch it on by side effect. + s.Add("a task", task.Do) + if on, _ := s.AgentEnabled(); on { + t.Error("delegation turned on by an unrelated write") + } +} + +func TestSetAgentEnabledPersistsAndReportsChange(t *testing.T) { + p := filepath.Join(t.TempDir(), "tasks.json") + + changed, err := OpenAt(p).SetAgentEnabled(true) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("enabling from the default should report a change") + } + // A second process reading the same file sees it. + if on, _ := OpenAt(p).AgentEnabled(); !on { + t.Error("the setting did not persist") + } + // Setting it again is not a change. + if changed, _ := OpenAt(p).SetAgentEnabled(true); changed { + t.Error("enabling an already-enabled file should report no change") + } + if changed, _ := OpenAt(p).SetAgentEnabled(false); !changed { + t.Error("disabling should report a change") + } +} + +// The two gates are separate decisions with different blast radii: letting an +// agent edit the task list is not letting ike start a process that edits files. +// Neither may turn the other on. +func TestTheTwoGatesAreIndependent(t *testing.T) { + s := testStore(t) + + if _, err := s.SetMCPEnabled(true); err != nil { + t.Fatal(err) + } + if on, _ := s.AgentEnabled(); on { + t.Error("enabling MCP access also enabled delegation") + } + + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + if _, err := s.SetMCPEnabled(false); err != nil { + t.Fatal(err) + } + if on, _ := s.AgentEnabled(); !on { + t.Error("revoking MCP access also revoked delegation") + } +} + +// The twin of TestUndoCannotReopenRevokedAccess. AgentEnabled is deliberately +// not in Snapshot, so no sequence of undo or redo can hand back a permission +// the owner closed. +func TestUndoCannotReopenRevokedDelegation(t *testing.T) { + s := testStore(t) + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + a, _, _ := s.Add("a task", task.Do) + s.SetPlan(a.ID, "a plan") + s.SetDir(a.ID, "--dir", t.TempDir()) + if _, err := s.SetAgentEnabled(false); err != nil { + t.Fatal(err) + } + + for i := 0; i < 5; i++ { + if _, _, err := s.Undo(); err != nil { + break + } + if on, _ := s.AgentEnabled(); on { + t.Fatalf("undo %d re-enabled revoked delegation", i) + } + } + for i := 0; i < 5; i++ { + if _, _, err := s.Redo(); err != nil { + break + } + if on, _ := s.AgentEnabled(); on { + t.Fatalf("redo %d re-enabled revoked delegation", i) + } + } +} + +// Consent is a property of the file, so the setting must be reachable even when +// the current space is missing or --space names something that is not there — +// the same guarantee SetMCPEnabled has. +func TestAgentGateWorksWithoutAResolvableSpace(t *testing.T) { + s := testStore(t) + s.Add("a task", task.Do) + + missing := s.InSpace("no-such-space") + if _, err := missing.Load(); err == nil { + t.Fatal("expected the missing space to fail a normal read") + } + if _, err := missing.SetAgentEnabled(true); err != nil { + t.Errorf("SetAgentEnabled should not need a resolvable space: %v", err) + } + if on, err := missing.AgentEnabled(); err != nil || !on { + t.Errorf("AgentEnabled() = %v, %v; should not need a resolvable space", on, err) + } +} diff --git a/internal/store/ops.go b/internal/store/ops.go index b7ccee6..f215a57 100644 --- a/internal/store/ops.go +++ b/internal/store/ops.go @@ -247,6 +247,40 @@ func (s *Store) MCPEnabled() (bool, error) { return f.MCPEnabled, nil } +// SetAgentEnabled turns delegation on or off for this file, and reports +// whether the setting changed. +// +// Every word of SetMCPEnabled's reasoning applies: it is a permission rather +// than an edit, so it is kept off the undo stack and no sequence of undo or +// redo can re-open what the owner closed; it returns no Data because nothing +// renders from it; and it goes through mutateFile rather than Mutate so that it +// keeps working when the current space is missing, because consent is a +// property of the file rather than of a matrix. +// +// It differs from the MCP gate in one way. MCP access is re-checked on every +// read and mutation because an MCP session outlives the check — `ike mcp +// disable` has to reach a client that is already connected. A delegated run has +// no such lifetime: it is started by a command that just read the flag, so the +// gate is checked once, at launch, in internal/cli. That keeps internal/agent a +// pure runner in the way internal/mcpserver is a pure transport. +func (s *Store) SetAgentEnabled(on bool) (changed bool, err error) { + _, err = s.mutateFile(func(f *File) error { + changed = f.AgentEnabled != on + f.AgentEnabled = on + return nil + }) + return changed, err +} + +// AgentEnabled reports whether ike may run an agent against this file. +func (s *Store) AgentEnabled() (bool, error) { + f, err := readFile(s.path) + if err != nil { + return false, s.redact(err) + } + return f.AgentEnabled, nil +} + // Rename changes a task's title. func (s *Store) Rename(id int, title string) (task.Task, Data, error) { title = strings.TrimSpace(title) diff --git a/internal/store/path.go b/internal/store/path.go index dfae591..0ac72db 100644 --- a/internal/store/path.go +++ b/internal/store/path.go @@ -65,3 +65,38 @@ func CheckPath(source, p string) (string, error) { } return p, nil } + +// CheckDir validates a user-supplied working directory — the one a delegated +// run executes in. It is CheckPath's rules applied one level down: where +// CheckPath wants the *parent* of a file to exist, this wants the path itself +// to be a directory that is already there. +// +// The reasoning is CheckPath's, and matters more here. A relative directory +// resolves against whatever working directory the frontend inherited, which for +// a task run from the TUI is wherever the terminal happened to be — so the same +// stored value would send an agent somewhere different each time. And this +// directory is not merely read: it is where a subprocess will edit files, so +// "create it if it is missing" would let a typo point a run at a fresh empty +// directory instead of failing. +func CheckDir(source, p string) (string, error) { + if strings.HasPrefix(p, "~") { + return "", fmt.Errorf("%s=%q starts with ~, which only a shell expands; "+ + "write the path out in full", source, p) + } + if !filepath.IsAbs(p) { + return "", fmt.Errorf("%s=%q must be an absolute path, so a task stays "+ + "attached to the same directory whatever directory ike runs from", source, p) + } + fi, err := os.Stat(p) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("%s=%q does not exist "+ + "(create it first, so a typo cannot invent one)", source, p) + } + return "", fmt.Errorf("%s=%q: %w", source, p, err) + } + if !fi.IsDir() { + return "", fmt.Errorf("%s=%q is not a directory", source, p) + } + return p, nil +} diff --git a/internal/store/plans.go b/internal/store/plans.go new file mode 100644 index 0000000..334ff70 --- /dev/null +++ b/internal/store/plans.go @@ -0,0 +1,359 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/jonascript/ike/internal/task" +) + +// Plans are stored one file per task, beside the data file: +// +// .plans//.md +// +// Beside it, rather than in it, for the reason Task's comment gives: Snapshot +// copies the whole task list into as many as forty snapshots, so a few KB of +// markdown per task would be amplified across the entire history — the same +// blow-up Snapshot.ArchiveEntry already exists to have fixed once. Only the +// PlanAt stamp lives on the task. +// +// Beside the *data file* rather than under XDG_STATE_HOME because a plan is +// user data, not cached state: it must follow --file and IKE_DATA_FILE, so two +// matrices cannot share one set of plans, and it should be picked up by whatever +// backs up the data directory. The layout follows tasks.json.lock and +// tasks.json.bak, which are already siblings of the file they belong to. +// +// The path carries the space name because tasks are numbered per space, so +// task 3 in `work` and task 3 in `personal` are different tasks. + +// planDirSuffix names the sidecar directory holding plan bodies. +const planDirSuffix = ".plans" + +// planDir is the directory holding plans for one space. +func (s *Store) planDir(space string) string { + return filepath.Join(s.path+planDirSuffix, space) +} + +// planPath is the file holding one task's plan. +func (s *Store) planPath(space string, id int) string { + return filepath.Join(s.planDir(space), strconv.Itoa(id)+".md") +} + +// Plan returns the plan attached to a task, or "" if there is none. +// +// A missing file is not an error. The stamp on the task and the file on disk +// are written together under the lock, but a data file restored from a backup — +// or copied to a machine without the sidecar directory — can carry a stamp with +// no body, and refusing to read the matrix over that would be a poor trade. +func (s *Store) Plan(id int) (string, error) { + d, err := s.Load() + if err != nil { + return "", err + } + if _, err := findTask(&d, id); err != nil { + return "", err + } + return s.readPlan(d.Space, id) +} + +func (s *Store) readPlan(space string, id int) (string, error) { + b, err := os.ReadFile(s.planPath(space, id)) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", s.redact(err) + } + // Sanitized on the way out as well as validated on the way in, the same + // double guarantee DisplayTitle gives a title: this file is plain text on + // disk that a hand edit, an older build, or a synced copy can have put + // anything into, and it is bound for a terminal. + return task.SanitizeBlock(string(b)), nil +} + +// SetPlan attaches a plan to a task, replacing any existing one. A blank body +// clears it, the way a blank quadrant label resets to the default. +// +// The body is written inside the same Mutate callback that stamps PlanAt, so +// both are covered by one flock and the stamp cannot end up describing a file +// that was never written. +func (s *Store) SetPlan(id int, body string) (task.Task, Data, error) { + if err := task.ValidatePlan(body); err != nil { + return task.Task{}, Data{}, err + } + if body == "" { + return s.ClearPlan(id) + } + + var out task.Task + d, err := s.mutateSpace(func(space string, d *Data) error { + i, err := findTask(d, id) + if err != nil { + return err + } + // Written before pushUndo so a failing write records no history and + // leaves the matrix untouched — the same reason pushUndo comes after + // validation everywhere else in ops.go. + if err := s.writePlan(space, id, body); err != nil { + return err + } + pushUndo(d, fmt.Sprintf("plan %q", d.Tasks[i].Title)) + + now := time.Now().UTC() + d.Tasks[i].PlanAt = &now + out = d.Tasks[i] + return nil + }) + return out, d, err +} + +// ClearPlan removes a task's plan. +func (s *Store) ClearPlan(id int) (task.Task, Data, error) { + var out task.Task + d, err := s.mutateSpace(func(space string, d *Data) error { + i, err := findTask(d, id) + if err != nil { + return err + } + if d.Tasks[i].PlanAt == nil { + // No stamp, nothing to undo. Returning early rather than recording + // a no-op snapshot keeps `ike undo` describing real changes. + out = d.Tasks[i] + return nil + } + pushUndo(d, fmt.Sprintf("clear the plan for %q", d.Tasks[i].Title)) + if err := s.removePlan(space, id); err != nil { + return err + } + d.Tasks[i].PlanAt = nil + out = d.Tasks[i] + return nil + }) + return out, d, err +} + +// SetDir sets the working directory a delegated run executes in. A blank dir +// clears it. +// +// source names where the path came from, and is passed in rather than fixed +// here for the reason CheckPath's doc gives: the message should point at the +// thing the user typed, and that is `--dir` from the CLI but a prompt in the +// TUI. +func (s *Store) SetDir(id int, source, dir string) (task.Task, Data, error) { + if dir != "" { + p, err := CheckDir(source, dir) + if err != nil { + return task.Task{}, Data{}, err + } + dir = p + } + var out task.Task + d, err := s.Mutate(func(d *Data) error { + i, err := findTask(d, id) + if err != nil { + return err + } + if d.Tasks[i].Dir == dir { + out = d.Tasks[i] + return nil + } + if dir == "" { + pushUndo(d, fmt.Sprintf("clear the directory for %q", d.Tasks[i].Title)) + } else { + pushUndo(d, fmt.Sprintf("set the directory for %q", d.Tasks[i].Title)) + } + d.Tasks[i].Dir = dir + out = d.Tasks[i] + return nil + }) + return out, d, err +} + +// PlanDraftPath is where an interactive agent session is told to leave the plan +// it agreed with you, for AdoptPlanDraft to pick up. +// +// It sits beside the plan itself and is stable per task, which is what makes it +// usable across sessions: the instruction naming it is in the conversation's +// history, so a session resumed next week still writes to somewhere ike looks. +// A temp path would have gone stale the moment the first session ended. +// +// A draft rather than the plan file directly, because the store is the only +// thing that writes that: routing through SetPlan keeps the atomic write, the +// validation, the PlanAt stamp, and the undo entry, none of which an agent +// writing the file itself would produce. +// The directory is created here, not just named. A task with no plan yet has no +// plan directory either, so handing an agent a path inside one that does not +// exist would make the write fail — and the plan agreed in the conversation +// would be lost at the last step, which is the worst possible moment. +func (s *Store) PlanDraftPath(id int) (string, error) { + d, err := s.Load() + if err != nil { + return "", err + } + if err := os.MkdirAll(s.planDir(d.Space), dataDirMode); err != nil { + return "", s.redact(err) + } + return s.planDraftPath(d.Space, id), nil +} + +func (s *Store) planDraftPath(space string, id int) string { + return filepath.Join(s.planDir(space), strconv.Itoa(id)+".draft.md") +} + +// AdoptPlanDraft attaches whatever an agent left at the draft path, and reports +// whether there was anything to attach. +// +// Called after an interactive session ends. A missing or blank draft is the +// ordinary case — most conversations do not end in a plan — so it is not an +// error. +func (s *Store) AdoptPlanDraft(id int) (task.Task, Data, bool, error) { + d, err := s.Load() + if err != nil { + return task.Task{}, Data{}, false, err + } + draft := s.planDraftPath(d.Space, id) + + b, err := os.ReadFile(draft) + if errors.Is(err, os.ErrNotExist) { + return task.Task{}, d, false, nil + } + if err != nil { + return task.Task{}, Data{}, false, s.redact(err) + } + body := strings.TrimSpace(task.SanitizeBlock(string(b))) + if body == "" { + _ = os.Remove(draft) + return task.Task{}, d, false, nil + } + + t, out, err := s.SetPlan(id, body) + if err != nil { + // Deliberately left in place. The draft is the only copy of work the + // agent just did, and removing it because ike could not store it would + // destroy it — a plan too long to validate is still recoverable by hand + // from the path AdoptPlanDraft was about to read. + return task.Task{}, Data{}, false, err + } + _ = os.Remove(draft) + return t, out, true, nil +} + +// SetSession pins an interactive conversation to a task, so later visits resume +// it rather than starting over. A blank id forgets the conversation. +func (s *Store) SetSession(id int, sessionID string) (task.Task, Data, error) { + var out task.Task + d, err := s.Mutate(func(d *Data) error { + i, err := findTask(d, id) + if err != nil { + return err + } + if d.Tasks[i].SessionID == sessionID { + out = d.Tasks[i] + return nil + } + // No pushUndo. A session ID is a pointer to a conversation that exists + // outside ike, and undoing back to a previous one would resume a + // conversation the user has moved on from — closer to reopening revoked + // MCP access than to reversing an edit. + d.Tasks[i].SessionID = sessionID + out = d.Tasks[i] + return nil + }) + return out, d, err +} + +// writePlan replaces one task's plan file. +func (s *Store) writePlan(space string, id int, body string) error { + dir := s.planDir(space) + if err := os.MkdirAll(dir, dataDirMode); err != nil { + return s.redact(err) + } + // Through the same atomic write the data file uses, so an interrupted write + // leaves the previous plan rather than a truncated one. No .bak here: unlike + // the matrix, a plan is one task's worth of text and is cheap to redraft. + if err := writeBytesAtomic(s.planPath(space, id), ".plan-*.md", []byte(body)); err != nil { + return s.redact(err) + } + return nil +} + +func (s *Store) removePlan(space string, id int) error { + err := os.Remove(s.planPath(space, id)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return s.redact(err) + } + return nil +} + +// PrunePlans deletes plan files that belong to no active task, and reports how +// many it removed. +// +// Deleting a task deliberately does *not* delete its plan: Delete is undoable, +// so removing the body would make undo silently lossy — the task would come +// back with a PlanAt stamp and nothing behind it. Because NextID is monotonic +// and IDs are never reused, an orphan can never be picked up by a later task, +// so leaving it is safe and costs a few KB. This is the explicit sweep for +// anyone who wants the space back. +// +// Archived tasks keep their plans, since Restore brings them back active. +func (s *Store) PrunePlans() (int, error) { + f, err := s.loadFile() + if err != nil { + return 0, err + } + + removed := 0 + for space, d := range f.Spaces { + live := make(map[int]bool, len(d.Tasks)+len(d.Archive)) + for _, t := range d.Tasks { + live[t.ID] = true + } + for _, t := range d.Archive { + live[t.ID] = true + } + + entries, err := os.ReadDir(s.planDir(space)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return removed, s.redact(err) + } + for _, e := range entries { + id, ok := planFileID(e.Name()) + if !ok || live[id] { + continue + } + if err := os.Remove(filepath.Join(s.planDir(space), e.Name())); err != nil { + return removed, s.redact(err) + } + removed++ + } + } + return removed, nil +} + +// planFileID parses ".md" or ".draft.md" back into a task ID. Anything +// else in the directory is left alone — a sweep that deleted files it did not +// recognize would be a poor thing to point at a directory inside someone's data +// folder. +// +// Drafts are included so a conversation abandoned half way does not leave a +// file that nothing ever cleans up. +func planFileID(name string) (int, bool) { + base, ok := strings.CutSuffix(name, ".md") + if !ok { + return 0, false + } + base = strings.TrimSuffix(base, ".draft") + id, err := strconv.Atoi(base) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} diff --git a/internal/store/plans_test.go b/internal/store/plans_test.go new file mode 100644 index 0000000..73af8c4 --- /dev/null +++ b/internal/store/plans_test.go @@ -0,0 +1,559 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jonascript/ike/internal/task" +) + +const samplePlan = "## Goal\n\nShip it.\n\n- [ ] step one\n- [ ] step two\n" + +func TestSetPlanRoundTrips(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + + got, d, err := s.SetPlan(a.ID, samplePlan) + if err != nil { + t.Fatal(err) + } + if !got.HasPlan() { + t.Error("the returned task should carry a PlanAt stamp") + } + // The post-mutation Data is what a frontend renders from, so the stamp has + // to be visible there too rather than only on the returned task. + if ts := d.List(task.Do); len(ts) != 1 || !ts[0].HasPlan() { + t.Error("the returned Data should show the task as planned") + } + + body, err := s.Plan(a.ID) + if err != nil { + t.Fatal(err) + } + if body != samplePlan { + t.Errorf("Plan() = %q, want %q", body, samplePlan) + } +} + +// The whole reason plans are sidecar files is that Snapshot copies the task +// list wholesale, so a plan body on the Task would be cloned into every +// snapshot. This is the check that no plan text reached the data file. +func TestPlanBodyStaysOutOfTheDataFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "tasks.json") + s := OpenAt(p) + + a, _, _ := s.Add("ship v2", task.Do) + if _, _, err := s.SetPlan(a.ID, samplePlan); err != nil { + t.Fatal(err) + } + // Enough mutations to push snapshots onto the history stack. + for i := 0; i < 5; i++ { + s.Add("filler", task.Schedule) + } + + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "Ship it.") { + t.Error("the plan body reached tasks.json; it belongs in the sidecar, " + + "or every undo snapshot carries a copy of it") + } + // The stamp, by contrast, must be there — it is what marks the task planned. + if !strings.Contains(string(b), "plan_at") { + t.Error("the PlanAt stamp should persist in the data file") + } +} + +func TestPlanFileIsPrivate(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "tasks.json") + s := OpenAt(p) + a, _, _ := s.Add("ship v2", task.Do) + if _, _, err := s.SetPlan(a.ID, samplePlan); err != nil { + t.Fatal(err) + } + + // A plan is as personal as the matrix, and is written through the same + // atomic path, so it inherits the same modes. + fi, err := os.Stat(filepath.Join(p+planDirSuffix, "default", "1.md")) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != dataFileMode { + t.Errorf("plan file mode = %#o, want %#o", got, dataFileMode) + } + di, err := os.Stat(filepath.Join(p+planDirSuffix, "default")) + if err != nil { + t.Fatal(err) + } + if got := di.Mode().Perm(); got != dataDirMode { + t.Errorf("plan dir mode = %#o, want %#o", got, dataDirMode) + } +} + +func TestSetPlanReplacesAndClears(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + + s.SetPlan(a.ID, "first") + s.SetPlan(a.ID, "second") + if body, _ := s.Plan(a.ID); body != "second" { + t.Errorf("Plan() = %q, want the replacement", body) + } + + // A blank body clears, the way a blank quadrant label resets to the default. + got, _, err := s.SetPlan(a.ID, "") + if err != nil { + t.Fatal(err) + } + if got.HasPlan() { + t.Error("a blank plan should clear the stamp") + } + if body, _ := s.Plan(a.ID); body != "" { + t.Errorf("Plan() = %q after clearing, want empty", body) + } +} + +func TestSetPlanRejectsBadInput(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + + if _, _, err := s.SetPlan(a.ID, "plan\x1b[2Kwith an escape"); err == nil { + t.Error("a plan carrying an escape sequence should be refused") + } + if _, _, err := s.SetPlan(a.ID, strings.Repeat("x", task.MaxPlanLen+1)); err == nil { + t.Error("an over-long plan should be refused") + } + if _, _, err := s.SetPlan(999, samplePlan); err == nil { + t.Error("a plan for a task that does not exist should be refused") + } + // A refused plan leaves nothing behind. + if body, _ := s.Plan(a.ID); body != "" { + t.Errorf("a rejected plan was written anyway: %q", body) + } +} + +// A plan is a property of a task, so an unrelated mutation being undone must +// not take it with it. +func TestPlanSurvivesUnrelatedUndo(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + s.SetPlan(a.ID, samplePlan) + + b, _, _ := s.Add("something else", task.Schedule) + if _, _, err := s.Complete(b.ID); err != nil { + t.Fatal(err) + } + if _, _, err := s.Undo(); err != nil { + t.Fatal(err) + } + + body, err := s.Plan(a.ID) + if err != nil { + t.Fatal(err) + } + if body != samplePlan { + t.Errorf("Plan() = %q after an unrelated undo, want it intact", body) + } + d, _ := s.Load() + if ts := d.List(task.Do); len(ts) != 1 || !ts[0].HasPlan() { + t.Error("the PlanAt stamp was lost to an unrelated undo") + } +} + +// Attaching a plan is itself undoable, since it goes through pushUndo. +func TestUndoRemovesThePlanStamp(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + s.SetPlan(a.ID, samplePlan) + + if _, _, err := s.Undo(); err != nil { + t.Fatal(err) + } + d, _ := s.Load() + if ts := d.List(task.Do); len(ts) != 1 || ts[0].HasPlan() { + t.Error("undoing the plan should clear the stamp") + } +} + +// Clearing a plan on a task that has none is a no-op, and must not push a +// snapshot — `ike undo` should describe changes that happened. +func TestClearPlanWithNoPlanRecordsNothing(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + + before, _ := s.Load() + if _, _, err := s.ClearPlan(a.ID); err != nil { + t.Fatal(err) + } + after, _ := s.Load() + if len(after.Undo) != len(before.Undo) { + t.Errorf("undo depth went %d → %d; a no-op clear recorded history", + len(before.Undo), len(after.Undo)) + } +} + +// Plans are keyed by space, because task IDs are per space: task 1 in `work` +// and task 1 in `personal` are different tasks. +func TestPlansAreScopedToTheirSpace(t *testing.T) { + s := testStore(t) + if _, err := s.NewSpace("work"); err != nil { + t.Fatal(err) + } + + def := s.InSpace("default") + work := s.InSpace("work") + a, _, _ := def.Add("default task", task.Do) + b, _, _ := work.Add("work task", task.Do) + if a.ID != b.ID { + t.Fatalf("expected both spaces to number from the same start, got %d and %d", a.ID, b.ID) + } + + def.SetPlan(a.ID, "the default plan") + work.SetPlan(b.ID, "the work plan") + + if got, _ := def.Plan(a.ID); got != "the default plan" { + t.Errorf("default space Plan() = %q", got) + } + if got, _ := work.Plan(b.ID); got != "the work plan" { + t.Errorf("work space Plan() = %q", got) + } +} + +// A stamp with no file behind it is reachable from a restored backup or a copy +// that left the sidecar directory behind. Reading the matrix must still work. +func TestPlanToleratesAMissingFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "tasks.json") + s := OpenAt(p) + a, _, _ := s.Add("ship v2", task.Do) + s.SetPlan(a.ID, samplePlan) + + if err := os.RemoveAll(p + planDirSuffix); err != nil { + t.Fatal(err) + } + body, err := s.Plan(a.ID) + if err != nil { + t.Fatalf("a missing plan file should read as empty, got %v", err) + } + if body != "" { + t.Errorf("Plan() = %q, want empty", body) + } +} + +// The plan file is plain text on disk, so it can carry anything a hand edit or +// a synced copy put there. It is sanitized on the way out for the same reason +// DisplayTitle sanitizes a title. +func TestPlanSanitizesOnRead(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "tasks.json") + s := OpenAt(p) + a, _, _ := s.Add("ship v2", task.Do) + s.SetPlan(a.ID, "placeholder") + + raw := "real\x1b[2K\rFAKE\n\nsecond line\n" + if err := os.WriteFile(filepath.Join(p+planDirSuffix, "default", "1.md"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + body, err := s.Plan(a.ID) + if err != nil { + t.Fatal(err) + } + if strings.ContainsRune(body, 0x1b) || strings.ContainsRune(body, '\r') { + t.Errorf("Plan() = %q, still carries control characters", body) + } + // Structure survives; only the control bytes are replaced. + if !strings.Contains(body, "\n\nsecond line") { + t.Errorf("Plan() = %q, lost its line structure", body) + } +} + +func TestSetDir(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + dir := t.TempDir() + + got, _, err := s.SetDir(a.ID, "--dir", dir) + if err != nil { + t.Fatal(err) + } + if got.Dir != dir { + t.Errorf("Dir = %q, want %q", got.Dir, dir) + } + + d, _ := s.Load() + if ts := d.List(task.Do); len(ts) != 1 || ts[0].Dir != dir { + t.Error("the directory did not persist") + } + + // Blank clears. + if got, _, err := s.SetDir(a.ID, "--dir", ""); err != nil || got.Dir != "" { + t.Errorf("clearing gave Dir=%q err=%v", got.Dir, err) + } +} + +func TestSetDirRejectsBadPaths(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + file := filepath.Join(t.TempDir(), "afile") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + dir string + }{ + {"relative", "some/where"}, + {"unexpanded tilde", "~/dev/ike"}, + {"does not exist", filepath.Join(t.TempDir(), "nope")}, + {"not a directory", file}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, _, err := s.SetDir(a.ID, "--dir", c.dir); err == nil { + t.Errorf("SetDir(%q) should have been refused", c.dir) + } + }) + } +} + +// Deleting a task deliberately leaves its plan, so undo can bring both back. +// PrunePlans is the explicit sweep for the orphans that leaves behind. +func TestDeleteKeepsThePlanAndPruneRemovesIt(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "tasks.json") + s := OpenAt(p) + a, _, _ := s.Add("ship v2", task.Do) + s.SetPlan(a.ID, samplePlan) + + if _, _, err := s.Delete(a.ID); err != nil { + t.Fatal(err) + } + planFile := filepath.Join(p+planDirSuffix, "default", "1.md") + if _, err := os.Stat(planFile); err != nil { + t.Fatalf("delete removed the plan file; undo would bring the task back empty: %v", err) + } + // And undo does bring it back, plan and all. + if _, _, err := s.Undo(); err != nil { + t.Fatal(err) + } + if body, _ := s.Plan(a.ID); body != samplePlan { + t.Errorf("after undoing the delete, Plan() = %q", body) + } + + // Now really delete it, and sweep. + s.Delete(a.ID) + n, err := s.PrunePlans() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("PrunePlans() removed %d, want 1", n) + } + if _, err := os.Stat(planFile); !os.IsNotExist(err) { + t.Error("PrunePlans left the orphan behind") + } +} + +// An archived task still owns its plan: Restore brings it back active. +func TestPruneKeepsArchivedTasksPlans(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + s.SetPlan(a.ID, samplePlan) + if _, _, err := s.Complete(a.ID); err != nil { + t.Fatal(err) + } + + n, err := s.PrunePlans() + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("PrunePlans() removed %d; an archived task keeps its plan", n) + } +} + +// A conversation is pinned to the task so a later visit resumes it rather than +// briefing a new agent. +func TestSetSessionPersists(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + + if a.HasSession() { + t.Error("a fresh task has no conversation") + } + got, d, err := s.SetSession(a.ID, "d3c56c08-c430-455d-8f2c-849ff6294610") + if err != nil { + t.Fatal(err) + } + if !got.HasSession() { + t.Error("the returned task should carry the session") + } + if ts := d.List(task.Do); len(ts) != 1 || !ts[0].HasSession() { + t.Error("the returned Data should show the session") + } + reread, _ := s.Load() + if reread.Tasks[0].SessionID != "d3c56c08-c430-455d-8f2c-849ff6294610" { + t.Errorf("SessionID = %q, it did not persist", reread.Tasks[0].SessionID) + } +} + +// Undo must not walk a task back to an older conversation: the ID points at +// something outside ike, and resuming a conversation you moved on from is +// closer to reopening revoked access than to reversing an edit. +func TestUndoDoesNotRewindTheSession(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + s.SetSession(a.ID, "first") + s.SetSession(a.ID, "second") + + for range 3 { + if _, _, err := s.Undo(); err != nil { + break + } + } + d, _ := s.Load() + if len(d.Tasks) > 0 && d.Tasks[0].SessionID == "first" { + t.Error("undo rewound to a previous conversation") + } +} + +// The draft is how a plan agreed in conversation gets attached: the agent +// writes it, ike adopts it through SetPlan so it still gets validation, the +// atomic write, the stamp, and an undo entry. +func TestAdoptPlanDraft(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + + // Nothing to adopt is the ordinary case, not an error. + if _, _, got, err := s.AdoptPlanDraft(a.ID); err != nil || got { + t.Errorf("AdoptPlanDraft with no draft = %v, %v; want false, nil", got, err) + } + + draft, err := s.PlanDraftPath(a.ID) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(draft), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draft, []byte("## Agreed\n\nDo the thing.\n"), 0o600); err != nil { + t.Fatal(err) + } + + tk, d, got, err := s.AdoptPlanDraft(a.ID) + if err != nil || !got { + t.Fatalf("AdoptPlanDraft = %v, %v", got, err) + } + if !tk.HasPlan() { + t.Error("adopting should stamp the task as planned") + } + if ts := d.List(task.Do); len(ts) != 1 || !ts[0].HasPlan() { + t.Error("the returned Data should show the task as planned") + } + if body, _ := s.Plan(a.ID); body != "## Agreed\n\nDo the thing." { + t.Errorf("Plan() = %q", body) + } + // Consumed, so the next session does not re-adopt a stale plan. + if _, err := os.Stat(draft); !os.IsNotExist(err) { + t.Error("the draft should be removed once adopted") + } +} + +// A blank draft means the conversation did not end in a plan, which is most of +// them. +func TestAdoptPlanDraftIgnoresABlankDraft(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + draft, _ := s.PlanDraftPath(a.ID) + if err := os.MkdirAll(filepath.Dir(draft), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draft, []byte(" \n\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, _, got, err := s.AdoptPlanDraft(a.ID); err != nil || got { + t.Errorf("AdoptPlanDraft = %v, %v; want false, nil", got, err) + } + if _, err := os.Stat(draft); !os.IsNotExist(err) { + t.Error("a blank draft should be cleared away") + } +} + +// A draft ike cannot store is left in place: it is the only copy of what the +// agent just wrote, and deleting it because validation failed would destroy it. +func TestAdoptPlanDraftKeepsADraftItCannotStore(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + draft, _ := s.PlanDraftPath(a.ID) + if err := os.MkdirAll(filepath.Dir(draft), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draft, []byte(strings.Repeat("x", task.MaxPlanLen+1)), 0o600); err != nil { + t.Fatal(err) + } + + if _, _, _, err := s.AdoptPlanDraft(a.ID); err == nil { + t.Fatal("an over-long draft should be refused") + } + if _, err := os.Stat(draft); err != nil { + t.Error("the draft should survive so it can be recovered by hand") + } +} + +// A conversation abandoned half way leaves a draft; prune sweeps those too. +func TestPruneRemovesOrphanedDrafts(t *testing.T) { + s := testStore(t) + a, _, _ := s.Add("ship v2", task.Do) + draft, _ := s.PlanDraftPath(a.ID) + if err := os.MkdirAll(filepath.Dir(draft), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draft, []byte("half a plan"), 0o600); err != nil { + t.Fatal(err) + } + + // While the task is alive the draft is not an orphan. + if n, _ := s.PrunePlans(); n != 0 { + t.Errorf("PrunePlans() removed %d while the task still exists", n) + } + s.Delete(a.ID) + if n, _ := s.PrunePlans(); n != 1 { + t.Errorf("PrunePlans() removed %d, want the orphaned draft", n) + } +} + +func TestPlanFileID(t *testing.T) { + cases := []struct { + name string + want int + ok bool + }{ + {"1.md", 1, true}, + {"42.md", 42, true}, + {"1.draft.md", 1, true}, + {"42.draft.md", 42, true}, + {"draft.md", 0, false}, + {"0.md", 0, false}, + {"-1.md", 0, false}, + {"notanumber.md", 0, false}, + {"1.txt", 0, false}, + {"1", 0, false}, + {"README", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := planFileID(c.name) + if got != c.want || ok != c.ok { + t.Errorf("planFileID(%q) = %d, %v; want %d, %v", c.name, got, ok, c.want, c.ok) + } + }) + } +} diff --git a/internal/store/spaces.go b/internal/store/spaces.go index a4fd07f..68e1b14 100644 --- a/internal/store/spaces.go +++ b/internal/store/spaces.go @@ -70,6 +70,7 @@ func (f *File) dataFor(name string, d *Data) Data { out.Space = name out.AllSpaces = f.spaceInfos() out.MCPAllowed = f.MCPEnabled + out.AgentAllowed = f.AgentEnabled return out } diff --git a/internal/store/store.go b/internal/store/store.go index 0b1d2b9..48acdb0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -74,11 +74,20 @@ const defaultSpace = "default" // consent decision about a file ("I have decided to let my agent manage this"), // not a property of one matrix, and a per-space gate would mean an agent // holding access to one space could still see the names of the others. +// +// AgentEnabled is the second consent flag, and is deliberately separate from +// MCPEnabled rather than folded into one "allow agents" setting. They are +// different decisions with different blast radii: MCPEnabled means an agent +// already running may read and edit this task list, while AgentEnabled means +// ike itself may start a process that edits files in a directory. Agreeing to +// the first is not agreeing to the second, and someone who wants their agent to +// tidy their matrix should not thereby have granted `ike delegate`. type File struct { - Version int `json:"version"` - Current string `json:"current"` - Spaces map[string]*Data `json:"spaces"` - MCPEnabled bool `json:"mcp_enabled,omitempty"` + Version int `json:"version"` + Current string `json:"current"` + Spaces map[string]*Data `json:"spaces"` + MCPEnabled bool `json:"mcp_enabled,omitempty"` + AgentEnabled bool `json:"agent_enabled,omitempty"` } // Data is one space: a complete matrix, and the unit every operation acts on. @@ -111,6 +120,13 @@ type Data struct { // still report success, and persist nothing. The rename makes it a // compile error instead of a silent no-op. MCPAllowed bool `json:"-"` + // AgentAllowed reports whether delegation is on for the whole file. It is + // named apart from File.AgentEnabled for the same reason MCPAllowed is. + // + // Display only: a frontend renders the ambient marker from it, but the + // decision to actually start a run reads the flag fresh, so `ike agent + // disable` in another terminal takes effect without waiting for a poll. + AgentAllowed bool `json:"-"` } // Labels holds user-chosen names for quadrants. It is sparse: only quadrants @@ -354,6 +370,21 @@ func (s *Store) loadResolved(space string) (File, string, *Data, error) { // exclusive lock, then atomically writes the whole document. It returns the // post-mutation data for that space. func (s *Store) Mutate(fn func(*Data) error) (Data, error) { + return s.mutateSpace(func(_ string, d *Data) error { return fn(d) }) +} + +// mutateSpace is Mutate with the resolved space name handed to the callback. +// +// It exists for the plan operations, which write a file named after the space +// (plans.go) and so cannot use Mutate: Data.Space is derived by dataFor *after* +// fn returns, so inside a Mutate callback it is still empty. Reading the name +// separately would mean resolving twice, and the second resolution would be +// outside this lock — a `ike space use` landing in between would file the plan +// under the wrong space. +// +// Everything else about the two is identical, so Mutate delegates here rather +// than the pair growing two copies of the resolve-then-render sequence. +func (s *Store) mutateSpace(fn func(name string, d *Data) error) (Data, error) { var out Data _, err := s.mutateFile(func(f *File) error { // Resolved inside the lock, and before fn, so a mutation naming a space @@ -362,7 +393,7 @@ func (s *Store) Mutate(fn func(*Data) error) (Data, error) { if err != nil { return err } - if err := fn(d); err != nil { + if err := fn(name, d); err != nil { return err } out = f.dataFor(name, d) @@ -507,13 +538,30 @@ func writeFileAtomic(path string, doc File) error { if err := writeBackup(path); err != nil { return err } + return writeBytesAtomic(path, ".tasks-*.json", b) +} +// writeBytesAtomic replaces path with b, atomically and durably. It is the body +// writeFileAtomic used to hold inline, lifted out so that the plan sidecars +// (plans.go) get the same four guarantees rather than a second, untested copy +// of them: a private temp file, fsync before the rename, an atomic rename, and +// a best-effort directory fsync after. +// +// This is deliberately *not* a second write path in the sense CLAUDE.md +// forbids. mutateFile still owns the lock, the re-read, and the gate; this owns +// only the bytes-to-disk step, and mutateFile reaches it through +// writeFileAtomic exactly as before. durability_test.go pins the behavior and +// passes unchanged, which is the check that the lift was faithful. +// +// pattern is the os.CreateTemp pattern, so the leftovers of an interrupted +// write are recognizable as belonging to the file they were replacing. +func writeBytesAtomic(path, pattern string, b []byte) error { // A random name created with O_EXCL, rather than path+".tmp". IKE_DATA_FILE // can point anywhere, including a shared directory, and opening a // predictable name follows symlinks — so a pre-created path+".tmp" symlink // would redirect this write and truncate whatever it pointed at. // os.CreateTemp opens with 0600, so the replacement file is private. - f, err := os.CreateTemp(filepath.Dir(path), ".tasks-*.json") + f, err := os.CreateTemp(filepath.Dir(path), pattern) if err != nil { return err } diff --git a/internal/store/transfer.go b/internal/store/transfer.go index ecb85f7..45f73f3 100644 --- a/internal/store/transfer.go +++ b/internal/store/transfer.go @@ -18,11 +18,18 @@ import ( // document holding just that space, which opens with `ike --file` and imports // with `ike space import`. // -// MCP access is deliberately left off in the exported file, whatever it is here. -// Consent is a decision about a file on a machine, and an export exists to be -// copied elsewhere — carrying "agents may read this" along to a machine whose -// owner never said so would be the wrong default in the one direction that -// matters. +// Both consent flags — MCP access and agent delegation — are deliberately left +// off in the exported file, whatever they are here. Consent is a decision about +// a file on a machine, and an export exists to be copied elsewhere: carrying +// "agents may read this", still less "ike may start an agent", along to a +// machine whose owner never said so would be the wrong default in the one +// direction that matters. The out literal below gets this by construction, by +// naming only the three fields an export carries. +// +// Plan bodies are *not* exported. They live beside the data file rather than in +// it (see plans.go), so an export carries the tasks and their PlanAt stamps but +// not the markdown. That is a known gap rather than a decision — a task landing +// on the other machine will report a plan it cannot show. // // It refuses to overwrite unless force, and writes through the same atomic path // as any other write, so an interrupted export cannot leave a half file that @@ -67,8 +74,8 @@ func (s *Store) ExportSpace(name, path string, force bool) (SpaceInfo, error) { // name. as renames a single imported space on the way in. // // Task IDs need no renumbering: next_id belongs to the space and travels with -// it. MCP access is never carried in, for the same reason export never writes -// it out. +// it. Neither consent flag is ever carried in, for the same reason export never +// writes them out. func (s *Store) ImportSpaces(path, as string, all bool) ([]SpaceInfo, error) { p, err := CheckPath("import path", path) if err != nil { diff --git a/internal/task/task.go b/internal/task/task.go index b54ac4e..49f5884 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -40,6 +40,28 @@ const MaxSpaceNameLen = 32 // title is amplified across the entire history. const MaxTitleLen = 500 +// MaxPlanLen bounds an attached plan. It is far larger than a title because a +// plan is prose, and it costs nothing in the history: plan bodies are stored +// beside the data file rather than on the Task, precisely so they stay out of +// the snapshots the title comment above is worried about. +// +// The bound still matters at the other end. A plan is written by an agent, and +// an agent that loops can produce a great deal of text; this is what stops one +// run filling the disk. +const MaxPlanLen = 20000 + +// isControl reports whether r is a C0 or C1 control character. +func isControl(r rune) bool { + return r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) +} + +// isBlockControl reports whether r is a control character that has no place in +// multi-line text. Tab and newline are the two that do: they are the structure +// of a plan or a transcript rather than an attack on the display. +func isBlockControl(r rune) bool { + return isControl(r) && r != '\n' && r != '\t' +} + // firstControlChar returns the first C0 or C1 control character in s, and // whether it found one. // @@ -54,7 +76,18 @@ const MaxTitleLen = 500 // human-readable views are the ones people trust. func firstControlChar(s string) (rune, bool) { for _, r := range s { - if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + if isControl(r) { + return r, true + } + } + return 0, false +} + +// firstBlockControlChar is firstControlChar for text that is allowed to span +// lines — a plan, or a line of agent output. +func firstBlockControlChar(s string) (rune, bool) { + for _, r := range s { + if isBlockControl(r) { return r, true } } @@ -125,7 +158,32 @@ func ValidateSpaceName(name string) error { // of the check, so it is enforced at both ends rather than trusting the file. func SanitizeDisplay(s string) string { return strings.Map(func(r rune) rune { - if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + if isControl(r) { + return '�' + } + return r + }, s) +} + +// SanitizeBlock is SanitizeDisplay for text that is meant to span lines: an +// attached plan, or a line of agent output on its way to the screen. It keeps +// newlines and tabs and replaces every other control character. +// +// It exists because SanitizeDisplay cannot be used here. That function replaces +// every rune below 0x20, which includes '\n' — running a plan through it turns +// each line break into U+FFFD and renders the whole plan as one long line. The +// two are separate functions rather than one with a flag so that neither call +// site can pick the wrong behavior silently: a single-line field that used +// SanitizeBlock by mistake would let a newline forge an extra row, which is the +// exact failure firstControlChar's comment describes. +// +// Agent output is the most untrusted text ike renders — it is bytes chosen by a +// model, printed into a terminal — so every transcript line goes through here +// before it is drawn. Note that width-aware truncation is not a substitute: +// ansi.Truncate measures escape sequences without removing them. +func SanitizeBlock(s string) string { + return strings.Map(func(r rune) rune { + if isBlockControl(r) { return '�' } return r @@ -134,6 +192,14 @@ func SanitizeDisplay(s string) string { // Task is a single to-do item. Completed tasks keep their ID and gain a // DoneAt timestamp when moved to the archive. +// +// Dir and PlanAt support delegation: a task can carry a working directory and +// a plan, and either can be handed to an agent. Note what is *not* here — the +// plan body. Snapshot copies Tasks wholesale into as many as forty snapshots, +// so a few KB of markdown per task would be amplified across the whole history, +// which is the blow-up Snapshot.ArchiveEntry exists to have already fixed once. +// PlanAt is the marker frontends render from; the body lives beside the data +// file and is read on demand. See internal/store/plans.go. type Task struct { ID int `json:"id"` Title string `json:"title"` @@ -141,8 +207,31 @@ type Task struct { Rank float64 `json:"rank,omitempty"` CreatedAt time.Time `json:"created_at"` DoneAt *time.Time `json:"done_at,omitempty"` + + // Dir is the working directory a delegated run executes in, remembered + // after the first run so a task stays attached to the project it is about. + Dir string `json:"dir,omitempty"` + // PlanAt is when the attached plan was last written, and nil when there is + // no plan. It says a plan exists without making a frontend stat a file to + // find out, so the matrix can mark planned tasks from the Data it holds. + PlanAt *time.Time `json:"plan_at,omitempty"` + // SessionID pins an interactive agent conversation to this task. + // + // Claude Code lets the caller choose a session's ID rather than only + // reporting one back, so ike mints this once and passes it as --session-id + // the first time, then --resume on every later visit. That is what makes + // the conversation a property of the task: you can talk something through, + // leave, and come back days later to the same history rather than + // re-explaining it. + SessionID string `json:"session_id,omitempty"` } +// HasPlan reports whether a plan is attached. +func (t Task) HasPlan() bool { return t.PlanAt != nil } + +// HasSession reports whether a conversation has been started for this task. +func (t Task) HasSession() bool { return t.SessionID != "" } + // DisplayTitle is the title as it is safe to print into a terminal. Use it for // every human-facing render; --json output keeps the raw title, since // encoding/json escapes control characters itself. @@ -198,3 +287,18 @@ func Validate(title string, q Quadrant) error { } return nil } + +// ValidatePlan checks a plan body. Like ValidateLabel, a blank plan is not an +// error here — callers treat it as "clear the attached plan". +// +// It allows newlines and tabs where Validate does not, because a plan is a +// block of markdown rather than a line in a listing. +func ValidatePlan(body string) error { + if n := len([]rune(body)); n > MaxPlanLen { + return fmt.Errorf("plan is %d characters; the maximum is %d", n, MaxPlanLen) + } + if r, bad := firstBlockControlChar(body); bad { + return fmt.Errorf("plan cannot contain control characters (found %#U)", r) + } + return nil +} diff --git a/internal/task/task_test.go b/internal/task/task_test.go index 8389e50..125c128 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -138,6 +138,82 @@ func TestSanitizeDisplay(t *testing.T) { } } +func TestSanitizeBlock(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain text untouched", "buy milk", "buy milk"}, + {"unicode untouched", "café 🚀", "café 🚀"}, + {"newline kept", "two\nlines", "two\nlines"}, + {"tab kept", "a\tb", "a\tb"}, + {"crlf keeps the newline, drops the return", "a\r\nb", "a�\nb"}, + {"escape replaced", "\x1b[31mred", "�[31mred"}, + {"osc 52 clipboard write replaced", "\x1b]52;c;aGk=\x07", "�]52;c;aGk=�"}, + {"nul replaced", "a\x00b", "a�b"}, + {"del replaced", "a\x7fb", "a�b"}, + {"c1 replaced", "a\u009bb", "a�b"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := SanitizeBlock(c.in); got != c.want { + t.Errorf("SanitizeBlock(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// The two sanitizers must not be interchangeable. SanitizeDisplay replaces +// every rune below 0x20, newline included, so using it on a plan would collapse +// the whole thing onto one line of replacement characters — and using +// SanitizeBlock on a title would let a newline forge an extra listing row. +func TestSanitizersDifferOnNewlines(t *testing.T) { + const plan = "## Goal\n\n- step one\n- step two\n" + + if got := SanitizeBlock(plan); got != plan { + t.Errorf("SanitizeBlock mangled a plan: %q", got) + } + if got := SanitizeDisplay(plan); strings.Contains(got, "\n") { + t.Error("SanitizeDisplay is supposed to strip newlines; " + + "if that changed, SanitizeBlock has lost its reason to exist") + } +} + +func TestValidatePlan(t *testing.T) { + cases := []struct { + name string + body string + wantErr bool + }{ + {"blank is a clear, not an error", "", false}, + {"markdown with newlines and tabs", "## Goal\n\n- a\n\tb\n", false}, + {"at the limit", strings.Repeat("x", MaxPlanLen), false}, + {"over the limit", strings.Repeat("x", MaxPlanLen+1), true}, + {"counts runes, not bytes", strings.Repeat("é", MaxPlanLen), false}, + {"escape rejected", "plan\x1b[2Kwith escape", true}, + {"carriage return rejected", "plan\rwith return", true}, + {"nul rejected", "plan\x00", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if err := ValidatePlan(c.body); (err != nil) != c.wantErr { + t.Errorf("ValidatePlan() error = %v, wantErr %v", err, c.wantErr) + } + }) + } +} + +func TestHasPlan(t *testing.T) { + when := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + if (Task{ID: 1}).HasPlan() { + t.Error("a task with no PlanAt has no plan") + } + if !(Task{ID: 1, PlanAt: &when}).HasPlan() { + t.Error("a task with PlanAt has a plan") + } +} + // Validate rejects control characters on the way in, so a title only carries // them if it predates that check or was written by something other than ike. // DisplayTitle is the matching guarantee that such a title still cannot diff --git a/internal/tui/agent.go b/internal/tui/agent.go new file mode 100644 index 0000000..eae4483 --- /dev/null +++ b/internal/tui/agent.go @@ -0,0 +1,623 @@ +package tui + +import ( + "context" + "fmt" + "os" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/jonascript/ike/internal/agent" + "github.com/jonascript/ike/internal/store" + "github.com/jonascript/ike/internal/task" +) + +// A run streams into the TUI through the ordinary Bubble Tea channel pattern: a +// command blocks on one event, wraps it as a message, and is re-issued from +// Update. Nothing here touches the model from a goroutine. +// +// The run keeps going while you look at something else. `esc` leaves the run +// view without stopping the agent, because a run takes minutes and being +// trapped watching it would make delegation useless for anything but the task +// in front of you. The matrix marks the running task; `D` on it comes back. + +// agentEventMsg carries one event from a live run. +type agentEventMsg struct { + seq int + event agent.Event +} + +// agentDoneMsg says the run's event stream has closed. +type agentDoneMsg struct{ seq int } + +// agentStartedMsg hands the model a run that Start has just produced. Start +// blocks on exec, so it happens in a command rather than in Update. +type agentStartedMsg struct { + seq int + run *agent.Run + spec agent.Spec + task task.Task + err error +} + +// planSavedMsg carries the outcome of storing a drafted plan. +type planSavedMsg struct { + data store.Data + task task.Task + err error +} + +// run is the state of the one run the TUI allows at a time. +// +// One at a time on purpose: several would need per-task run state, a way to +// choose between them, and a rule for what the matrix shows when two are going +// at once. A person watching a delegated task is watching one thing. +type run struct { + // seq identifies this run, so events from a canceled one that are already + // in flight are ignored rather than appearing in its successor's + // transcript. + seq int + handle *agent.Run + taskID int + title string + mode agent.Mode + lines []string + cursor int + follow bool // whether the view is pinned to the newest line + done bool + failed bool + plan string // the drafted plan, for a ModePlan run + cost float64 + model string + effort string // the level this run settled on, and why, for the header + why string + started bool +} + +// startAgent launches a run for the selected task. +func (m *Model) startAgent(mode agent.Mode) tea.Cmd { + t, ok := m.selected() + if !ok { + return nil + } + if m.run != nil && !m.run.done { + m.status = "a run is already going; press D to watch it, or ctrl+c there to stop it" + return nil + } + if mode == agent.ModeExecute { + // The gate, read fresh rather than from m.data: consent belongs to the + // file, and `ike agent disable` in another terminal must take effect + // here without waiting for a poll. + enabled, err := m.store.AgentEnabled() + if err != nil { + m.status = err.Error() + return nil + } + if !enabled { + m.status = "delegation is off · run `ike agent enable` to allow it" + return nil + } + } + + t, ok = m.ensureDir(t) + if !ok { + return nil + } + dir := t.Dir + + plan, err := m.store.Plan(t.ID) + if err != nil { + m.status = err.Error() + return nil + } + + m.runSeq++ + seq := m.runSeq + // The TUI has no --effort of its own, so this is always the recommendation. + // Resolved here rather than read back off the spec so the header can say why + // as well as what: the reason is not part of a command line. + level, why := agent.ResolveEffort(mode, "", plan != "") + m.run = &run{ + seq: seq, + taskID: t.ID, + title: t.DisplayTitle(), + mode: mode, + effort: level, + why: why, + follow: true, + } + m.mode = modeRun + m.status = "" + + spec := agent.Spec{ + Mode: mode, + Title: t.Title, + Quadrant: m.data.Labels.Of(t.Quadrant), + Plan: plan, + Dir: dir, + } + captured := t + return func() tea.Msg { + r, err := agent.Start(context.Background(), spec) + return agentStartedMsg{seq: seq, run: r, spec: spec, task: captured, err: err} + } +} + +// sessionEndedMsg says an interactive session has exited and the TUI has the +// terminal back. +type sessionEndedMsg struct { + taskID int + err error +} + +// draftAdoptedMsg carries a plan an interactive session left behind. +type draftAdoptedMsg struct { + data store.Data + task task.Task + got bool + err error +} + +// startSession hands the terminal to an interactive agent on the selected task. +// +// tea.ExecProcess is what makes this work: it releases the terminal, runs the +// command with it, and restores the TUI when the command exits — so `c` feels +// like stepping out of ike and back in, with the matrix exactly as you left it. +// The command's streams are left nil deliberately, because that is the signal +// for ExecProcess to wire the real terminal to them. +func (m *Model) startSession(mode agent.Mode) tea.Cmd { + t, ok := m.selected() + if !ok { + return nil + } + if m.run != nil && !m.run.done { + // The terminal is about to belong to something else, and a streaming + // run drawing into it would corrupt both. + m.status = "a run is going; stop it with ctrl+c in the run view first" + return nil + } + if mode == agent.ModeExecute { + enabled, err := m.store.AgentEnabled() + if err != nil { + m.status = err.Error() + return nil + } + if !enabled { + m.status = "delegation is off · run `ike agent enable` to allow it" + return nil + } + } + + t, ok = m.ensureDir(t) + if !ok { + return nil + } + plan, err := m.store.Plan(t.ID) + if err != nil { + m.status = err.Error() + return nil + } + + resume := t.HasSession() + if !resume { + sid, err := agent.NewSessionID() + if err != nil { + m.status = err.Error() + return nil + } + // Stored before the agent starts, so a session that ends badly is + // still reachable next time. + updated, d, err := m.store.SetSession(t.ID, sid) + if !m.apply(d, err) { + return nil + } + t = updated + } + + draft, err := m.store.PlanDraftPath(t.ID) + if err != nil { + m.status = err.Error() + return nil + } + + c, err := agent.InteractiveCommand(context.Background(), agent.Session{ + Mode: mode, + Title: t.Title, + Quadrant: m.data.Labels.Of(t.Quadrant), + Plan: plan, + Dir: t.Dir, + SessionID: t.SessionID, + Resume: resume, + DraftPath: draft, + }) + if err != nil { + m.status = err.Error() + return nil + } + + m.mode = modeNormal + m.status = "" + id := t.ID + return tea.ExecProcess(c, func(err error) tea.Msg { + return sessionEndedMsg{taskID: id, err: err} + }) +} + +// ensureDir resolves and stores a task's working directory if it has none. +func (m *Model) ensureDir(t task.Task) (task.Task, bool) { + if t.Dir != "" { + return t, true + } + cwd, err := os.Getwd() + if err != nil { + m.status = err.Error() + return t, false + } + // Remembered, so the task stays attached to the project rather than to + // wherever this particular ike was started. + updated, d, err := m.store.SetDir(t.ID, "the current directory", cwd) + if !m.apply(d, err) { + return t, false + } + return updated, true +} + +// waitForEvent reads one event and re-issues itself, which is how a channel is +// consumed in Bubble Tea without the model being touched off the update loop. +func waitForEvent(seq int, r *agent.Run) tea.Cmd { + return func() tea.Msg { + e, ok := <-r.Events() + if !ok { + return agentDoneMsg{seq: seq} + } + return agentEventMsg{seq: seq, event: e} + } +} + +// handleAgentMsg folds a run message into the model. +func (m Model) handleAgentMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case agentStartedMsg: + if m.run == nil || m.run.seq != msg.seq { + // A run canceled before it started. Reap it rather than leaving a + // process nobody is reading. + if msg.run != nil { + msg.run.Cancel() + } + return m, nil, true + } + if msg.err != nil { + m.run.done, m.run.failed = true, true + m.run.lines = append(m.run.lines, wrapNote(msg.err.Error())) + m.pinCursor() + return m, nil, true + } + m.run.handle = msg.run + m.run.started = true + return m, waitForEvent(msg.seq, msg.run), true + + case agentEventMsg: + if m.run == nil || m.run.seq != msg.seq { + return m, nil, true + } + m.absorb(msg.event) + m.pinCursor() + return m, waitForEvent(msg.seq, m.run.handle), true + + case agentDoneMsg: + if m.run == nil || m.run.seq != msg.seq { + return m, nil, true + } + m.run.done = true + if m.run.mode == agent.ModePlan && !m.run.failed { + return m, m.savePlanCmd(), true + } + m.pinCursor() + return m, nil, true + + case sessionEndedMsg: + // Back from the conversation. The store may have moved a long way while + // the terminal was elsewhere — the agent can have run ike itself — so + // re-read rather than trusting what was on screen before. + if d, err := m.store.Load(); err == nil { + m.followCurrentSpace(&d) + m.refresh(d) + } + if msg.err != nil { + // Quitting out of an agent is the ordinary ending and is not + // reliably distinguishable from a real failure by exit status, so + // this is reported without being treated as one. + m.status = "session ended: " + task.SanitizeDisplay(msg.err.Error()) + } + id := msg.taskID + s := m.store + return m, func() tea.Msg { + t, d, got, err := s.AdoptPlanDraft(id) + return draftAdoptedMsg{data: d, task: t, got: got, err: err} + }, true + + case draftAdoptedMsg: + if msg.err != nil { + m.status = msg.err.Error() + return m, nil, true + } + if !msg.got { + return m, nil, true + } + m.refresh(msg.data) + m.status = "attached the plan you agreed on to " + msg.task.DisplayTitle() + return m, nil, true + + case planSavedMsg: + if msg.err != nil { + m.status = msg.err.Error() + if m.run != nil { + m.run.lines = append(m.run.lines, wrapNote(msg.err.Error())) + m.pinCursor() + } + return m, nil, true + } + m.refresh(msg.data) + if m.run != nil { + m.run.lines = append(m.run.lines, "", " · plan attached to task "+fmt.Sprint(msg.task.ID)) + m.pinCursor() + } + m.status = "plan attached to " + msg.task.DisplayTitle() + return m, nil, true + } + return m, nil, false +} + +// savePlanCmd stores the plan a planning run produced. +func (m *Model) savePlanCmd() tea.Cmd { + body := strings.TrimSpace(m.run.plan) + id := m.run.taskID + s := m.store + if body == "" { + m.run.failed = true + m.run.lines = append(m.run.lines, wrapNote("the agent finished without producing a plan")) + m.pinCursor() + return nil + } + return func() tea.Msg { + t, d, err := s.SetPlan(id, body) + return planSavedMsg{data: d, task: t, err: err} + } +} + +// absorb turns one event into transcript lines. +// +// Every string here has already been through task.SanitizeBlock, in +// internal/agent, at the single point everything leaves that package — so no +// call site has to remember to, and the CLI and the TUI cannot disagree about +// whether it happened. +func (m *Model) absorb(e agent.Event) { + r := m.run + switch e.Kind { + case agent.KindStarted: + r.model = e.Model + if e.Model != "" { + r.lines = append(r.lines, " · "+e.Model) + } + case agent.KindThinking: + r.lines = append(r.lines, " · "+firstLine(e.Text)) + case agent.KindTool: + r.lines = append(r.lines, " → "+e.Tool) + case agent.KindText: + r.lines = append(r.lines, splitLines(e.Text)...) + case agent.KindResult: + r.cost = e.CostUSD + r.failed = e.IsError + if r.mode == agent.ModePlan { + // The plan is the run's result rather than everything it said, so + // it is kept whole here instead of being reassembled from the + // transcript. + r.plan = e.Text + } + if e.IsError { + r.lines = append(r.lines, "", wrapNote("the run did not succeed: "+firstLine(e.Text))) + } + case agent.KindError: + r.failed = true + r.lines = append(r.lines, "", wrapNote(e.Text)) + } +} + +// pinCursor keeps the view on the newest line while it is following. +func (m *Model) pinCursor() { + if m.run != nil && m.run.follow { + m.run.cursor = max(len(m.run.lines)-1, 0) + } +} + +// handleRunKey handles keys in the run view. +func (m Model) handleRunKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + r := m.run + switch { + case key.Matches(msg, keys.Cancel), key.Matches(msg, keys.Agent): + // Detach. The run keeps going and keeps accumulating, because a run + // takes minutes and there is nothing to watch for most of them. + m.mode = modeNormal + if r != nil && !r.done { + m.status = fmt.Sprintf("still running: %s · D to watch it again", r.title) + } + return m, nil + + case key.Matches(msg, keys.Quit): + // ctrl+c and q both reach here. In the run view they mean "stop this", + // not "quit ike" — a run is the one thing on screen that a stray q + // should not throw away silently. + if r != nil && !r.done { + r.stop() + m.status = "run canceled" + return m, nil + } + m.mode = modeNormal + return m, nil + } + + if r != nil && moveCursor(&r.cursor, len(r.lines), msg) { + // Scrolling back detaches from the tail; returning to the last line + // re-follows, so a glance at earlier output does not permanently stop + // the view from advancing. + r.follow = r.cursor >= len(r.lines)-1 + return m, nil + } + return m, nil +} + +// stop cancels a run, if it has actually started. A run whose Start has not +// returned yet is caught by the seq check in handleAgentMsg. +func (r *run) stop() { + r.done = true + if r.handle != nil { + r.handle.Cancel() + } +} + +// runView renders the transcript through the shared full-screen list, so the +// scroll-window arithmetic has one implementation — the reason listView exists. +// A cursor pinned to the last row is exactly "follow the tail", and moveCursor +// gives scrollback for nothing. +func (m Model) renderRun() string { + r := m.run + if r == nil { + return "" + } + verb := "delegating" + if r.mode == agent.ModePlan { + verb = "planning" + } + + state := "running…" + switch { + case r.failed: + state = "failed" + case r.done && r.cost > 0: + state = fmt.Sprintf("done ($%.2f)", r.cost) + case r.done: + state = "done" + } + + // renderList derives the chrome it has to pay for from this header, so + // adding the effort line here does not cost the transcript a row off the + // bottom of the terminal. + dim := lipgloss.NewStyle().Foreground(m.dimColor()) + header := []string{ + dim.Render(fmt.Sprintf("%s · %s", state, r.title)), + dim.Render(effortNote(r.effort, r.why)), + "", + } + + // Every row starts with a two-space gutter, which is what renderList + // replaces with the cursor mark. + rows := make([]string, len(r.lines)) + for i, l := range r.lines { + rows[i] = " " + l + } + + hint := "esc detach · ctrl+c stop · j/k scroll" + if r.done { + hint = "esc/q back · j/k scroll" + } + return m.renderList(listView{ + title: strings.ToUpper(verb[:1]) + verb[1:] + " task " + fmt.Sprint(r.taskID), + header: header, + rows: rows, + empty: "waiting for the agent…", + cursor: r.cursor, + hint: hint, + }) +} + +// renderPlan shows the plan attached to the selected task. +func (m Model) renderPlan() string { + rows := make([]string, len(m.planLines)) + for i, l := range m.planLines { + rows[i] = " " + l + } + return m.renderList(listView{ + title: "Plan for task " + fmt.Sprint(m.planTask), + rows: rows, + empty: "no plan attached", + cursor: m.planCursor, + hint: "P draft with an agent · c talk it through · D delegate · j/k scroll · esc/q back", + }) +} + +// handlePlanKey handles keys in the plan view. +func (m Model) handlePlanKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Cancel), key.Matches(msg, keys.Quit), key.Matches(msg, keys.Plan): + m.mode = modeNormal + return m, nil + + case key.Matches(msg, keys.DraftPlan): + m.mode = modeNormal + return m, m.startAgent(agent.ModePlan) + + case key.Matches(msg, keys.Chat): + m.mode = modeNormal + return m, m.startSession(agent.ModePlan) + + case key.Matches(msg, keys.Agent): + m.mode = modeNormal + return m, m.startAgent(agent.ModeExecute) + } + moveCursor(&m.planCursor, len(m.planLines), msg) + return m, nil +} + +// openPlan reads the selected task's plan into the plan view. +func (m *Model) openPlan() { + t, ok := m.selected() + if !ok { + return + } + body, err := m.store.Plan(t.ID) + if err != nil { + m.status = err.Error() + return + } + m.planTask = t.ID + m.planCursor = 0 + m.planLines = nil + if body != "" { + m.planLines = splitLines(body) + } + m.mode = modePlan + m.status = "" +} + +// splitLines breaks a block into transcript rows, dropping a trailing empty +// line so a body ending in a newline does not add a blank row. +func splitLines(s string) []string { + return strings.Split(strings.TrimRight(s, "\n"), "\n") +} + +// firstLine keeps a long block to one row. Thinking and failure summaries are +// shown as a sign of what is happening rather than as something to read in full. +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + const maxRunes = 120 + if len([]rune(s)) > maxRunes { + s = string([]rune(s)[:maxRunes]) + "…" + } + return s +} + +func wrapNote(s string) string { return " ! " + firstLine(s) } + +// effortNote says what the run settled on and why, matching the line the CLI +// prints so the two frontends describe a run the same way. +func effortNote(level, why string) string { + if why == "" { + return "effort " + level + } + return "effort " + level + " — " + why +} diff --git a/internal/tui/agent_test.go b/internal/tui/agent_test.go new file mode 100644 index 0000000..3c20c13 --- /dev/null +++ b/internal/tui/agent_test.go @@ -0,0 +1,737 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/jonascript/ike/internal/agent" + "github.com/jonascript/ike/internal/store" + "github.com/jonascript/ike/internal/task" +) + +// These tests never start a process. Everything below the key handling is +// internal/agent's job and is tested there; what matters here is that events +// fold into the model correctly and that the two views behave — which is +// exactly what feeding synthetic messages through Update checks. + +// send pushes a non-key message through Update, the way a tea.Cmd's result +// would arrive. +func send(t *testing.T, m Model, msg tea.Msg) Model { + t.Helper() + next, _ := m.Update(msg) + return next.(Model) +} + +// running puts the model into a live run on task id, without a process. +func running(m Model, id int, mode agent.Mode) Model { + m.runSeq++ + m.run = &run{seq: m.runSeq, taskID: id, title: "a task", mode: mode, follow: true} + m.mode = modeRun + return m +} + +func event(seq int, e agent.Event) agentEventMsg { return agentEventMsg{seq: seq, event: e} } + +// Delegating without permission must refuse, and say how to grant it. +func TestDelegateRefusedWhenGateIsOff(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + + m = press(t, m, "D") + + if m.mode != modeNormal { + t.Errorf("mode = %v, want to have stayed on the matrix", m.mode) + } + if m.run != nil { + t.Error("a run was started with the gate off") + } + if !strings.Contains(m.status, "ike agent enable") { + t.Errorf("status = %q, it must name the command to run", m.status) + } +} + +// Drafting a plan is read-only, so it is deliberately not gated. It should get +// as far as trying to start — which fails here only because there is no binary. +func TestDraftingAPlanIsNotGated(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + t.Setenv("IKE_AGENT_CMD", "") + t.Setenv("PATH", t.TempDir()) + + m = press(t, m, "P") + + if m.run == nil { + t.Fatal("P should have begun a planning run without needing the gate") + } + if m.mode != modeRun { + t.Errorf("mode = %v, want modeRun", m.mode) + } +} + +func TestTranscriptAccumulates(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m = running(m, 1, agent.ModeExecute) + seq := m.run.seq + + m = send(t, m, event(seq, agent.Event{Kind: agent.KindStarted, Model: "claude-opus-5"})) + m = send(t, m, event(seq, agent.Event{Kind: agent.KindText, Text: "Working on it."})) + m = send(t, m, event(seq, agent.Event{Kind: agent.KindTool, Tool: "Read"})) + m = send(t, m, event(seq, agent.Event{Kind: agent.KindResult, Text: "done", CostUSD: 0.05})) + m = send(t, m, agentDoneMsg{seq: seq}) + + out := m.render() + for _, want := range []string{"claude-opus-5", "Working on it.", "Read", "$0.05"} { + if !strings.Contains(out, want) { + t.Errorf("the run view does not show %q:\n%s", want, out) + } + } + if !m.run.done { + t.Error("the run should be marked done") + } +} + +// A text block spanning several lines becomes several rows, or a long answer +// would be one unreadable line clipped at the terminal width. +func TestMultiLineTextBecomesSeveralRows(t *testing.T) { + m, _ := testModel(t) + m = running(m, 1, agent.ModeExecute) + before := len(m.run.lines) + + m = send(t, m, event(m.run.seq, agent.Event{Kind: agent.KindText, Text: "one\ntwo\nthree"})) + + if got := len(m.run.lines) - before; got != 3 { + t.Errorf("added %d rows, want 3", got) + } +} + +// esc leaves the run view without stopping the agent — a run takes minutes and +// being trapped watching it would make delegation useless. +func TestEscapeDetachesWithoutStopping(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m = running(m, 1, agent.ModeExecute) + seq := m.run.seq + + m = press(t, m, "esc") + + if m.mode != modeNormal { + t.Errorf("mode = %v, want to be back on the matrix", m.mode) + } + if m.run == nil || m.run.done { + t.Fatal("esc must not stop the run") + } + if !strings.Contains(m.status, "still running") { + t.Errorf("status = %q, it should say the run is still going", m.status) + } + + // The matrix marks the running task. + if !strings.Contains(m.render(), strings.TrimSpace(runMark)) { + t.Error("the matrix should mark the task that is still running") + } + + // Events keep arriving while detached, and the transcript keeps growing. + m = send(t, m, event(seq, agent.Event{Kind: agent.KindText, Text: "still working"})) + if len(m.run.lines) == 0 { + t.Error("a detached run should keep accumulating") + } + + // D reattaches rather than starting a second run. + m = press(t, m, "D") + if m.mode != modeRun { + t.Errorf("mode = %v, want D to reattach", m.mode) + } + if m.run.seq != seq { + t.Error("D started a new run instead of reattaching") + } + if !strings.Contains(m.render(), "still working") { + t.Error("reattaching should show what arrived while detached") + } +} + +// ctrl+c in the run view stops the run rather than quitting ike — a run is the +// one thing on screen a stray keystroke should not throw ike away over. +func TestCtrlCStopsTheRunRatherThanQuitting(t *testing.T) { + m, _ := testModel(t) + m = running(m, 1, agent.ModeExecute) + + next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + m = next.(Model) + + if cmd != nil { + t.Error("ctrl+c in a run must not quit ike") + } + if !m.run.done { + t.Error("ctrl+c should stop the run") + } + if m.status != "run canceled" { + t.Errorf("status = %q", m.status) + } +} + +// Events already in flight when a run is canceled must not appear in its +// successor's transcript. +func TestLateEventsFromAnOldRunAreIgnored(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m = running(m, 1, agent.ModeExecute) + old := m.run.seq + + // A second run replaces the first. + m = running(m, 1, agent.ModeExecute) + fresh := m.run.seq + if old == fresh { + t.Fatal("the two runs should have different sequence numbers") + } + + m = send(t, m, event(old, agent.Event{Kind: agent.KindText, Text: "from the old run"})) + if len(m.run.lines) != 0 { + t.Errorf("the old run's event leaked into the new transcript: %v", m.run.lines) + } + + m = send(t, m, agentDoneMsg{seq: old}) + if m.run.done { + t.Error("the old run's completion ended the new one") + } +} + +// A planning run stores its result, which is the plan. +func TestPlanningRunAttachesThePlan(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m = running(m, 1, agent.ModePlan) + seq := m.run.seq + + const plan = "## Goal\n\nShip it.\n\n- step one" + m = send(t, m, event(seq, agent.Event{Kind: agent.KindResult, Text: plan})) + next, cmd := m.Update(agentDoneMsg{seq: seq}) + m = next.(Model) + if cmd == nil { + t.Fatal("finishing a planning run should save the plan") + } + m = send(t, m, cmd()) + + body, err := s.Plan(1) + if err != nil { + t.Fatal(err) + } + if body != plan { + t.Errorf("stored plan = %q, want %q", body, plan) + } + // The run view stays up when the run finishes — the transcript and the + // "plan attached" note are the point of it. esc goes back to the matrix, + // which now marks the task as planned. + if !strings.Contains(m.render(), "plan attached") { + t.Error("the run view should note that the plan was attached") + } + m = press(t, m, "esc") + if !strings.Contains(m.render(), strings.TrimSpace(planMark)) { + t.Error("a planned task should be marked in the matrix") + } +} + +// A planning run that produces nothing must not attach an empty plan. +func TestPlanningRunWithNoResultAttachesNothing(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m = running(m, 1, agent.ModePlan) + seq := m.run.seq + + m = send(t, m, event(seq, agent.Event{Kind: agent.KindResult, Text: " "})) + m = send(t, m, agentDoneMsg{seq: seq}) + + if body, _ := s.Plan(1); body != "" { + t.Errorf("an empty plan was attached: %q", body) + } + if !m.run.failed { + t.Error("the run should be marked failed") + } +} + +// A failed run is not silently indistinguishable from a successful one. +func TestFailedRunIsMarked(t *testing.T) { + m, _ := testModel(t) + m = running(m, 1, agent.ModeExecute) + seq := m.run.seq + + m = send(t, m, event(seq, agent.Event{Kind: agent.KindResult, Text: "could not do it", IsError: true})) + m = send(t, m, agentDoneMsg{seq: seq}) + + if !m.run.failed { + t.Error("the run should be marked failed") + } + if out := m.render(); !strings.Contains(out, "failed") { + t.Errorf("the run view should say it failed:\n%s", out) + } +} + +func TestPlanView(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + if _, _, err := s.SetPlan(1, "## Goal\n\nShip it."); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + + m = press(t, m, "p") + if m.mode != modePlan { + t.Fatalf("mode = %v, want modePlan", m.mode) + } + out := m.render() + if !strings.Contains(out, "## Goal") || !strings.Contains(out, "Ship it.") { + t.Errorf("the plan view does not show the plan:\n%s", out) + } + + m = press(t, m, "esc") + if m.mode != modeNormal { + t.Errorf("mode = %v, want to be back on the matrix", m.mode) + } +} + +func TestPlanViewWithNoPlan(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + + m = press(t, m, "p") + if out := m.render(); !strings.Contains(out, "no plan attached") { + t.Errorf("the plan view should say there is none:\n%s", out) + } +} + +// Scrolling back stops the view chasing the tail, and returning to the last +// line resumes it — so a glance at earlier output is not permanent. +func TestScrollbackDetachesAndReattachesTheTail(t *testing.T) { + m, _ := testModel(t) + m = running(m, 1, agent.ModeExecute) + seq := m.run.seq + for i := range 10 { + m = send(t, m, event(seq, agent.Event{Kind: agent.KindText, Text: string(rune('a' + i))})) + } + if !m.run.follow || m.run.cursor != len(m.run.lines)-1 { + t.Fatalf("the view should start pinned to the newest line, cursor=%d", m.run.cursor) + } + + m = press(t, m, "k") + if m.run.follow { + t.Error("scrolling back should stop the view following the tail") + } + was := m.run.cursor + m = send(t, m, event(seq, agent.Event{Kind: agent.KindText, Text: "new line"})) + if m.run.cursor != was { + t.Error("a new event moved the cursor while scrolled back") + } + + m = press(t, m, "j", "j") + if !m.run.follow { + t.Error("returning to the last line should resume following") + } +} + +// The run dies with the process, so quitting stops it deliberately rather than +// orphaning an agent with nothing reading it. +func TestQuittingStopsALiveRun(t *testing.T) { + m, _ := testModel(t) + m = running(m, 1, agent.ModeExecute) + m.mode = modeNormal + + next, cmd := m.Update(keyFor("q")) + m = next.(Model) + + if cmd == nil { + t.Error("q on the matrix should still quit") + } + if !m.run.done { + t.Error("quitting should stop a live run") + } +} + +// The TUI has no --effort of its own, so what it shows is always ike's own +// choice — which makes the header the only place a person can see it. +func TestRunViewShowsTheChosenEffort(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + // No binary, so the run fails to start — but startAgent settles the effort + // before it gets that far, which is all this is about. + t.Setenv("IKE_AGENT_CMD", "") + t.Setenv("PATH", t.TempDir()) + m.refreshFromStore(t) + + m = press(t, m, "D") + if m.run == nil { + t.Fatal("D should have begun a run") + } + if m.run.effort != "high" || m.run.why != "no plan to follow" { + t.Errorf("effort = %q (%q), want high with the no-plan reason", m.run.effort, m.run.why) + } + if out := m.render(); !strings.Contains(out, "effort high — no plan to follow") { + t.Errorf("run view = %q, want the effort line in the header", out) + } + + // Attach a plan and the next run is follow-through instead. + if _, _, err := s.SetPlan(1, "## step one"); err != nil { + t.Fatal(err) + } + m.run = nil + m.mode = modeNormal + m.refreshFromStore(t) + + m = press(t, m, "D") + if m.run == nil { + t.Fatal("D should have begun a second run") + } + if m.run.effort != "medium" || m.run.why != "following an attached plan" { + t.Errorf("effort = %q (%q), want the step down for an attached plan", m.run.effort, m.run.why) + } + if out := m.render(); !strings.Contains(out, "effort medium — following an attached plan") { + t.Errorf("run view = %q, want the effort line in the header", out) + } +} + +// A second run while one is going is refused rather than silently replacing it. +func TestASecondRunIsRefused(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + s.Add("another", task.Do) + m.refreshFromStore(t) + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + + m = running(m, 1, agent.ModeExecute) + seq := m.run.seq + m.mode = modeNormal + m.cursor[task.Do] = 1 // a different task + + m = press(t, m, "P") + if m.run.seq != seq { + t.Error("a second run replaced the live one") + } + if !strings.Contains(m.status, "already going") { + t.Errorf("status = %q, it should say a run is already going", m.status) + } +} + +// The ambient footer line tells you which state the gate is in, and how to +// change it — the delegation counterpart of the MCP line. +func TestAgentHelpLineReflectsTheGate(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m.showHelp = true + + if out := m.render(); !strings.Contains(out, "ike agent enable") { + t.Errorf("help should say how to turn delegation on:\n%s", out) + } + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + if out := m.render(); !strings.Contains(out, "ike agent disable") { + t.Errorf("help should say how to turn delegation off:\n%s", out) + } +} + +// fakeAgentEnv names the file this binary replays when standing in for the +// claude CLI. See TestMain. +const fakeAgentEnv = "IKE_TUI_FAKE_STREAM" + +// fakeAgentCLI points agent.Start at this test binary, replaying events. +func fakeAgentCLI(t *testing.T, events ...string) { + t.Helper() + p := filepath.Join(t.TempDir(), "stream.jsonl") + if err := os.WriteFile(p, []byte(strings.Join(events, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("IKE_AGENT_CMD", exe) + t.Setenv(fakeAgentEnv, p) +} + +// pump runs the Bubble Tea command loop by hand: execute a command, feed its +// message back through Update, repeat until nothing is left. +// +// The tests above inject messages directly, which checks that events fold into +// the model correctly but not that the commands actually produce them. This +// closes that gap — it exercises startAgent's exec, waitForEvent re-issuing +// itself for each event, and savePlanCmd, against a real subprocess. +func pump(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + for i := 0; cmd != nil; i++ { + if i > 200 { + t.Fatal("the command loop did not settle") + } + msg := cmd() + if msg == nil { + return m + } + var next tea.Model + next, cmd = m.Update(msg) + m = next.(Model) + } + return m +} + +// A planning run, end to end through the real command plumbing: exec the +// binary, stream its events, attach the plan it produced. +func TestPlanningRunEndToEnd(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + dir := t.TempDir() + if _, _, err := s.SetDir(1, "test", dir); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + + fakeAgentCLI(t, + `{"type":"system","subtype":"init","session_id":"abc","model":"claude-opus-5"}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"Exploring."}]}}`, + `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read"}]}}`, + `{"type":"result","subtype":"success","is_error":false,"result":"## Goal\n\nShip it.","total_cost_usd":0.03}`, + ) + + cmd := m.startAgent(agent.ModePlan) + if cmd == nil { + t.Fatal("startAgent returned no command") + } + m = pump(t, m, cmd) + + out := m.render() + for _, want := range []string{"claude-opus-5", "Exploring.", "Read", "$0.03", "plan attached"} { + if !strings.Contains(out, want) { + t.Errorf("the run view does not show %q:\n%s", want, out) + } + } + if body, _ := s.Plan(1); body != "## Goal\n\nShip it." { + t.Errorf("stored plan = %q", body) + } +} + +// A delegated run, end to end, including the gate and the attached plan +// reaching the agent's command line. +func TestDelegateRunEndToEnd(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + dir := t.TempDir() + if _, _, err := s.SetDir(1, "test", dir); err != nil { + t.Fatal(err) + } + if _, _, err := s.SetPlan(1, "## The plan"); err != nil { + t.Fatal(err) + } + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + + fakeAgentCLI(t, + `{"type":"system","subtype":"init","session_id":"abc","model":"claude-opus-5"}`, + `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write"}]}}`, + `{"type":"result","subtype":"success","is_error":false,"result":"I created the file.","total_cost_usd":0.10}`, + ) + + m = pump(t, m, m.startAgent(agent.ModeExecute)) + + if m.run == nil || !m.run.done { + t.Fatal("the run did not finish") + } + if m.run.failed { + t.Error("a successful run was marked failed") + } + out := m.render() + if !strings.Contains(out, "Write") { + t.Errorf("the run view does not show the tool use:\n%s", out) + } + if !strings.Contains(out, "$0.10") { + t.Errorf("the run view does not show the cost, so the result event never landed:\n%s", out) + } + // Delegating does not complete the task — reading what it did and deciding + // is the point. + d, _ := s.Load() + if len(d.Tasks) != 1 || len(d.Archive) != 0 { + t.Error("a delegated run should not complete the task by itself") + } +} + +// `c` hands the terminal over, and the conversation is pinned to the task so +// pressing it again resumes rather than briefing a new agent. +func TestChatPinsThenResumesTheConversation(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + dir := t.TempDir() + if _, _, err := s.SetDir(1, "test", dir); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + fakeAgentCLI(t) + + cmd := m.startSession(agent.ModePlan) + if cmd == nil { + t.Fatal("c should have started a session") + } + // Back on the matrix, because the terminal is about to belong to claude — + // there is no ike view to be in while it does. + if m.mode != modeNormal { + t.Errorf("mode = %v, want modeNormal during a handover", m.mode) + } + + d, _ := s.Load() + sid := d.Tasks[0].SessionID + if sid == "" { + t.Fatal("the session should be stored before the agent starts, or it is unresumable") + } + // A task with a conversation is marked, so you can see there is one to + // pick up. + m.refreshFromStore(t) + if !strings.Contains(m.render(), strings.TrimSpace(chatMark)) { + t.Error("a task with a conversation should be marked in the matrix") + } + + // Pressing it again resumes the same one. + m2 := m + if cmd := m2.startSession(agent.ModePlan); cmd == nil { + t.Fatal("c should work a second time") + } + d, _ = s.Load() + if d.Tasks[0].SessionID != sid { + t.Error("a second visit started a different conversation") + } +} + +// Supervising still needs permission; being at the terminal does not remove the +// decision to let ike start an agent that edits files. +func TestSupervisingStillNeedsTheGate(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + if _, _, err := s.SetDir(1, "test", t.TempDir()); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + fakeAgentCLI(t) + + if cmd := m.startSession(agent.ModeExecute); cmd != nil { + t.Error("C should be refused with the gate off") + } + if !strings.Contains(m.status, "ike agent enable") { + t.Errorf("status = %q", m.status) + } + + // Talking a plan through is read-only, so it is not gated. + if cmd := m.startSession(agent.ModePlan); cmd == nil { + t.Error("c should work with the gate off — planning is read-only") + } +} + +// A streaming run owns the screen, and a session is about to own the terminal. +// Letting both happen would corrupt the display. +func TestSessionIsRefusedWhileARunIsGoing(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + m = running(m, 1, agent.ModeExecute) + m.mode = modeNormal + + if cmd := m.startSession(agent.ModePlan); cmd != nil { + t.Error("a session should be refused while a run is streaming") + } + if !strings.Contains(m.status, "run is going") { + t.Errorf("status = %q", m.status) + } +} + +// Coming back from a session adopts whatever plan was agreed, and re-reads the +// store — the agent may have changed it while the terminal was elsewhere. +func TestReturningFromASessionAdoptsThePlan(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + + draft, err := s.PlanDraftPath(1) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(draft, []byte("## Agreed\n\nDo the thing.\n"), 0o600); err != nil { + t.Fatal(err) + } + + next, cmd := m.Update(sessionEndedMsg{taskID: 1}) + m = next.(Model) + if cmd == nil { + t.Fatal("returning should look for a plan the session left") + } + m = send(t, m, cmd()) + + if body, _ := s.Plan(1); body != "## Agreed\n\nDo the thing." { + t.Errorf("stored plan = %q", body) + } + if !strings.Contains(m.status, "attached the plan") { + t.Errorf("status = %q", m.status) + } + if !strings.Contains(m.render(), strings.TrimSpace(planMark)) { + t.Error("the matrix should mark the task as planned") + } +} + +// Most conversations do not end in a plan, and that is not an error. +func TestReturningWithNoPlanIsQuiet(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + m.refreshFromStore(t) + + next, cmd := m.Update(sessionEndedMsg{taskID: 1}) + m = next.(Model) + m = send(t, m, cmd()) + + if strings.Contains(m.status, "attached") { + t.Errorf("status = %q, nothing was agreed", m.status) + } + if body, _ := s.Plan(1); body != "" { + t.Errorf("a plan appeared from nowhere: %q", body) + } +} + +// The gate is read fresh when a run starts, not from the polled Data, so +// revoking it in another terminal takes effect without waiting for a tick. +func TestGateIsReadFreshWhenStartingARun(t *testing.T) { + m, s := testModel(t) + s.Add("ship v2", task.Do) + if _, err := s.SetAgentEnabled(true); err != nil { + t.Fatal(err) + } + m.refreshFromStore(t) + + // Revoked behind the model's back — no refresh, so m.data still says on. + if _, err := store.OpenAt(s.Path()).SetAgentEnabled(false); err != nil { + t.Fatal(err) + } + if !m.data.AgentAllowed { + t.Fatal("the stale Data should still report delegation on, or this proves nothing") + } + + m = press(t, m, "D") + if m.run != nil { + t.Error("the run started against a gate that had just been revoked") + } + if !strings.Contains(m.status, "ike agent enable") { + t.Errorf("status = %q", m.status) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 546f6e1..5991e65 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -37,6 +37,18 @@ type keyMap struct { // File keys. OpenFile is live only inside the file picker. Files key.Binding OpenFile key.Binding + + // Delegation keys. Plan opens the attached plan, DraftPlan asks an agent + // for one, and Agent hands the task over — or reattaches to a live run. + // They are capitals so the lowercase letters stay free, and so neither sits + // next to `d` for delete. + Plan key.Binding + DraftPlan key.Binding + Agent key.Binding + // Chat hands the terminal to a real Claude Code session on the selected + // task, and Supervise does the same for carrying the work out. + Chat key.Binding + Supervise key.Binding } var keys = keyMap{ @@ -70,4 +82,10 @@ var keys = keyMap{ Files: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "files")), OpenFile: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open a path")), + + Plan: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "plan")), + DraftPlan: key.NewBinding(key.WithKeys("P"), key.WithHelp("P", "draft a plan")), + Agent: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "delegate")), + Chat: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "chat about the plan")), + Supervise: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "work on it together")), } diff --git a/internal/tui/main_test.go b/internal/tui/main_test.go index ce3641e..02e92fc 100644 --- a/internal/tui/main_test.go +++ b/internal/tui/main_test.go @@ -13,7 +13,19 @@ import ( // ~/.local/state/ike/recent.json — and their file picker would fill up with // temp directories that no longer exist. Set here rather than in each helper // because it has to cover any test that calls New, including ones added later. +// It also lets this binary stand in for the claude CLI, so an agent run can be +// driven end to end without a network, a cost, or a shell script to keep +// executable. See fakeAgentCLI in agent_test.go. func TestMain(m *testing.M) { + if f := os.Getenv(fakeAgentEnv); f != "" { + b, err := os.ReadFile(f) + if err != nil { + panic(err) + } + os.Stdout.Write(b) + os.Exit(0) + } + dir, err := os.MkdirTemp("", "ike-tui-state") if err != nil { panic(err) diff --git a/internal/tui/model.go b/internal/tui/model.go index 605f52c..860ea1b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "github.com/jonascript/ike/internal/agent" "github.com/jonascript/ike/internal/store" "github.com/jonascript/ike/internal/task" ) @@ -23,6 +24,8 @@ const ( modeArchive modeSpaces modeFiles + modeRun + modePlan ) // inputPurpose says what the shared text input is currently collecting. @@ -75,6 +78,15 @@ type Model struct { recent []string // data files opened before, most recent first showHelp bool + // run is the one agent run the TUI allows at a time, or nil. It outlives + // the run view: detaching with esc leaves it here still accumulating. + run *run + runSeq int // identifies a run, so a canceled one's late events are ignored + + planTask int + planCursor int + planLines []string + status string loadErr string @@ -254,6 +266,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: return m.handleKey(msg) } + // Agent messages arrive whether or not the run view is on screen, since a + // detached run keeps streaming into the model. + if next, cmd, handled := m.handleAgentMsg(msg); handled { + return next, cmd + } return m, nil } @@ -269,6 +286,10 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleSpacesKey(msg) case modeFiles: return m.handleFilesKey(msg) + case modeRun: + return m.handleRunKey(msg) + case modePlan: + return m.handlePlanKey(msg) } return m.handleNormalKey(msg) } @@ -311,6 +332,12 @@ func (m Model) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Quit): + // A live run dies with the process, so it is stopped deliberately here + // rather than being orphaned by the exit. The agent has its own process + // group and would otherwise outlive ike with nothing reading it. + if m.run != nil { + m.run.stop() + } return m, tea.Quit case key.Matches(msg, keys.Quadrant): @@ -401,6 +428,29 @@ func (m Model) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.PrevSpace): m.cycleSpace(-1) + case key.Matches(msg, keys.Chat): + return m, m.startSession(agent.ModePlan) + + case key.Matches(msg, keys.Supervise): + return m, m.startSession(agent.ModeExecute) + + case key.Matches(msg, keys.Plan): + m.openPlan() + + case key.Matches(msg, keys.DraftPlan): + return m, m.startAgent(agent.ModePlan) + + case key.Matches(msg, keys.Agent): + // Reattach if a run is already going, rather than refusing: D is the + // key you press when you want to know what the agent is doing, and + // having it do nothing while a run is live would be the wrong answer. + if m.run != nil && !m.run.done { + m.mode = modeRun + m.status = "" + return m, nil + } + return m, m.startAgent(agent.ModeExecute) + case key.Matches(msg, keys.Help): m.showHelp = !m.showHelp } diff --git a/internal/tui/spaces_test.go b/internal/tui/spaces_test.go index 7db5b9a..9228d59 100644 --- a/internal/tui/spaces_test.go +++ b/internal/tui/spaces_test.go @@ -542,10 +542,21 @@ func TestListViewsFitTheTerminal(t *testing.T) { m.recent = store.LoadRecent().Paths m.refreshFromStore(t) + // The run and plan views are the fourth and fifth lists, and go through the + // same renderList — which is the point: a new list must not grow another + // copy of the scroll-window arithmetic. + m.run = &run{taskID: 1, title: "a task", lines: make([]string, 200)} + for i := range m.run.lines { + m.run.lines[i] = fmt.Sprintf("transcript line %d", i) + } + m.run.cursor = len(m.run.lines) - 1 + m.planTask, m.planLines = 1, m.run.lines + for _, mode := range []struct { name string m mode - }{{"archive", modeArchive}, {"spaces", modeSpaces}, {"files", modeFiles}} { + }{{"archive", modeArchive}, {"spaces", modeSpaces}, {"files", modeFiles}, + {"run", modeRun}, {"plan", modePlan}} { m.mode = mode.m for _, h := range []int{minHeight, 13, 16, 24, 40} { m.width, m.height = 100, h diff --git a/internal/tui/view.go b/internal/tui/view.go index a61b0f8..e39389a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -83,6 +83,12 @@ func (m Model) render() string { if m.mode == modeFiles { return m.renderFiles() } + if m.mode == modeRun { + return m.renderRun() + } + if m.mode == modePlan { + return m.renderPlan() + } footer := m.renderFooter() footerH := lipgloss.Height(footer) @@ -264,7 +270,10 @@ func (m Model) renderTaskLines(q task.Quadrant, w, visible int, focused bool) [] for i := offset; i < len(tasks) && i-offset < visible; i++ { t := tasks[i] marker := " " - title := ansi.Truncate(t.DisplayTitle(), max(w-8, 4), "…") + // The delegation mark sits after the title so it cannot push the ID + // column around, and is one cell wide so a row's width is unchanged. + mark := m.taskMark(t) + title := ansi.Truncate(t.DisplayTitle(), max(w-8-len([]rune(mark)), 4), "…") + mark line := fmt.Sprintf("%s%s %s", marker, idStyle.Render(fmt.Sprintf("%3d", t.ID)), title) if focused && i == m.cursor[q] { line = selStyle.Render(fmt.Sprintf("▸ %3d %s", t.ID, title)) @@ -277,6 +286,34 @@ func (m Model) renderTaskLines(q task.Quadrant, w, visible int, focused bool) [] return lines } +// Delegation marks, in the order taskMark prefers them: a run in progress is +// the most immediate fact, then a plan you can act on, then a conversation you +// could pick back up. +const ( + planMark = " ✎" + runMark = " ⣾" + chatMark = " ⌁" +) + +// taskMark is the delegation marker for a row, or "" for a task with none. +// +// One mark, not three: a row is a task title, and a column of symbols would +// cost more width than it explains. Rendered from the stamps on the task rather +// than by looking for the files behind them, which is the whole reason those +// are persisted — the matrix redraws on every keypress, and a stat per row per +// frame would be a poor trade for a symbol. +func (m Model) taskMark(t task.Task) string { + switch { + case m.run != nil && !m.run.done && m.run.taskID == t.ID: + return runMark + case t.HasPlan(): + return planMark + case t.HasSession(): + return chatMark + } + return "" +} + func (m Model) renderFooter() string { dim := lipgloss.NewStyle().Foreground(m.dimColor()) @@ -313,7 +350,7 @@ func (m Model) renderFooter() string { undoHelp = "u undo · U redo" } help := "a add · e edit · x done · m move · J/K reorder · d delete · " + - undoHelp + " · v archive · s spaces · ? help · q quit" + undoHelp + " · v archive · s spaces · p plan · c chat · D delegate · ? help · q quit" if m.showHelp { help = strings.Join([]string{ "1-4 focus quadrant · tab/shift+tab cycle · j/k or ↑/↓ select task", @@ -326,6 +363,12 @@ func (m Model) renderFooter() string { "each space keeps its own tasks, headings, and history", "space changes are not undoable, unlike changes to tasks", "f data files: switch to another matrix file, or o to type a path", + "p show the attached plan (" + strings.TrimSpace(planMark) + " marks a task that has one) · P draft one with an agent", + "c chat: hands the terminal to Claude Code to talk the plan through · C to work on it together", + "a task keeps its conversation (" + strings.TrimSpace(chatMark) + "), so c picks up where you left off rather than starting over", + "D delegate: run an agent on the task, or reattach to a run already going", + "in a run: esc detaches and it keeps going · ctrl+c stops it · quitting ike stops it", + m.agentHelpLine(), m.mcpHelpLine(), }, "\n") } @@ -342,6 +385,15 @@ func (m Model) renderFooter() string { return strings.Join(append([]string{statusLine}, helpLines...), "\n") } +// agentHelpLine states whether ike may run an agent, and the command that +// changes it — the delegation counterpart of mcpHelpLine. +func (m Model) agentHelpLine() string { + if m.data.AgentAllowed { + return "delegation is on: D runs an agent on a task · turn off with: ike agent disable" + } + return "delegation is off · turn on with: ike agent enable (p and P still work; planning is read-only)" +} + // mcpHelpLine states the current MCP access setting and the command that // changes it. It names the marker so the footer symbol is self-explaining. func (m Model) mcpHelpLine() string {