Harvey /loop —
Implementation Plan
See loop-design.md for the full design rationale.
New Module Dependencies
None. Implemented with existing Harvey packages, time,
and the standard library signal-handling pattern already used three
times in terminal.go.
Files to Create
| File | Purpose |
|---|---|
harvey/loop.go |
cmdLoop, argument parsing, the iteration loop,
sleep-with-cancellation |
harvey/loop_test.go |
Unit tests for parsing and the iteration loop (with
mockLLMClient) |
Files to Modify
| File | Change |
|---|---|
harvey/terminal.go |
Factor the inline chat block (~lines 635-820) into
(a *Agent) runChatTurn(ctx, input, out) (reply string, stats ChatStats, err error);
REPL loop becomes a thin wrapper around it |
harvey/commands.go |
Register "loop" in registerCommands(); add
a "loop" case to cmdHelp’s topic switch and
the two topic-list strings (commands.go:566 and 596-600) |
harvey/helptext.go |
Add LoopHelpText constant |
Implementation Phases
Phase 1 —
Extract runChatTurn from the REPL chat block
Pull the body of the REPL’s plain-chat branch (RAG augmentation
through RecordTurnWithStats, roughly
terminal.go:681-830) into:
func (a *Agent) runChatTurn(ctx context.Context, input string, out io.Writer) (reply string, stats ChatStats, err error)- Takes an already-built, cancellable
ctx. Each call site (REPL,/loop) keeps owning its own SIGINT setup and “Cancelled.”/history-rollback framing —runChatTurnjust makes the model call and returns. - Returns the assembled reply text and stats.
- The REPL’s plain-chat branch becomes a thin wrapper: build
ctx/signal watcher →
runChatTurn→ existing display/skill-trigger/autoExecuteReplylogic, unchanged. - This phase is a pure refactor. Run
go test -racein isolation afterward — no existing test’s behaviour should change.
Phase 2 — Argument Parsing
func parseLoopArgs(args []string) (interval time.Duration, count int, rest string, err error)args[0]→parseDurationString(config.go:650); error if parse fails or result ≤ 0- Optional
--count N: ifargs[1] == "--count", parseargs[2]as an integer in[1, 100]; default 10 when the flag is absent - Remaining tokens joined with spaces →
rest; error if empty - No workspace path validation here —
restis either free text or a command line thata.dispatchvalidates itself
Phase 3 — Iteration Dispatch
func runLoopIteration(ctx context.Context, a *Agent, rest string, out io.Writer) (exitRequested bool, err error)- If
strings.HasPrefix(rest, "/"): extract the command name; if it isexit/quit/bye, return(true, nil)without dispatching; otherwisea.dispatch(rest, out) - Else:
a.runChatTurn(ctx, rest, out), print the reply and stat line — same shape as the REPL’s display, minus the skill-trigger/autoExecuteReplyexclusions documented in the design
Phase 4 — Sleep With Cancellation
func sleepInterruptible(ctx context.Context, d time.Duration) (cancelled bool)selectontime.NewTimer(d).Candctx.Done()- Reuses the same SIGINT-watcher-goroutine pattern as the rest of
terminal.go— the orchestrator owns one cancellable context for the whole run and passes it down, so cancellation during either the iteration or the sleep surfaces identically
Phase 5 — Orchestrator
func cmdLoop(a *Agent, args []string, out io.Writer) errorparseLoopArgs— on error, print usage and returnnil(matches other commands’ “print usage, don’t error” convention, e.g./pipelinewith no threshold)- Print plan summary:
Looping every %s, up to %d time(s): %s - Build one cancellable
context.Context+ SIGINT watcher for the whole run - Loop
i := 1..count:runLoopIteration- if it reports
exitRequested, printloop: stopping — %q would exit Harveyand break - if cancelled, break
- if
i < count,sleepInterruptible; if it reports cancellation, break
- Print summary:
Loop finished after %d/%d iteration(s)orLoop cancelled after %d/%d iteration(s)
Phase 6 — Help Text & Registration
LoopHelpTextinhelptext.go, following the existing%{app_name}(7) user manual...template (seeRunHelpTextfor a similarly single-purpose command’s guide)registerCommands():"loop": { Usage: "/loop INTERVAL [--count N] PROMPT|/COMMAND", Description: "Repeat a prompt or command on an interval, up to N times", Handler: cmdLoop, },cmdHelp: addcase "loop": fmt.Fprint(out, FmtHelp(LoopHelpText, ...))and addloopto both topic-listing strings
Phase 7 — Tests
(loop_test.go)
| Test | Covers |
|---|---|
TestParseLoopArgs_valid |
5m, 30s, 300, with and
without --count |
TestParseLoopArgs_invalid |
Bad duration, zero/negative interval, --count out of
range or non-numeric, empty prompt |
TestCmdLoop_chatMode |
N iterations of a plain prompt against mockLLMClient;
asserts a.History grows by 2×N messages |
TestCmdLoop_commandMode |
Loops a harmless built-in (/status) N times; asserts no
chat call is made |
TestCmdLoop_exitSentinel |
Looping /exit stops the loop without exiting
Harvey |
TestCmdLoop_countCap |
--count 0 and --count 101 rejected with
usage messages |
TestSleepInterruptible_cancel |
Cancelling the context returns promptly without waiting the full duration |
TestRunChatTurn_* |
Coverage for the extracted helper — add minimal tests in Phase 1 if the inline block had none |