Plan Mode Is a State Machine, Not a Prompt
theme: fancy highlight: a11y-dark
🚀 Welcome to the sixth installment of the "Building an AI Agent Without Frameworks" series.
Even if you haven't read the previous articles, you can start directly from this one. All you need to know is: we already have a minimal AI Agent that can read, modify files, and execute commands.
In this article, we will teach it to act more like an engineer: when facing a long task, investigate first, then plan, and wait for human approval before starting to write code.
A Little Story from the Office
Last Friday, a colleague leaned over:
"Can you quickly fix the login module? The old and new interfaces are a mess right now, and it'd be best to add a few tests."
"Sure, I'll let the Agent take a look first. Should be quick."
You copy the requirements to the Agent and go get a glass of water.
When you come back, it's already working:
read_file
src/login.ts
read_file
src/routes.ts
edit_file
src/login.ts
edit_file
src/...
It looks pretty smooth. Soon, the Agent replies:
"The login module has been refactored."
You're about to wrap up when your colleague glances at the changes:
"Wait, why did you change this file?"
"Isn't this the login entry point?"
"This one hasn't been used for ages. The online system uses a different layer. And that old interface can't be deleted; the client is still calling it."
You quickly scroll down, and things look worse and worse:
The real login entry point was never read
The callers of the old interface weren't fully searched
The compatibility logic was deleted as if it were old code
The test commands were never run once
You ask the Agent:
"Why didn't you fully investigate the call relationships first?"
Its answer is also quite reasonable:
"I completed the refactoring based on the files I read."
Now the problem is very clear.
Your "take a look first" meant it should thoroughly understand the project first; its understanding of "take a look first" was to read two files and then start modifying as it went.
The model isn't necessarily incapable of writing code. It's just that we gave it write_file, edit_file, and bash from the very beginning, so it naturally might start working before fully understanding the situation.
If you were handing the task to a colleague, you'd probably add:
"Don't change anything yet. List the entry points, callers, compatibility plan, potential pitfalls, and how you plan to test it. I'll review it, and you can start once I'm okay with it."
What Exactly Is Plan Mode
Plan Mode can be understood as the Agent's "propose a solution, don't build yet" phase.
Let's take Claude Code as an example first.
In a mode where modifications are allowed, Claude Code's goal is to complete the task directly:
Read project
↓
Modify files
↓
Execute commands
↓
Verify results
After switching to Plan Mode, Claude Code temporarily restricts the ability to modify source code. It first reads and searches the project, forms a plan, and then waits for user approval.
At this point, its goal is not to complete the code, but to first answer:
What really needs to be changed
Which files and call relationships are involved
In what order should it be implemented
What are the risks
How to verify it in the end
Therefore, after entering Plan Mode, the Agent should go through three stages:
Planning Phase
Read, search, and understand the project
↓
Approval Phase
Submit the plan, pause and wait for user confirmation
↓
Execution Phase
Enter Code Mode after user approval
Restore modification and command execution capabilities
So Plan Mode is not another large language model, nor is it just generating a PLAN.md.
It is first and foremost a running state within the Harness:
What phase are we currently in?
↓
Determines which tools the model can see
↓
Determines when the Agent Loop must pause
The plan content is generated by the large model, but the "cannot modify before approval" and "must wait after submission" rules should be guaranteed by the Harness.
Why the Agent Always Wants to Change Code Immediately
Let's clarify something first: the large model isn't truly "impulsive by nature."
It simply chooses an action that seems most likely to advance the task based on the current goal, context, and available tools.
When the user says:
Help me refactor the login module
And the Harness simultaneously provides:
read_file
write_file
edit_file
bash
Then from the model's perspective, both reading and modifying are legitimate actions. After reading one or two related files, it easily judges that "the information is sufficient," and the next natural step is to call edit_file.
The Agent Loop will also continue to push it forward:
User requests code modification
↓
Model sees write tools
↓
Reads a few files
↓
Calls edit_file
↓
Harness returns "Modification successful"
↓
Model continues to modify the next file
At no point in the entire loop is it required to:
First prove it has found all entry points
First list the scope of impact
First present the plan to the user
Wait for user approval before continuing
So it's not intentionally messing up; rather, in the Harness we provided, "start modifying directly" is a completely unobstructed path.
Even if we add a line to the prompt:
Please understand the situation thoroughly before modifying the code.
The model still needs to judge for itself what "understand thoroughly" means. It might read two files and consider that condition met.
So the first thing Plan Mode does is block this path at the capability level:
Plan Mode Available
├── read_file
├── list_files
├── search_files
└── submit_plan
Plan Mode Unavailable
├── write_file
├── edit_file
└── bash
Now, even if the model thinks "it can start modifying," it doesn't have the tools to modify files. All it can do is continue reading, continue searching, or call submit_plan to hand over the plan.
However, taking away the write tools only solves "cannot modify prematurely."
To truly make it form a "first understand, then confirm" workflow, two more layers are needed:
Planning Prompt
Requires exploring the project first, and clearly writing steps, risks, and verification methods in the plan
Approval State Machine
Pause the Agent Loop after submit_plan, must wait for user choice
So what this article implements is not just a tool switch, but a three-layer coordination:
Tool Restriction
Guarantees no writing before approval
Planning Prompt
Guides the model to understand the project thoroughly first
User Approval
Decides when to restore write tools
This skeleton wasn't invented out of thin air. The Plan Modes of Claude Code, Cursor, Codex, and Oh My Pi all show a similar "plan first, approve, then execute" approach, just implemented in different places:
| Product | Similarities to Our Approach | Main Differences |
|---|---|---|
| Claude Code | Plan Mode investigates the project, submits a plan, and doesn't modify source code before approval | It makes Plan a direct permission mode and offers multiple execution methods after approval |
| Cursor | Searches the codebase first, asks key questions, generates a plan, then waits for user approval | Its plan can be directly edited and saved as Markdown |
| Codex | Also emphasizes exploring the real project first, then outputting an executable, complete plan | It's more of a planning collaboration protocol; public materials don't say it internally uses a tool named submit_plan |
| Oh My Pi | Has an independent Plan role, plan artifacts, and an approval entry point | Also supports clearing, compressing, or retaining planning context after approval, much more complex than our first version |
| Original Pi | This workflow can be implemented through extensions | The core itself explicitly has no built-in Plan Mode; it cannot be said to be implemented this way by default |
Therefore, a more accurate description of our version is:
Using Claude Code-style permission boundaries and approval workflow as the skeleton, and adding Codex-style planning prompts.
Our self-defined submit_plan can be understood as an explicit "planning complete" event. Its role is very similar to "exit Plan Mode" or "submit plan" in other Agents, but it doesn't mean these products all use a tool with the same name internally.
We want the Agent to follow the same process:
First enter Plan Mode
↓
Only read and search the project
↓
Submit an implementation plan
↓
Wait for user approval
↓
Enter Code Mode after approval
↓
Modify code and update execution progress
The final terminal experience will look like this:
> /plan
Entered Plan Mode. Can only read and search the project.
> Refactor the login module, make it compatible with the old interface, and add tests
Agent reads directories, searches call relationships, analyzes risks...
Please choose:
1. Approve and execute
2. Continue modifying the plan
3. Cancel
> 1
Plan approved. Entered Code Mode.
Created task directory:
.powercode/tasks/20260817-143012000-refactor-login-module/
Before approval, the Agent cannot see the tools for writing files and executing commands; after approval, it starts modifying code and uses a TODO list to record its progress.
This is the Plan Mode we will implement in this article:
It's not a prompt saying "please think first," but a "read-only—approval—execution" state machine controlled by the Harness.
What We Will Implement in This Article
Based on the powercode completed after the fifth article, we add six capabilities:
- Two modes:
codeandplan; - Three commands:
/plan,/code,/status; - Plan Mode can only read, list directories, and search;
- The model must submit a plan title, full body, and execution steps via
submit_plan; - The Agent Loop pauses after submitting a plan, waiting for user approval;
- After approval, it enters Code Mode, generates
PLAN.mdandTODO.mdin.powercode/tasks/<plan-name>/, and then executes.
The final tool boundaries are:
Plan Mode
├── read_file # Read the content of a specified file
├── list_files # View the project directory structure
├── search_files # Search for text and code within the project
└── submit_plan # Submit the complete plan and enter the approval phase
Code Mode
├── read_file # Read the content of a specified file
├── list_files # View the project directory structure
├── search_files # Search for text and code within the project
├── write_file # Create a file or write complete content
├── edit_file # Make targeted modifications to an existing file
└── bash # Execute terminal commands like build and test
Why doesn't Plan Mode provide Bash?
Not because Bash is completely unusable, but because this Demo's planning phase simply doesn't need it.
Plan Mode only needs to accomplish three things:
read_file
Only reads a single file
list_files
Only lists directory structure
search_files
Only searches text
These three tools are already sufficient for the Agent to see the directory structure, find relevant code, and read file contents.
If we also gave Bash to Plan Mode, we would then need to judge whether each shell command is just a query, or if it could write files, delete files, or access the network. This would introduce a lot of boundary handling unrelated to the main topic of this article.
Mature products like Codex and Claude Code can continue to constrain Bash through command parsing, permission rules, user approval, and sandboxing. But for our first version, the clearest choice is:
Plan Mode only provides the dedicated query tools truly needed for planning, and does not provide Bash for now.
Series Directory
- Building an AI Agent Without Frameworks: (1) Get It Running First
- Without LangChain, Build an AI Agent: Give the LLM "Hands" to Read Project Files
- The Core Loop of an AI Agent Is This Simple: Build an Agent Loop by Hand
- How Does Claude Code Modify Code Itself? The Answer Lies in These 4 Tools
- Without LangChain: Build Agent Conversation Memory and Context Compaction with 200 Lines of Code
- This Article: How Does an AI Agent Execute Long Tasks? Add Plan Mode and a Task List
- More practical content continuously updated...
🚀 Source code for this section: powercode 👈 Click it
If you encounter problems midway, you can check the source code for troubleshooting. The code for subsequent chapters will also be continuously updated. If this project is helpful to you, a Star ⭐ is welcome.
First, Clarify the Repository and Directory
This article continues using the main project powercode.
After the fifth article, we had already frozen the code at that point in:
powercode/demos/05-session-compaction
This directory is an independent Demo for the fifth article, used to review Session and Compactor, and will not be modified further.
What the sixth article modifies is the main project source code:
powercode/
├── provider.json
├── src/ # Agent source code we continue to develop in this article
│ ├── agent.ts
│ ├── main.ts
│ ├── plan.ts # New in this article
│ ├── plan-files.ts # New in this article
│ ├── options.ts # New in this article
│ ├── context/
│ │ ├── session.ts
│ │ └── compactor.ts
│ └── tools/
│ ├── read-file.ts
│ ├── write-file.ts
│ ├── edit-file.ts
│ ├── bash.ts
│ ├── list-files.ts # New in this article
│ ├── search-files.ts # New in this article
│ └── submit-plan.ts # New in this article
├── demos/
│ └── 05-session-compaction/ # Fifth article's finished state, no longer modified
└── workspace/
└── task-board/ # Practice directory for the Agent to plan and execute
└── .powercode/ # Task artifacts generated after running plan
└── tasks/
└── <plan-name>/
├── PLAN.md
└── TODO.md
Don't mix up the three directories:
powercode/src
The Agent Harness we are developing
powercode/demos/05-session-compaction
The frozen version from the fifth article
powercode/workspace/task-board
The practice project the Agent actually reads, searches, and modifies
All subsequent code is added based on the main project after the fifth article. It's okay if you haven't read the previous ones: the new files and key modifications involved in this article will be provided in full.
First, the Conclusion: Plan and TODO Are Two Concepts, but Can Be in the Same Document
Plan and TODO often appear together, but they don't solve the same problem.
Plan
Decides how to proceed
Happens before execution
Needs user review
TODO
Records the current progress
Happens after the plan is approved
Updated as execution progresses
Some Agents put both in the same document. For example, a plan document generated by Codex might directly contain a checklist like this:
- [ ] Find the real entry point of the login module
- [ ] Sort out the callers of the old interface
- [ ] Implement the compatibility layer
- [ ] Add tests and verify
Before the plan is approved, these checkboxes represent "how to proceed," which is essentially still a task breakdown of the Plan. If the Agent continues to update the checkbox status after approval, this document simultaneously takes on the responsibility of a TODO.
So what really needs to be separated are two states:
Before approval
This is a plan checklist pending review
After approval
This is an execution progress checklist
As for whether it's physically one file or two, that's an implementation choice of the Harness. To make responsibilities clearer, this article will create an independent task directory for each approved plan and save two files inside:
.powercode/tasks/<plan-name>/
├── PLAN.md
│ Saves the user-approved plan, kept as stable as possible
│
└── TODO.md
Generated from the plan steps, continuously updated during execution
This way, the plans and progress of different tasks won't overwrite each other, and it will be easier to add task history and interruption recovery later.
The complete workflow should be:
Code Mode
↓ User enters /plan
Plan Mode
↓ Read project, search code, clarify requirements
Submit Plan
↓
Wait for user approval
├── Continue modifying the plan
├── Cancel
└── Approve
↓
Code Mode
↓
Create .powercode/tasks/<plan-name>/
↓
Write PLAN.md and TODO.md
↓
Modify code, verify item by item, update TODO
This is also not the same thing as the context compaction from the previous article:
Session + Compactor
Solves "what can the model see in this round"
Plan Mode
Solves "what can the Agent do before user approval"
Task directory + PLAN.md + TODO.md
Solves "which task is this, what was approved, and where is the execution at"
These three issues should be handled in different parts of the Harness.
Why You Can't Just Write "Please Plan First"
The simplest approach is to write in the system prompt:
Please analyze the requirements first, do not modify the code.
This can increase the probability of the model thinking first, but it is not a reliable permission boundary.
If the Harness still hands all the following tools to the model:
read_file
write_file
edit_file
bash
The model still has the ability to modify the project. The prompt is merely advising it not to write, without actually taking away the write permission.
The official Claude Code places Plan Mode within its permission mode system: the Plan phase allows reading and exploration but not modifying source code; after the plan is complete, it's handed to the user for review, and only after approval does it switch to execution permissions.
Reference: Claude Code Permission Modes
This perfectly illustrates the role of the Harness:
The large model is responsible for generating the plan, and the Harness is responsible for deciding the current state, which tools to provide, and when to pause and wait for the user.
The combination used in this article is:
Claude Code-style permission state machine
+
More explicit planning prompts
+
Post-approval TODO execution progress
The first version will not implement an independent Plan model, Plan sub-agent, context clearing, or a full-screen reviewer for now. These are all capabilities that a production-grade Plan Mode can continue to add:
- Independent Plan Model: Switch to a model better at reasoning during the planning phase, and switch back to a faster or cheaper execution model after approval. This is essentially model routing; the Agent Loop remains the same.
- Plan Sub-Agent: The main Agent doesn't do the full planning itself, but launches a sub-Agent with independent context and tools to research the project, and finally hands the plan back to the main Agent. This introduces multi-Agent scheduling and result handover.
- Context Clearing: After user approval, don't hand the entire planning conversation to the execution model, only keep the approved Plan and necessary information. What's cleared is the message context, not the project files.
- Full-Screen Reviewer: Instead of printing a large block of Plan directly in the terminal, open an independent interface for the user to scroll, edit, annotate, approve, or reject the plan. This belongs to TUI interaction enhancement.
An independent Plan model and a Plan sub-Agent are easily confused: the former is just "switching to a different large model to think," while the latter is "launching another Agent to complete the planning task."
This article's first version uses the same model, the same Session, and a simple CLI approval menu. We'll get the most important state boundaries right first.
Step 1: Establish the Plan Mode State Machine
This step doesn't involve the large model or tool calls yet. We'll just create an object specifically for saving the Plan Mode's running state, so the Harness can answer three questions at any time:
Is it currently Plan Mode or Code Mode?
Is the plan being generated, or is it already waiting for approval?
Is the currently saved plan pending approval, or already approved?
These answers will later determine which tools the Agent can see, and whether the Agent Loop should continue running or pause to wait for the user.
A PlanDraft will also appear in the code below. Don't be intimidated by the name; it's just the data format the Harness uses internally to save a "plan pending approval": title is used to create the task directory, content saves the complete Markdown plan body, and steps is used to generate and update the TODO.
First, create src/plan.ts:
export type AgentMode = "code" | "plan";
export type AgentStatus =
| "idle"
| "planning"
| "waiting_for_approval"
| "executing";
export interface PlanDraft {
title: string;
content: string;
steps: string[];
}
export class AgentState {
private mode: AgentMode;
private status: AgentStatus;
private pendingPlan?: PlanDraft;
private approvedPlan?: PlanDraft;
constructor(initialMode: AgentMode = "code") {
this.mode = initialMode;
this.status = initialMode === "plan" ? "planning" : "idle";
}
getMode(): AgentMode {
return this.mode;
}
getStatus(): AgentStatus {
return this.status;
}
getPendingPlan(): PlanDraft | undefined {
return this.pendingPlan;
}
getApprovedPlan(): PlanDraft | undefined {
return this.approvedPlan;
}
enterPlan(): void {
this.mode = "plan";
this.status = "planning";
this.pendingPlan = undefined;
this.approvedPlan = undefined;
}
enterCode(): void {
this.mode = "code";
this.status = "idle";
this.pendingPlan = undefined;
this.approvedPlan = undefined;
}
submitPlan(plan: PlanDraft): void {
if (
this.mode !== "plan" ||
this.status !== "planning"
) {
throw new Error("Only the planning phase can submit a plan.");
}
this.pendingPlan = plan;
this.status = "waiting_for_approval";
}
refinePlan(): void {
if (
this.status !== "waiting_for_approval" ||
!this.pendingPlan
) {
throw new Error("There is currently no plan waiting to be modified.");
}
this.status = "planning";
}
approvePlan(): PlanDraft {
if (
this.status !== "waiting_for_approval" ||
!this.pendingPlan
) {
throw new Error("There is currently no plan waiting for approval.");
}
const plan = this.pendingPlan;
this.mode = "code";
this.status = "executing";
this.pendingPlan = undefined;
this.approvedPlan = plan;
return plan;
}
cancelPlan(): void {
this.mode = "code";
this.status = "idle";
this.pendingPlan = undefined;
this.approvedPlan = undefined;
}
finishExecution(): void {
if (this.status === "executing") {
this.status = "idle";
this.approvedPlan = undefined;
}
}
}
export function formatPlan(plan: PlanDraft): string {
return `# ${plan.title}
${plan.content.trim()}
`;
}
1. What mode and status Manage Respectively
Here, mode and status are intentionally separated:
mode
Controls what capabilities the Agent currently has
For example, Plan Mode cannot see write_file and bash
status
Records which stage the Agent Loop has reached
For example, planning, waiting for approval, or executing
Why not just define a boolean?
planMode: boolean
Because planMode = true can only tell us "currently in Plan Mode," but cannot express the following two completely different situations:
mode = plan
status = planning
Agent is still reading the project, searching code, and generating a plan
mode = plan
status = waiting_for_approval
Plan has been submitted, Agent Loop must pause
Although the tool boundaries are the same in both states, the former can still continue calling the model, while the latter can only wait for the user's choice.
2. How the Harness Receives the Model-Generated Plan
As we've been saying, a plan needs to include the goal, implementation steps, risks, and verification methods.
This content is ultimately just a piece of ordinary Markdown:
# Refactor Login Module
## Goal
Refactor the login flow while maintaining compatibility with the old interface.
## Implementation Steps
1. Find the login entry point
2. Sort out the callers of the old interface
3. Add a compatibility layer
## Risks
Older client versions might still call the original interface.
## Verification Method
Run login tests and manually verify with the old client.
Let's clarify an easily confusing point here:
The large model can directly understand the entire Markdown. We don't need to first extract sections like risks and verification methods and then re-feed them to it.
After user approval, the Harness can put the complete plan into the model's context, or it can tell the model the path to PLAN.md and let it read it itself. Both methods work.
Whether the Harness actively puts the plan in, or the Agent uses
read_fileto read it, the plan body must ultimately enter the model's context for the large model to understand and execute it. The real value ofPLAN.mdis to persist the approved plan: even if the context is later compacted or lost, the Agent can still re-read it.
If so, why define PlanDraft?
Because this article also wants the Harness to automatically complete two things:
Create a task directory based on the plan title
Generate TODO.md based on the execution steps
If we let the Harness guess the title and steps from free-form Markdown, the parsing would be very unstable. But the goal, background, risks, and verification instructions only need to be read by the user and the large model; there's no need to break them all into fields.
Therefore, the first version only structures the parts the program truly needs to process:
export interface PlanDraft {
title: string;
content: string;
steps: string[];
}
interface is just TypeScript's way of describing the shape of data. PlanDraft is not a new Agent, nor an extra draft file; it's just a JavaScript object format for saving a plan pending approval.
Why is Draft in the name?
Because when the model first submits it, this plan hasn't been approved by the user yet; it can only be considered a "plan draft pending review." Before approval, the user can still ask the model to modify or cancel it.
The data generated by the model will flow like this:
Model calls submit_plan
↓ Submits title, content, steps
Harness gets PlanDraft
↓
Saves to pendingPlan
↓ User approves
Moves to approvedPlan
↓
Generates PLAN.md and TODO.md
Now it's easier to understand each field:
title
Plan name, later used to generate the task directory name
content
Complete Markdown plan body
Goal, plan, risks, and verification methods are all written here
steps
Executable steps extracted from content
Used to generate and update TODO.md
Note, it's not that the Harness first saves content and then parses steps from the Markdown. The model submits these three fields simultaneously when calling submit_plan, and the Harness only checks if they are non-empty strings or arrays.
There will be a small amount of duplication here: content will contain the implementation plan, and steps lists the executable items separately again. This is intentional.
content
For user review, and for the large model to execute
steps
For the Harness to generate and track the TODO
The Plan Mode prompt will require the two to be consistent. The first version can only constrain format and non-emptiness, and cannot semantically prove they are exactly the same; but this is sufficient for a teaching Demo. This way, the expressive power of Markdown is preserved, and the Harness doesn't need to parse the entire document itself.
3. What's the Difference Between pendingPlan and approvedPlan
Both fields save a PlanDraft, but their trust levels are different:
pendingPlan
The model has submitted it, but the user hasn't approved it yet
It can be asked to be modified, or it can be canceled
approvedPlan
The user has approved it
The Agent can treat it as the formal basis for subsequent execution
The key action during approval is moving the same plan from the "pending area" to the "approved area":
pendingPlan
↓ User approves
approvedPlan
This is also the meaning of these lines of code inside approvePlan():
const plan = this.pendingPlan;
this.mode = "code";
this.status = "executing";
this.pendingPlan = undefined;
this.approvedPlan = plan;
Note, "the model generated a plan" does not mean "the plan is in effect." Only after user approval does it enter approvedPlan.
4. These Methods Are Actually State Transition Entry Points
AgentState is not responsible for calling the large model, nor for writing PLAN.md. It is only responsible for saving the state and ensuring the state can only be changed through the following entry points:
enterPlan(): Enter Plan Mode, switch status toplanning, and clean up any unfinished previous plan.enterCode(): Manually return to Code Mode, equivalent to abandoning the current planning process.submitPlan(): Only allowed to be called in Plan Mode, saves the pending plan, and enterswaiting_for_approval.refinePlan(): When the user requests modifications, return from waiting for approval toplanning.approvePlan(): Convert the pending plan to an approved plan, switch to Code Mode, and start execution.cancelPlan(): Cancel the plan, clear both plan fields, and return toidle.finishExecution(): After execution ends, restoreexecutingtoidle, and clearapprovedPlanwhich was only used during this execution. The already writtenPLAN.mdandTODO.mdare not affected.
Stringing them together gives the complete state machine:
flowchart TD
A["mode: code<br/>status: idle"] -->|"User enters /plan<br/>enterPlan()"| B["mode: plan<br/>status: planning"]
B -->|"User enters /code<br/>enterCode()"| A
B -->|"Model submits plan<br/>submitPlan()"| C["mode: plan<br/>status: waiting_for_approval"]
C -->|"User requests further modification<br/>refinePlan()"| B
C -->|"User cancels<br/>cancelPlan()"| A
C -->|"User approves<br/>approvePlan()"| D["mode: code<br/>status: executing"]
D -->|"Execution ends<br/>finishExecution()"| A
classDef code fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;
classDef plan fill:#fff8e1,stroke:#f9a825,color:#5d4037;
class A,D code;
class B,C plan;
This diagram needs to be viewed on two layers:
mode inside the boxes
code means Code Mode
plan means Plan Mode
It determines which tools can currently be used
status inside the boxes
idle, planning, waiting_for_approval, executing
Determines if the Agent Loop is planning, waiting, or executing
Methods on the arrows
Indicate through which entry point the state can change
For example, after the model calls submitPlan(), it just moves from planning to waiting_for_approval. It is still in Plan Mode at this point, and write tools will not be restored. Next, the Agent Loop must stop and can only wait for the user to choose to continue modifying, cancel, or approve.
Only when the user chooses to approve does approvePlan() complete two things simultaneously: switch mode back to code, and switch status to executing. At this point, the Harness creates the task files and starts execution.
Actively checking the current state inside submitPlan(), refinePlan(), and approvePlan() is to prevent skipping the normal workflow. For example, submitting a plan directly in Code Mode, or approving directly without a pending plan, should both immediately throw an error.
5. formatPlan() Only Adds the Title
content is already the complete Markdown plan body, so formatPlan() doesn't need to understand or rearrange the sections inside.
title + content
↓ formatPlan()
A complete PLAN.md that can be displayed and saved
It won't approve the plan, won't switch modes, and won't write files. It's only responsible for concatenating the title and body. The goal, plan, risks, and verification methods remain in the model-generated content.
Step 2: Add Mode Commands to the CLI
Claude Code can switch Plan Mode with Shift+Tab.
Our first version will use three text commands first:
/plan Enter Plan Mode
/code Return to Code Mode
/status View current mode and status
The reason is simple: the current project uses readline.question() for line-by-line input. If we tried to capture Shift+Tab now, we'd also need to handle raw keypresses, terminal escape sequences, and input box refreshing, and the article would veer from Agent Harness to TUI implementation.
But shortcuts and text commands should ultimately call the same state switching function:
/plan ──────────┐
├── state.enterPlan()
Shift+Tab ──────┘
So the first version gets the state machine right first; adding shortcuts later won't require modifying the Agent core.
Create src/options.ts
export interface CliOptions {
dir: string;
plan: boolean;
}
function readValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`${name} requires a value.`);
}
return value;
}
export function parseCliOptions(args: string[]): CliOptions {
return {
dir: readValue(args, "-dir") ?? ".",
plan: args.includes("--plan"),
};
}
parseCliOptions() allows the user to decide two things when starting PowerCode: which mode the Agent enters initially, and which project directory it operates on.
This article uses the following command to start:
npm start -- --plan -dir ./workspace/task-board
Here, the first -- after npm start is just npm's parameter separator, indicating that the following content should be passed on to PowerCode. The parameters truly belonging to PowerCode are:
--plan
Enter Plan Mode directly after startup
-dir ./workspace/task-board
Set workspace/task-board as the Agent's working directory
These two parameters are independent of each other: --plan determines the initial mode, and -dir determines the directory the tools can read, search, and modify. This article puts them together so the Agent immediately plans the practice project after startup, while also preventing it from experimenting with powercode's own source code.
This article explicitly uses
-dirbecause we are still debugging the CLI vianpm startinside the PowerCode source directory. In the future, after PowerCode is published as a global npm command, the user will only need to enter their own project and runpowercode, and the current project directory corresponding toprocess.cwd()will automatically become the Agent's workspace;-dirwill only be kept as an optional override parameter for specifying a project from another location.
That is to say, the most common usage in the future should be:
cd ~/projects/my-app
powercode --plan
Only when it's inconvenient to enter the target project first would you need to explicitly specify:
powercode --plan -dir ~/projects/my-app
Step 3: Add Two Truly Read-Only Exploration Tools
The fifth article already had read_file, but reading a project also requires two basic capabilities:
list_files
View directory structure
search_files
Search code by keyword
Create src/tools/list-files.ts
import { readdir } from "node:fs/promises";
import { join, relative } from "node:path";
import type { Tool } from "./types.ts";
import { resolveInWorkDir } from "./path.ts";
const MAX_ITEMS = 200;
const SKIPPED_DIRECTORIES = new Set([
".git",
".powercode",
"dist",
"node_modules",
]);
function parsePath(argumentsJson: string): string {
const input = JSON.parse(argumentsJson) as {
path?: unknown;
};
if (input.path !== undefined && typeof input.path !== "string") {
throw new Error("path must be a string.");
}
return input.path ?? ".";
}
function toDisplayPath(path: string): string {
return path.replaceAll("\\", "/");
}
export class ListFilesTool implements Tool {
readonly name = "list_files";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description: "Recursively list files and directories in the current project.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Directory relative to the project root, defaults to the project root",
},
},
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const path = parsePath(argumentsJson);
const start = resolveInWorkDir(this.workDir, path);
const items: string[] = [];
const walk = async (directory: string): Promise<void> => {
if (items.length >= MAX_ITEMS) {
return;
}
const entries = await readdir(directory, {
withFileTypes: true,
});
entries.sort((left, right) =>
left.name.localeCompare(right.name),
);
for (const entry of entries) {
if (items.length >= MAX_ITEMS) {
return;
}
if (entry.isSymbolicLink()) {
continue;
}
if (
entry.isDirectory() &&
SKIPPED_DIRECTORIES.has(entry.name)
) {
continue;
}
const fullPath = join(directory, entry.name);
const displayPath = toDisplayPath(
relative(this.workDir, fullPath),
);
items.push(
entry.isDirectory()
? `${displayPath}/`
: displayPath,
);
if (entry.isDirectory()) {
await walk(fullPath);
}
}
};
await walk(start);
if (items.length === 0) {
return "Directory is empty.";
}
const suffix =
items.length >= MAX_ITEMS
? `\n...[Max ${MAX_ITEMS} items returned]...`
: "";
return items.join("\n") + suffix;
}
}
Here, resolveInWorkDir() from the fourth article is reused.
Even if a tool is read-only, the path must be restricted from escaping the working directory. Read-only does not mean it can read arbitrary files on the user's computer.
Create src/tools/search-files.ts
import {
readFile,
readdir,
stat,
} from "node:fs/promises";
import { join, relative } from "node:path";
import type { Tool } from "./types.ts";
import { resolveInWorkDir } from "./path.ts";
const MAX_RESULTS = 50;
const MAX_FILE_BYTES = 200_000;
const SKIPPED_DIRECTORIES = new Set([
".git",
".powercode",
"dist",
"node_modules",
]);
interface SearchInput {
query: string;
path: string;
}
function parseInput(argumentsJson: string): SearchInput {
const input = JSON.parse(argumentsJson) as {
query?: unknown;
path?: unknown;
};
if (
typeof input.query !== "string" ||
!input.query.trim()
) {
throw new Error("query must be a non-empty string.");
}
if (
input.path !== undefined &&
typeof input.path !== "string"
) {
throw new Error("path must be a string.");
}
return {
query: input.query,
path: input.path ?? ".",
};
}
function toDisplayPath(path: string): string {
return path.replaceAll("\\", "/");
}
export class SearchFilesTool implements Tool {
readonly name = "search_files";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description:
"Search for a keyword in text files within the current project.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "The text keyword to search for",
},
path: {
type: "string",
description:
"Search directory relative to the project root, defaults to the project root",
},
},
required: ["query"],
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const input = parseInput(argumentsJson);
const start = resolveInWorkDir(
this.workDir,
input.path,
);
const query = input.query.toLowerCase();
const results: string[] = [];
const walk = async (directory: string): Promise<void> => {
if (results.length >= MAX_RESULTS) {
return;
}
const entries = await readdir(directory, {
withFileTypes: true,
});
for (const entry of entries) {
if (results.length >= MAX_RESULTS) {
return;
}
if (entry.isSymbolicLink()) {
continue;
}
if (
entry.isDirectory() &&
SKIPPED_DIRECTORIES.has(entry.name)
) {
continue;
}
const fullPath = join(directory, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
const fileStat = await stat(fullPath);
if (fileStat.size > MAX_FILE_BYTES) {
continue;
}
const content = await readFile(fullPath, "utf8");
if (content.includes("\0")) {
continue;
}
const displayPath = toDisplayPath(
relative(this.workDir, fullPath),
);
const lines = content.split(/\r?\n/);
for (
let index = 0;
index < lines.length;
index += 1
) {
const line = lines[index];
if (line.toLowerCase().includes(query)) {
results.push(
`${displayPath}:${index + 1}: ${line.trim()}`,
);
}
if (results.length >= MAX_RESULTS) {
return;
}
}
}
};
await walk(start);
if (results.length === 0) {
return `No results found for keyword: ${input.query}`;
}
const suffix =
results.length >= MAX_RESULTS
? `\n...[Max ${MAX_RESULTS} results returned]...`
: "";
return results.join("\n") + suffix;
}
}
This search tool has no regex, pipes, or redirection; it does one clear thing: find text within the working directory.
This is easier to explain and easier to test than handing a full Bash to Plan Mode.
Note that both tools default to skipping .powercode. What's saved here are Harness artifacts, not project source code; if historical Plans and TODOs are repeatedly searched during subsequent planning, it might interfere with the model's judgment. During the execution phase, the precise task file paths given in the approval message can still be accessed via read_file and edit_file.
Step 4: Make the Registry Responsible for Both "Hiding" and "Intercepting"
Simply not sending the write tool definitions to the model is not complete enough.
The model usually won't call a tool it hasn't seen, but the Harness should still double-check before actual execution:
First layer
Don't put write_file, edit_file, bash into the tools parameter
Second layer
Even if the model generates these tool names, the Registry refuses to execute them
Modify two methods in src/tools/registry.ts:
- Add an
allowedNamesparameter togetDefinitions(), only sending the tool definitions allowed in the current mode to the model; - Add the same parameter to
execute(), intercepting disallowed tools again before actual execution.
register() and other code remain unchanged; only the following two methods need to be replaced:
getDefinitions(
allowedNames?: ReadonlySet<string>,
): OpenAI.Chat.Completions.ChatCompletionTool[] {
return [...this.tools.values()]
.filter(
(tool) =>
!allowedNames || allowedNames.has(tool.name),
)
.map((tool) => tool.definition);
}
async execute(
name: string,
argumentsJson: string,
allowedNames?: ReadonlySet<string>,
): Promise<string> {
if (allowedNames && !allowedNames.has(name)) {
throw new Error(
`Tool not allowed in current mode: ${name}`,
);
}
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Tool not found: ${name}`);
}
return tool.execute(argumentsJson);
}
At this point, the "read-only" nature of Plan Mode is no longer just a prompt.
The real boundary is:
const PLAN_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
"submit_plan",
]);
write_file, edit_file, and bash are not in the set, so the model cannot see them, and the Registry will not execute them.
Step 5: Add the submit_plan Tool
If the plan were just ordinary Markdown text, it would be hard for the Agent Loop to accurately judge:
Is the model explaining its thought process?
Or has it already submitted the final plan?
Should it continue calling the model now?
Or should it pause and wait for the user?
So we add an explicit control tool:
submit_plan
The model indicates "the plan is complete"
The Harness switches the status to waiting_for_approval
The Agent Loop pauses
Create src/tools/submit-plan.ts:
import {
AgentState,
type PlanDraft,
} from "../plan.ts";
import type { Tool } from "./types.ts";
function parseString(
input: Record<string, unknown>,
name: string,
): string {
const value = input[name];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${name} must be a non-empty string.`);
}
return value.trim();
}
function parseStringArray(
input: Record<string, unknown>,
name: string,
): string[] {
const value = input[name];
if (
!Array.isArray(value) ||
value.some(
(item) =>
typeof item !== "string" || !item.trim(),
)
) {
throw new Error(`${name} must be an array of strings.`);
}
if (value.length === 0) {
throw new Error(`${name} cannot be empty.`);
}
return value.map((item) => item.trim());
}
function parsePlan(argumentsJson: string): PlanDraft {
const input: unknown = JSON.parse(argumentsJson);
if (
typeof input !== "object" ||
input === null ||
Array.isArray(input)
) {
throw new Error("Plan parameters must be an object.");
}
const record = input as Record<string, unknown>;
return {
title: parseString(record, "title"),
content: parseString(record, "content"),
steps: parseStringArray(record, "steps"),
};
}
export class SubmitPlanTool implements Tool {
readonly name = "submit_plan";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description:
"Submit the final implementation plan and pause to wait for user approval.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Plan title",
},
content: {
type: "string",
description:
"Complete Markdown plan body, including goal, plan, risks, and verification method. Do not repeat the top-level title.",
},
steps: {
type: "array",
items: { type: "string" },
description: "Implementation steps in execution order",
},
},
required: [
"title",
"content",
"steps",
],
additionalProperties: false,
},
},
};
constructor(private readonly state: AgentState) {}
async execute(argumentsJson: string): Promise<string> {
const plan = parsePlan(argumentsJson);
this.state.submitPlan(plan);
return "Plan submitted, waiting for user approval.";
}
}
This tool does not modify the user's source code.
It only writes the plan title, Markdown body, and execution steps into AgentState, and then changes the Harness state. The plan content is later displayed to the user by the CLI.
Step 6: Inject Dedicated Prompts for Plan Mode
Now that the permission boundary is guaranteed by the Harness, the prompts are only responsible for improving plan quality.
When modifying src/agent.ts, first add the state-related imports:
import {
AgentState,
formatPlan,
} from "./plan.ts";
Then split the original single system prompt into three parts:
const CORE_SYSTEM_PROMPT = `
You are power-code, a development assistant.
Prioritize reading real files; after modifications, proactively run commands to verify results; please answer in Chinese.
All file paths are relative to the current working directory; you cannot operate on files outside the working directory.
`;
const PLAN_MODE_PROMPT = `
# Current Mode: Plan Mode
You can now only research the project and formulate plans; you cannot modify files or execute commands.
Please work in the following order:
1. First, use list_files, search_files, and read_file to understand the real project.
2. Do not ask the user for answers that can be found in the project.
3. Only ask the user questions and wait for an answer when key requirements or trade-offs cannot be confirmed from the project.
4. Do not write ordinary guesses as established facts.
5. content must be a complete Markdown plan body, clearly stating the goal, plan, risks, and verification method. Do not repeat the top-level title corresponding to title.
6. steps must be extracted from the implementation plan in content, arranged in execution order, and include necessary verification steps.
7. content and steps must not present two different plans.
8. After the plan is complete, you must call submit_plan; do not just output a block of ordinary Markdown.
`;
const EXECUTION_PROMPT = `
# Current Mode: Code Mode
The following plan has been approved by the user.
The approval message will provide the paths to PLAN.md and TODO.md for this task.
Please read these two files first, then execute from top to bottom according to TODO.md.
- After completing a step with a result, immediately update the corresponding - [ ] to - [x].
- When code has been modified but the corresponding verification is not yet complete, do not check off steps that include verification work.
- When encountering an error, first read the TODO.md for this task to confirm the current position, then fix and re-verify.
- Do not arbitrarily change the approved goals and scope.
`;
What's borrowed here is a very practical planning sequence:
Explore first
↓
Confirm what can be confirmed from the code yourself
↓
Only ask questions that truly require user decision
↓
Generate a plan that can be directly executed
Note, the prompt is not a security boundary.
Even if the model ignores "cannot modify files," Plan Mode cannot access write tools; this is the significance of modifying the Registry earlier.
Step 7: Dynamically Provide Tools Based on Mode
Continue modifying src/agent.ts.
First, address an easily overlooked point. To demonstrate the minimal Agent Loop, the fifth article set the maximum execution rounds to 8:
const MAX_STEPS = 8;
For a long task like "read project, generate plan, modify multiple files, verify item by item," 8 rounds are usually insufficient. Adjust it to:
const MAX_STEPS = 30;
Here, one "round" means the model completes one thought process and returns a result, not necessarily calling only one tool. The model might call multiple read_file or write_file in the same round, so multiple "Round 8" entries might appear in the log.
MAX_STEPS must still be kept; it's a safety limit to prevent the Agent from looping infinitely due to repeated searches or modifications. 30 is not a fixed standard, just a starting point more suitable for this article's long task Demo; it can be made a configuration item later.
First, define two sets of tool names outside the Agent class, which can be placed after the previous prompt constants:
const PLAN_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
"submit_plan",
]);
const CODE_TOOL_NAMES = new Set([
"read_file",
"list_files",
"search_files",
"write_file",
"edit_file",
"bash",
]);
Then add AgentState to the Agent:
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
private readonly session: Session,
private readonly state: AgentState,
) {
this.compactor = new Compactor(
client,
client.getContextWindow(),
);
}
Next, put the following two private methods inside the Agent class body, positioned after constructor and before run():
export class Agent {
constructor(...) {
...
}
// Add getAllowedToolNames() here
// Add buildSystemPrompt() here
async run(...) {
...
}
}
The specific code is as follows:
private getAllowedToolNames(): ReadonlySet<string> {
return this.state.getMode() === "plan"
? PLAN_TOOL_NAMES
: CODE_TOOL_NAMES;
}
private buildSystemPrompt(): string {
if (this.state.getMode() === "plan") {
return CORE_SYSTEM_PROMPT + PLAN_MODE_PROMPT;
}
const approvedPlan = this.state.getApprovedPlan();
if (approvedPlan) {
return (
CORE_SYSTEM_PROMPT +
EXECUTION_PROMPT +
`\n\n${formatPlan(approvedPlan)}`
);
}
return CORE_SYSTEM_PROMPT;
}
This is exactly where "the entire plan is handed directly to the large model." formatPlan(approvedPlan) will add title + content as-is to the execution phase's system prompt; the large model doesn't need to rely on the Harness to parse risks or verification methods. Writing PLAN.md later is to persist the approval result, making it easy to re-read during execution and review later.
When calling the model, no longer get all tools directly:
const allowedToolNames =
this.getAllowedToolNames();
const completion = await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(allowedToolNames),
);
Also pass the same set when executing tools:
result = await this.registry.execute(
name,
toolCall.function.arguments,
allowedToolNames,
);
Finally, in the outer Agent Loop of run(), find the following tool loop:
for (const toolCall of toolCalls) {
// Execute tool and write tool result to Session
}
Place the status check after the closing brace of this tool loop, before the outer for (let step...) enters the next round:
for (const toolCall of toolCalls) {
// Original tool execution code
}
// Add here: All tools for this round have been executed,
// but the large model has not been requested again yet.
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
const plan = this.state.getPendingPlan();
if (!plan) {
throw new Error("Plan status is abnormal.");
}
return formatPlan(plan);
}
Don't put it inside for (const toolCall...), and don't put it outside the entire run(). Placing it here ensures all tool results for this round have been written to the Session, while stopping in time before the next model request.
This way, after the model calls submit_plan, the Agent Loop won't continue to let it approve itself, but will immediately return to the CLI.
Complete Key Structure of src/agent.ts
The integrated run() body is as follows:
async run(prompt: string): Promise<string> {
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
throw new Error("The current plan is waiting for approval.");
}
this.session.append({
role: "user",
content: prompt,
});
for (let step = 1; step <= MAX_STEPS; step += 1) {
const memory =
await this.compactor.buildWorkingMemory(
this.session,
);
const allowedToolNames =
this.getAllowedToolNames();
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
[
{
role: "system",
content: this.buildSystemPrompt(),
},
...memory,
];
const completion =
await this.client.completeWithUsage(
messages,
this.registry.getDefinitions(
allowedToolNames,
),
);
const message = completion.message;
if (!message) {
throw new Error("Model did not return a message.");
}
this.session.append(message);
if (
completion.totalTokens !== undefined &&
completion.totalTokens > 0
) {
this.session.saveUsage(
completion.totalTokens,
);
}
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
this.state.finishExecution();
return (
message.content ?? "Model did not return text content."
);
}
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") {
throw new Error(
`Tool type not supported yet: ${toolCall.type}`,
);
}
const name = toolCall.function.name;
console.log(
`Round ${step}: AI calls ${name}`,
);
let result: string;
try {
result = await this.registry.execute(
name,
toolCall.function.arguments,
allowedToolNames,
);
console.log(`✓ ${name} execution complete\n`);
} catch (error) {
const reason =
error instanceof Error
? error.message
: String(error);
result = `Tool execution failed: ${reason}`;
console.log(`✗ ${result}\n`);
}
this.session.append({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
if (
this.state.getStatus() ===
"waiting_for_approval"
) {
const plan = this.state.getPendingPlan();
if (!plan) {
throw new Error("Plan status is abnormal.");
}
return formatPlan(plan);
}
}
throw new Error(
`Execution exceeded ${MAX_STEPS} rounds, stopped.`,
);
}
The original Session and Compactor don't need to be deleted.
The project content read during the Plan phase still enters the current session, and when it gets too long, the Compactor still constructs the working memory. We've only changed the tools available in each round.
Step 8: Create an Independent Task Directory for Each Plan
Why not create an independent TODO.md at the very beginning of Plan Mode?
Because the plan hasn't been approved yet at that point.
The plan itself can perfectly well contain an unchecked step list; Codex uses this form of expression. But in this article's two-file design, TODO.md represents the formal progress that has entered the execution phase.
If the user asks to continue modifying the plan or cancels directly, there shouldn't be a TODO.md in the working directory that looks like execution has already started.
So the order is:
Model submits Plan
↓
CLI displays it to the user
↓
User approves
↓
Harness generates a safe directory name based on the Plan title
↓
Harness creates .powercode/tasks/<plan-name>/
↓
Harness writes PLAN.md and TODO.md
The task directory name cannot directly use the raw title returned by the model. The title might contain spaces, slashes, .., or other characters unsuitable for paths, and plans with the same name could overwrite each other.
So the Harness needs to first convert the title into a safe name, and then add a time identifier:
Plan Title
Refactor login module, compatible with old interface
Task Directory
.powercode/tasks/20260817-143012000-refactor-login-module-compatible-with-old-interface/
This .powercode is located in the execution directory specified by the user via -dir, not hardcoded into the Harness's own source directory. This article's startup command uses -dir ./workspace/task-board, so the actual location is:
powercode/workspace/task-board/.powercode/tasks/<plan-name>/
Create src/plan-files.ts:
import {
mkdir,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import {
formatPlan,
type PlanDraft,
} from "./plan.ts";
export interface PlanFiles {
taskDir: string;
planPath: string;
todoPath: string;
}
function createTaskName(title: string): string {
const safeTitle = title
.normalize("NFKC")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
const digits = new Date()
.toISOString()
.replace(/\D/g, "")
.slice(0, 17);
const timestamp = [
digits.slice(0, 8),
digits.slice(8),
].join("-");
return `${timestamp}-${safeTitle || "plan"}`;
}
function formatTodo(plan: PlanDraft): string {
const steps = plan.steps
.map((item) => `- [ ] ${item}`)
.join("\n");
return `# Execution Checklist
> This checklist is generated from the approved PLAN.md.
## Implementation Steps
${steps}
`;
}
export async function saveApprovedPlan(
workDir: string,
plan: PlanDraft,
): Promise<PlanFiles> {
const taskName = createTaskName(plan.title);
const taskDir = join(
".powercode",
"tasks",
taskName,
);
const absoluteTaskDir = join(workDir, taskDir);
const planPath = join(taskDir, "PLAN.md");
const todoPath = join(taskDir, "TODO.md");
await mkdir(absoluteTaskDir, {
recursive: true,
});
await Promise.all([
writeFile(
join(workDir, planPath),
formatPlan(plan),
"utf8",
),
writeFile(
join(workDir, todoPath),
formatTodo(plan),
"utf8",
),
]);
return {
taskDir,
planPath,
todoPath,
};
}
PLAN.md saves the complete content, so the goal, background, risks, and verification method are not lost. TODO.md is generated only from steps; if a certain verification must be practically executed, it should be included as an explicit step in steps.
A Unicode letter and number whitelist is used here to process the title; slashes, .., punctuation, and consecutive spaces will all be replaced. The time part is precise to milliseconds to reduce the possibility of overwriting tasks with the same name.
The writing here is not the model calling write_file in Plan Mode.
It happens after the user clicks approve, completed by the Harness itself:
Plan Mode
Model has no write tools
User approves
Harness switches to Code Mode
Code Mode
Harness creates the task directory and plan artifacts for this task
Agent starts execution
This boundary needs to be clearly stated in both the article and the code.
Step 9: Complete the Approval Workflow in the CLI
Finally, modify src/main.ts.
First, add alongside the original imports:
import { AgentState } from "./plan.ts";
import { saveApprovedPlan } from "./plan-files.ts";
import { ListFilesTool } from "./tools/list-files.ts";
import { SearchFilesTool } from "./tools/search-files.ts";
import { SubmitPlanTool } from "./tools/submit-plan.ts";
import { parseCliOptions } from "./options.ts";
Then parse arguments, register tools, and state:
const options = parseCliOptions(
process.argv.slice(2),
);
const workDir = resolve(process.cwd(), options.dir);
const state = new AgentState(
options.plan ? "plan" : "code",
);
const registry = new Registry();
registry.register(new ReadFileTool(workDir));
registry.register(new ListFilesTool(workDir));
registry.register(new SearchFilesTool(workDir));
registry.register(new WriteFileTool(workDir));
registry.register(new EditFileTool(workDir));
registry.register(new BashTool(workDir));
registry.register(new SubmitPlanTool(state));
const agent = new Agent(
client,
registry,
session,
state,
);
printStatus() is a top-level helper function in src/main.ts. Place it after the agent creation is complete, before reviewPendingPlan() and the input loop at the very bottom:
const state = new AgentState(...);
const registry = new Registry();
const agent = new Agent(...);
// Add printStatus() here
// Define reviewPendingPlan() later
// Finally, the while (true) input loop
The specific code is as follows:
function printStatus(): void {
console.log(`Current Mode: ${state.getMode()}`);
console.log(`Current Status: ${state.getStatus()}`);
const tools =
state.getMode() === "plan"
? "read_file, list_files, search_files, submit_plan"
: "read_file, list_files, search_files, write_file, edit_file, bash";
console.log(`Available Tools: ${tools}`);
}
Then add plan approval:
async function reviewPendingPlan(): Promise<void> {
while (
state.getStatus() ===
"waiting_for_approval"
) {
console.log(`
Please choose:
1. Approve and execute
2. Continue modifying the plan
3. Cancel
`);
const choice = (
await readline.question("Choice: ")
).trim();
if (choice === "1") {
const plan = state.approvePlan();
const files = await saveApprovedPlan(
workDir,
plan,
);
console.log(
"\nPlan approved. Entered Code Mode.",
);
console.log(
`Created task directory: ${files.taskDir}\n`,
);
await runPrompt(
`The plan has been approved.
Plan file: ${files.planPath}
Execution checklist: ${files.todoPath}
Please read these two files first, and start executing from the first uncompleted task; immediately update this TODO.md after completing each item.`,
);
return;
}
if (choice === "2") {
const feedback = (
await readline.question(
"Please enter plan modification feedback: ",
)
).trim();
if (!feedback) {
console.log("Modification feedback cannot be empty.");
continue;
}
state.refinePlan();
await runPrompt(
`Please modify the plan based on the following feedback, and call submit_plan again:\n${feedback}`,
);
continue;
}
if (choice === "3") {
state.cancelPlan();
console.log(
"\nPlan cancelled. Returned to Code Mode.",
);
return;
}
console.log("Please enter 1, 2, or 3.");
}
}
Finally, handle commands in the original input loop:
while (true) {
const value = (
await readline.question("> ")
).trim();
if (!value) continue;
if (value === "exit" || value === "quit") {
break;
}
if (value === "/plan") {
state.enterPlan();
console.log(
"Entered Plan Mode. Can only read and search the project.",
);
continue;
}
if (value === "/code") {
state.enterCode();
console.log("Entered Code Mode.");
continue;
}
if (value === "/status") {
printStatus();
continue;
}
try {
await runPrompt(value);
await reviewPendingPlan();
} catch (error) {
const message =
error instanceof Error
? error.message
: String(error);
console.error(`Execution failed in this round: ${message}`);
}
}
Now the three commands truly correspond to Harness states:
/plan
Switch state
Take away write tools
/code
Return to normal mode
Restore write tools
/status
View mode, phase, and current tool surface
They are not ordinary chat content sent to the large model.
Run a Complete Workflow
Prepare a practice directory, for example:
workspace/task-board/
└── src/
└── ceshi.ts
Start the project:
npm run build
npm start -- --plan -dir ./workspace/task-board
First, check the status:
> /status
Current Mode: plan
Current Status: planning
Available Tools: read_file, list_files, search_files, submit_plan
Enter a task:
Please transform src/ceshi.ts into a small, directly runnable todo demo.
Read the project first and provide a plan; do not modify immediately.
At this stage, you should see tool calls similar to:
list_files
↓
read_file
↓
search_files
↓
submit_plan
Should not appear:
write_file
edit_file
bash
After the plan is submitted, the CLI displays the complete Plan:
# Add command-line todo demo
## Goal
Transform the existing script into a directly runnable todo display, while keeping the implementation simple.
## Implementation Steps
1. Read and retain the existing entry structure.
2. Add todo data and formatted output.
3. Run node src/ceshi.ts.
4. Confirm the terminal output contains all todo items.
## Risks
- The current file extension might be incompatible with the Node execution method.
## Verification Method
- Run node src/ceshi.ts.
- Confirm the terminal output contains all todo items.
Then the approval menu appears:
Please choose:
1. Approve and execute
2. Continue modifying the plan
3. Cancel
When choosing 2, the Agent is still in Plan Mode and can only continue reading, searching, and resubmitting the plan.
When choosing 1:
Plan approved. Entered Code Mode.
Created task directory:
.powercode/tasks/20260817-143012000-add-command-line-todo-demo/
The user-specified execution directory will now contain:
workspace/task-board/
├── src/
│ └── ceshi.ts
└── .powercode/
└── tasks/
└── 20260817-143012000-add-command-line-todo-demo/
├── PLAN.md
└── TODO.md
Only now might the following appear:
read_file
.powercode/tasks/20260817-143012000-add-command-line-todo-demo/TODO.md
edit_file
src/ceshi.ts
edit_file
.powercode/tasks/20260817-143012000-add-command-line-todo-demo/TODO.md
bash
node src/ceshi.ts
Finally, the TODO.md in that task directory will look something like:
# Execution Checklist
## Implementation Steps
- [x] Read and retain the existing entry structure
- [x] Add todo data and formatted output
- [x] Run node src/ceshi.ts
- [x] Confirm the terminal output contains all todo items
One thing needs to be honestly stated here:
"Plan Mode cannot write code" is enforced by the Harness; "update TODO upon completing a step" in the first version still mainly relies on the execution prompt.
If we want TODO updates to become a hard constraint in the future, we can add a dedicated todo_update tool, where the Harness records the step status instead of letting the model directly edit the Markdown.
What's the Difference Between /code and "Approve and Execute"
Both will enter Code Mode, but their meanings are different.
/code
Manually exit Plan Mode
Does not mean the plan is approved
Does not automatically generate a TODO
Approve and Execute
Explicitly approves the current Plan
Creates the task directory for this task
Saves PLAN.md and TODO.md
Automatically starts execution
The normal workflow should use "Approve and Execute."
/code is more of an escape hatch: when the user doesn't want to continue planning, they can directly return to normal mode.
Why the First Version Doesn't Implement Shift+Tab
It's not that it can't be done, but that it's not the most important part of this article.
The current CLI is line-by-line input:
await readline.question("> ");
To capture Shift+Tab, you typically need to listen for keypress events and handle the escape sequences sent by the terminal.
No matter how the user switches, the underlying action is ultimately just:
state.enterPlan();
or:
state.enterCode();
Therefore, the correct implementation order is:
First implement the state machine and permission boundaries
↓
Then implement /plan, /code
↓
Finally map Shift+Tab to the same functions
It will be more natural to add shortcuts when we add a full TUI to powercode later.
A Few Easy Pitfalls
1. Writing Plan Mode as a Prompt
If the model can still access write tools, you cannot say the Harness has entered read-only mode.
The correct approach is to simultaneously hide the tool definitions and check the allowed list again before execution.
2. Automatically Executing a Plan Upon Submission
This would make "approval" just interface text.
After submit_plan is called, the status must be switched to:
waiting_for_approval
And then the Agent Loop must be paused.
3. Treating the Plan Checklist as Execution Progress Before Approval
The plan can have a task breakdown with checkboxes, but before user approval, they are still part of the proposal and should not be displayed as "in progress."
This article chooses to generate an independent TODO.md only after approval. If you choose a single-document approach like Codex, you must also distinguish between "pending approval" and "executing" in the state.
4. Giving Plan Mode the Entire Bash
You can't just check if a command starts with cat, grep, find. Pipes, redirections, command substitution, and subprocesses all make string judgment complex.
Using dedicated read-only tools is clearer for the first version.
5. Thinking Plan and TODO Must Be Saved as Two Files
The number of files is not the key; the state boundary is.
You can, like this article, use a stable PLAN.md to save the approved plan and a continuously changing TODO.md to record execution progress; or you can, like Codex, keep a checklist directly in the plan document.
As long as the Harness clearly records "pending approval" and "executing," both approaches are reasonable.
6. Directly Using the Plan Title as a Folder Name
The Plan title is generated by the model and cannot be directly used as a trusted path. Slashes, .., and special characters must first be removed, the length limited, and a timestamp or task ID added to avoid overwriting names.
7. Letting the Model Approve Its Own Plan
The model can generate a plan, but the approval authority belongs to the user. After submit_plan, it must return to the CLI, not send another "please confirm the plan is feasible" for the model to self-judge.
8. Writing Sensitive Information into Plan Files
PLAN.md and TODO.md in .powercode/tasks/ might enter Git. Do not write API Keys, Cookies, access tokens, or complete sensitive logs into them.
If these are just local runtime states, you can add .powercode/ to .gitignore; if the team wants to keep plans as project documentation, then selectively commit them.
What We've Truly Added to the Agent Here
AgentState
Saves mode, status, pending plan, and approved plan
Plan Mode
Only allows reading, listing directories, searching, and submitting plans
submit_plan
Turns ordinary model output into an explicit workflow event
User Approval
Decides to continue modifying, cancel, or switch to execution mode
Task Directory
Generates a safe name based on the Plan title, isolating artifacts for each task
PLAN.md + TODO.md
Respectively save the approved plan and real-time execution progress
The most important change is not the addition of two Markdown files, nor the /plan command.
It's that the Harness has started managing the Agent's work phases for the first time:
Planning Phase
Can only research, cannot execute
Approval Phase
Agent Loop pauses, waiting for human decision
Execution Phase
Restores write tools, completes tasks according to the approved plan
This is the most valuable lesson of Plan Mode:
It's not a prompt saying "please think first," but a read-only, approval, and execution state machine controlled by the Harness.
Preview of the Next Article
Now, powercode can already look at a project first, write a plan, and then follow the TODO step-by-step after we approve it.
But when you actually use it to modify a project, you'll quickly encounter a very practical problem:
bash execution fails
↓
Agent changes a parameter and executes again
↓
Still the same error
↓
Continues repeating until the maximum rounds are exhausted
Sometimes it will also prematurely mark a TODO as complete when the verification hasn't truly passed. Plan Mode can manage "think first before acting," but it can't yet manage how the Agent should self-rescue after a failure.
So in the next article, the seventh of this series, we'll continue to solve:
What to do when AI keeps reporting errors? Teach it to self-rescue instead of spinning in place.
We'll add failure classification, repeated failure detection, and recovery prompts to powercode. So the Harness doesn't just throw the error back to the model as-is, but can tell it: where it failed, whether it's a repeated failure, and what different approach to try next.