@letta-ai/letta-code 0.27.5 → 0.27.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/letta.js +372 -95
- package/package.json +1 -1
- package/skills/creating-extensions/SKILL.md +6 -2
- package/skills/creating-extensions/references/commands.md +1 -1
- package/skills/creating-extensions/references/events.md +3 -17
- package/skills/creating-extensions/references/permissions.md +90 -0
- package/skills/creating-extensions/references/plan-mode.md +285 -0
- package/skills/creating-extensions/references/providers.md +50 -148
- package/skills/customizing-commands/SKILL.md +2 -2
- package/skills/creating-extensions/references/btw-command.md +0 -106
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ Capabilities vary by surface. TUI/headless may load tools, commands, events, UI,
|
|
|
26
26
|
| Show transient output above input | Panel, usually from a command |
|
|
27
27
|
| Show small persistent state | Status value |
|
|
28
28
|
| React to app/session lifecycle or transform outbound turns | Event |
|
|
29
|
+
| Enforce dynamic allow/ask/deny policy for tool calls | Permission overlay |
|
|
29
30
|
| Add a custom model/API provider for local agents | Provider extension (local agents only) |
|
|
30
31
|
| Change the bottom statusline appearance | Use `customizing-statusline`, not this skill |
|
|
31
32
|
|
|
@@ -40,8 +41,9 @@ Default to a **tool** when the model should decide when to use the capability. D
|
|
|
40
41
|
- commands: `references/commands.md`
|
|
41
42
|
- local custom providers: `references/providers.md`
|
|
42
43
|
- events: `references/events.md`
|
|
44
|
+
- permissions: `references/permissions.md`
|
|
43
45
|
- panels/status/capabilities: `references/ui.md`
|
|
44
|
-
-
|
|
46
|
+
- complex plan-mode composition: `references/plan-mode.md`
|
|
45
47
|
4. For multi-capability or stateful extensions, also read `references/architecture.md`.
|
|
46
48
|
5. Write a single-file extension unless the user asks for something larger.
|
|
47
49
|
6. Return disposers for registered providers/commands/tools/events, timers, subscriptions, and panels that should close on reload.
|
|
@@ -76,6 +78,7 @@ letta.capabilities.commands
|
|
|
76
78
|
letta.capabilities.events.lifecycle
|
|
77
79
|
letta.capabilities.events.tools
|
|
78
80
|
letta.capabilities.events.turns
|
|
81
|
+
letta.capabilities.permissions
|
|
79
82
|
letta.capabilities.providers
|
|
80
83
|
letta.capabilities.ui.panels
|
|
81
84
|
letta.capabilities.ui.statusValues
|
|
@@ -140,6 +143,7 @@ Before finishing, verify:
|
|
|
140
143
|
| `references/commands.md` | The human should invoke `/foo` |
|
|
141
144
|
| `references/providers.md` | Adding a custom model/API provider for local agents |
|
|
142
145
|
| `references/events.md` | Reacting to lifecycle/tool/turn events or transforming turns/tools |
|
|
146
|
+
| `references/permissions.md` | Enforcing dynamic tool allow/ask/deny policy before approval/execution |
|
|
143
147
|
| `references/ui.md` | Panels, status values, or statusline capability guards are involved |
|
|
144
|
-
| `references/
|
|
148
|
+
| `references/plan-mode.md` | Recreating plan mode with commands, tools, events, permissions, and local state |
|
|
145
149
|
| `references/architecture.md` | Multiple capabilities, local state, cleanup, background model work, or non-trivial composition |
|
|
@@ -125,4 +125,4 @@ const stream = await forked.sendMessageStream([
|
|
|
125
125
|
|
|
126
126
|
Do not send directly to the active conversation from a busy command; fork first unless the user explicitly asked to affect the main conversation later.
|
|
127
127
|
|
|
128
|
-
For a
|
|
128
|
+
For a worked multi-capability extension that combines commands, tools, events, permissions, and local state, see `plan-mode.md`.
|
|
@@ -6,7 +6,7 @@ Use events when trusted local code should react to app/session changes or transf
|
|
|
6
6
|
|
|
7
7
|
- Capabilities
|
|
8
8
|
- Supported events
|
|
9
|
-
- Tool argument transforms
|
|
9
|
+
- Tool argument transforms
|
|
10
10
|
- Turn input transforms
|
|
11
11
|
- Event handler context
|
|
12
12
|
- Conversation status example
|
|
@@ -44,7 +44,7 @@ letta.events.on("event_name", (event, ctx) => {
|
|
|
44
44
|
});
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Tool events use this same API.
|
|
47
|
+
Tool events use this same API. Use `letta.permissions.register` for allow/ask/deny policy; use `tool_start` for last-mile argument transforms and lifecycle reactions.
|
|
48
48
|
|
|
49
49
|
```ts
|
|
50
50
|
letta.events.on("tool_start", (event, ctx) => {
|
|
@@ -115,7 +115,7 @@ Lifecycle handlers are notification-only and should not return values. `turn_sta
|
|
|
115
115
|
}
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
`tool_start` fires immediately before a client-side tool executes. This includes built-in tools, extension tools, and external tools executed through the local tool manager. It runs after permission/approval classification and before `PreToolUse` hooks, so trusted local extensions can change the actual executed arguments after the approval UI has already classified the original request.
|
|
118
|
+
`tool_start` fires immediately before a client-side tool executes. This includes built-in tools, extension tools, and external tools executed through the local tool manager. It runs after permission/approval classification and before `PreToolUse` hooks, so trusted local extensions can change the actual executed arguments after the approval UI has already classified the original request. Extension permission overlays are rechecked after `tool_start` on the final args.
|
|
119
119
|
|
|
120
120
|
Handlers can inspect `event.args`, mutate it directly, or return replacement args:
|
|
121
121
|
|
|
@@ -138,20 +138,6 @@ Handlers run in registration order. Later handlers see the current args after ea
|
|
|
138
138
|
|
|
139
139
|
`tool_start` is intentionally a trusted local extension point: it can rewrite commands, file paths, and other tool inputs before execution. Keep transforms focused and unsurprising.
|
|
140
140
|
|
|
141
|
-
### Denying tool execution
|
|
142
|
-
|
|
143
|
-
Handlers can deny a tool by returning `{ deny: true, reason?: "..." }`. All handlers still run (for side effects like logging or state updates), but if any handler denies, the tool is blocked. The first denial reason is shown to the model as the tool error message.
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
letta.events.on("tool_start", (event) => {
|
|
147
|
-
if (event.toolName === "Bash" && String(event.args.command).includes("rm -rf")) {
|
|
148
|
-
return { deny: true, reason: "Destructive command blocked." };
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Denial runs before `PreToolUse` hooks. If an extension denies a tool, hooks are not invoked for that tool call.
|
|
154
|
-
|
|
155
141
|
`turn_start` fires before outbound turns that include a user message. In the TUI this includes normal submits and prompt-style command turns. In headless it includes one-shot prompts and bidirectional user turns.
|
|
156
142
|
|
|
157
143
|
Handlers can mutate `event.input` directly or return replacement input:
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Extension permission recipes
|
|
2
|
+
|
|
3
|
+
Use permission overlays when trusted local code should participate in tool approval decisions. Prefer permissions over `tool_start` denial for policy: permissions run before approval UI and again before execution on final tool arguments.
|
|
4
|
+
|
|
5
|
+
## Capability
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
letta.capabilities.permissions
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Guard registrations when writing portable extensions:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export default function activate(letta) {
|
|
15
|
+
if (!letta.capabilities.permissions) return;
|
|
16
|
+
|
|
17
|
+
return letta.permissions.register({
|
|
18
|
+
id: "plan-mode",
|
|
19
|
+
description: "Allow read-only tools and writes only to the active plan file.",
|
|
20
|
+
check(event) {
|
|
21
|
+
if (!isPlanModeActive(event.conversationId)) return;
|
|
22
|
+
|
|
23
|
+
if (isReadOnlyTool(event.toolName, event.args)) {
|
|
24
|
+
return { decision: "allow" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (isActivePlanFileWrite(event.toolName, event.args)) {
|
|
28
|
+
return { decision: "allow", reason: "active plan file" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
decision: "deny",
|
|
33
|
+
reason: "Plan mode is active. You can only read files or update the plan file.",
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Event shape
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
{
|
|
44
|
+
agentId: string | null;
|
|
45
|
+
conversationId: string | null;
|
|
46
|
+
toolCallId: string | null;
|
|
47
|
+
toolName: string;
|
|
48
|
+
args: Record<string, unknown>;
|
|
49
|
+
cwd: string;
|
|
50
|
+
workingDirectory: string;
|
|
51
|
+
permissionMode: string | null;
|
|
52
|
+
phase: "approval" | "execution";
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Return values
|
|
57
|
+
|
|
58
|
+
Return one of:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
{ decision: "allow", reason?: string }
|
|
62
|
+
{ decision: "ask", reason?: string }
|
|
63
|
+
{ decision: "deny", reason?: string }
|
|
64
|
+
undefined // no opinion
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Composition rules across overlays:
|
|
68
|
+
|
|
69
|
+
- `deny` wins
|
|
70
|
+
- then `ask`
|
|
71
|
+
- then `allow`
|
|
72
|
+
- `undefined` means no opinion
|
|
73
|
+
|
|
74
|
+
User/configured hard denials still win before extension overlays. Extension overlays can override normal auto-allow/default approval behavior, including unrestricted/yolo mode.
|
|
75
|
+
|
|
76
|
+
## Two phases
|
|
77
|
+
|
|
78
|
+
Permission overlays run during approval classification (`phase: "approval"`) and again immediately before execution (`phase: "execution"`) after any `tool_start` argument transforms.
|
|
79
|
+
|
|
80
|
+
During execution, `ask` cannot reopen approval yet, so use `deny` for execution-time blocking behavior.
|
|
81
|
+
|
|
82
|
+
## Rules
|
|
83
|
+
|
|
84
|
+
- Keep checks fast and deterministic.
|
|
85
|
+
- Return `undefined` when the overlay is not active for the current conversation/state.
|
|
86
|
+
- Prefer path-scoped allow rules over broad allow rules.
|
|
87
|
+
- Do not mutate `event.args`; use `tool_start` for argument transforms.
|
|
88
|
+
- For policy decisions, prefer this API over `tool_start` denial.
|
|
89
|
+
|
|
90
|
+
For a complete worked example that uses permission overlays for plan-mode enforcement, see `plan-mode.md`.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# Plan mode extension example
|
|
2
|
+
|
|
3
|
+
Use this as the canonical multi-capability extension example. It composes a slash command, model-callable tools, turn reminders, permission overlays, and local state to recreate the old built-in plan-mode flow with extension APIs.
|
|
4
|
+
|
|
5
|
+
This is a pattern reference, not a full product implementation. Keep local extensions self-contained and avoid importing Letta Code internals.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- Flow
|
|
10
|
+
- Capabilities used
|
|
11
|
+
- State
|
|
12
|
+
- Entry command and tool
|
|
13
|
+
- Turn reminder
|
|
14
|
+
- Permission overlay
|
|
15
|
+
- Exit tool
|
|
16
|
+
- Notes
|
|
17
|
+
|
|
18
|
+
## Flow
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
/plan or enter_plan_mode
|
|
22
|
+
-> create ~/.letta/plans/<random>.md
|
|
23
|
+
-> remember active plan state for this conversation
|
|
24
|
+
-> remind the agent that only read-only tools and plan-file writes are allowed
|
|
25
|
+
-> permission overlay denies mutations outside ~/.letta/plans/*.md
|
|
26
|
+
-> agent writes the plan with normal Write/Edit/ApplyPatch tools
|
|
27
|
+
-> agent reads the plan and calls AskUserQuestion with Approve / Revise
|
|
28
|
+
-> if approved, agent calls exit_plan_mode
|
|
29
|
+
-> exit_plan_mode clears state and returns the approved-plan execution handoff
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Plan files are normal markdown files. Do not add a special `update_plan_file` tool unless the user explicitly wants that abstraction. Let the agent use normal write tools and constrain those tools with permissions.
|
|
33
|
+
|
|
34
|
+
## Capabilities used
|
|
35
|
+
|
|
36
|
+
Guard each registration with the matching capability:
|
|
37
|
+
|
|
38
|
+
- `commands`: `/plan` for explicit human entry
|
|
39
|
+
- `tools`: `enter_plan_mode` and `exit_plan_mode` for model-driven entry/exit
|
|
40
|
+
- `events.turns`: append a focused plan-mode reminder while active
|
|
41
|
+
- `permissions`: block mutating tools except plan-file writes
|
|
42
|
+
|
|
43
|
+
Do not use panels for persistent mode state. Panels are transient UI and can be noisy/fragile for mode indicators. Do not add a custom statusline renderer just to show plan mode; `setStatuslineRenderer` is a single global renderer, not an additive slot. This example intentionally keeps visible mode state out of scope.
|
|
44
|
+
|
|
45
|
+
## State
|
|
46
|
+
|
|
47
|
+
Use small local state under `~/.letta/extensions/`, keyed by conversation ID:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { homedir } from "node:os";
|
|
52
|
+
import { join, relative } from "node:path";
|
|
53
|
+
|
|
54
|
+
const PLANS_DIR = join(homedir(), ".letta", "plans");
|
|
55
|
+
const STATE_PATH = join(homedir(), ".letta", "extensions", "plan-mode.state.json");
|
|
56
|
+
const GLOBAL_CONVERSATION_ID = "__global__";
|
|
57
|
+
|
|
58
|
+
type PlanSession = {
|
|
59
|
+
conversationId: string;
|
|
60
|
+
planFilePath: string;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
cwd: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type PlanState = { sessions: Record<string, PlanSession> };
|
|
66
|
+
|
|
67
|
+
function conversationKey(id: string | null | undefined): string {
|
|
68
|
+
return id || GLOBAL_CONVERSATION_ID;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readState(): PlanState {
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(STATE_PATH)) return { sessions: {} };
|
|
74
|
+
const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8"));
|
|
75
|
+
return parsed?.sessions ? { sessions: parsed.sessions } : { sessions: {} };
|
|
76
|
+
} catch {
|
|
77
|
+
return { sessions: {} };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeState(state: PlanState): void {
|
|
82
|
+
mkdirSync(join(homedir(), ".letta", "extensions"), { recursive: true });
|
|
83
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Generate plan paths under `~/.letta/plans/`. The old built-in used random adjective/adjective/noun names like `zesty-dazzling-coral.md`; any collision-resistant readable name is fine.
|
|
88
|
+
|
|
89
|
+
## Entry command and tool
|
|
90
|
+
|
|
91
|
+
`/plan` and `enter_plan_mode` should call the same activation helper. The command returns a prompt/system reminder so the agent receives the path. The tool returns the same text as a tool result.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
function buildEnterPlanModeMessage(session, cwd) {
|
|
95
|
+
const relativePatchPath = relative(cwd, session.planFilePath).replace(/\\/g, "/");
|
|
96
|
+
return `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.
|
|
97
|
+
|
|
98
|
+
In plan mode, you should:
|
|
99
|
+
1. Thoroughly explore the codebase to understand existing patterns
|
|
100
|
+
2. Identify similar features and architectural approaches
|
|
101
|
+
3. Consider multiple approaches and their trade-offs
|
|
102
|
+
4. Use AskUserQuestion if you need to clarify the approach
|
|
103
|
+
5. Design a concrete implementation strategy
|
|
104
|
+
6. When ready, write the plan to the plan file, use AskUserQuestion to present the full plan for approval, and call exit_plan_mode after the user approves
|
|
105
|
+
|
|
106
|
+
Remember: DO NOT write or edit any files except the plan file. This is a read-only exploration and planning phase.
|
|
107
|
+
|
|
108
|
+
Plan file path: ${session.planFilePath}
|
|
109
|
+
If using apply_patch, use this exact relative patch path: ${relativePatchPath}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default function activate(letta) {
|
|
113
|
+
const disposers = [];
|
|
114
|
+
|
|
115
|
+
if (letta.capabilities.commands) {
|
|
116
|
+
disposers.push(letta.commands.register({
|
|
117
|
+
id: "plan",
|
|
118
|
+
description: "Enter plan mode",
|
|
119
|
+
override: true,
|
|
120
|
+
run(ctx) {
|
|
121
|
+
const session = activatePlanMode(ctx.conversation.id, ctx.cwd);
|
|
122
|
+
return {
|
|
123
|
+
type: "prompt",
|
|
124
|
+
systemReminder: true,
|
|
125
|
+
content: buildEnterPlanModeMessage(session, ctx.cwd),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (letta.capabilities.tools) {
|
|
132
|
+
disposers.push(letta.tools.register({
|
|
133
|
+
name: "enter_plan_mode",
|
|
134
|
+
description:
|
|
135
|
+
"Enter plan mode before a non-trivial implementation task. Use this for new features, multi-file changes, architectural decisions, unclear requirements, or tasks where the user should approve the approach before implementation.",
|
|
136
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
137
|
+
requiresApproval: true,
|
|
138
|
+
parallelSafe: false,
|
|
139
|
+
run(ctx) {
|
|
140
|
+
const session = activatePlanMode(ctx.conversation.id, ctx.cwd);
|
|
141
|
+
return buildEnterPlanModeMessage(session, ctx.cwd);
|
|
142
|
+
},
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return () => disposers.reverse().forEach((dispose) => dispose());
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Turn reminder
|
|
151
|
+
|
|
152
|
+
Append a reminder while plan mode is active. Keep it narrow and explicit about the allowed plan-file exception:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
function buildActiveReminder(session, cwd) {
|
|
156
|
+
const relativePatchPath = relative(cwd, session.planFilePath).replace(/\\/g, "/");
|
|
157
|
+
return `<system-reminder>
|
|
158
|
+
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
|
|
159
|
+
1. Answer the user's query comprehensively, using the AskUserQuestion tool if you need to ask the user clarifying questions.
|
|
160
|
+
2. Write your implementation plan to the plan file. Plan file path: ${session.planFilePath}
|
|
161
|
+
3. If using apply_patch, use this exact relative path in patch headers: ${relativePatchPath}
|
|
162
|
+
4. When the plan is complete, read the plan file and present the full plan to the user with AskUserQuestion. The question should offer at least "Approve" and "Revise" options.
|
|
163
|
+
5. If the user approves, call exit_plan_mode immediately. If the user asks to revise, stay in plan mode and update the plan file.
|
|
164
|
+
Do NOT make any file changes outside the plan file or run any tools that modify the system state until the user has approved the plan and you have called exit_plan_mode.
|
|
165
|
+
</system-reminder>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (letta.capabilities.events.turns) {
|
|
169
|
+
disposers.push(letta.events.on("turn_start", (event) => {
|
|
170
|
+
const session = getSession(event.conversationId);
|
|
171
|
+
if (!session) return;
|
|
172
|
+
return { input: [{ role: "user", content: buildActiveReminder(session, session.cwd) }, ...event.input] };
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Permission overlay
|
|
178
|
+
|
|
179
|
+
Use a permission overlay, not `tool_start`, for policy. Normalize tool names by family; UI display names and provider-specific tool names drift (`Read`, `read`, `read_file`, `ReadFile`, `SearchFileContent`, etc.).
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const readOnlyToolNames = new Set([
|
|
183
|
+
"askuserquestion",
|
|
184
|
+
"ask_user_question",
|
|
185
|
+
"glob",
|
|
186
|
+
"grep",
|
|
187
|
+
"listdir",
|
|
188
|
+
"list_directory",
|
|
189
|
+
"ls",
|
|
190
|
+
"read",
|
|
191
|
+
"read_file",
|
|
192
|
+
"readfile",
|
|
193
|
+
"search",
|
|
194
|
+
"search_file_content",
|
|
195
|
+
"skill",
|
|
196
|
+
"taskoutput",
|
|
197
|
+
"update_plan",
|
|
198
|
+
"view_image",
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
function normalizedToolName(toolName) {
|
|
202
|
+
return toolName.replace(/[\s-]/g, "").toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isReadOnlyToolName(toolName) {
|
|
206
|
+
const raw = toolName.toLowerCase();
|
|
207
|
+
return readOnlyToolNames.has(raw) || readOnlyToolNames.has(normalizedToolName(toolName));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isPlanFileWrite(toolName, args, cwd) {
|
|
211
|
+
// For Write/Edit-style tools, check file_path/path/notebook_path.
|
|
212
|
+
// For ApplyPatch-style tools, parse *** Add/Update/Delete File and *** Move to directives.
|
|
213
|
+
// Allow only if every target resolves to a .md file under ~/.letta/plans/.
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (letta.capabilities.permissions) {
|
|
217
|
+
disposers.push(letta.permissions.register({
|
|
218
|
+
id: "plan-mode",
|
|
219
|
+
description: "Allow read-only tools and writes only to ~/.letta/plans/*.md while plan mode is active.",
|
|
220
|
+
check(event) {
|
|
221
|
+
const session = getSession(event.conversationId);
|
|
222
|
+
if (!session) return;
|
|
223
|
+
|
|
224
|
+
if (isReadOnlyToolName(event.toolName)) return { decision: "allow" };
|
|
225
|
+
if (isPlanFileWrite(event.toolName, event.args, event.workingDirectory || event.cwd)) {
|
|
226
|
+
return { decision: "allow", reason: "plan file" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
decision: "deny",
|
|
231
|
+
reason:
|
|
232
|
+
`Plan mode is active. You can only use read-only tools (Read, Grep, Glob, etc.) and write to the plan file. ` +
|
|
233
|
+
`Write your plan to: ${session.planFilePath}. ` +
|
|
234
|
+
`Use AskUserQuestion when your plan is ready for user approval, then call exit_plan_mode after approval.`,
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Shell allowlists are easy to get wrong. Start conservative: allow clearly read-only shell commands if needed, plus a narrow plan-file heredoc or `mv old.md new.md` only when every target is inside `~/.letta/plans/*.md`. Deny mutating shell patterns such as `sed -i`, `find -delete`, `rm`, `cp`, `touch`, package installs, and arbitrary interpreters.
|
|
242
|
+
|
|
243
|
+
## Exit tool
|
|
244
|
+
|
|
245
|
+
In the extension version, `exit_plan_mode` is not the approval UI. The agent should present the plan with `AskUserQuestion` first, then call `exit_plan_mode` only after the user approves.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
if (letta.capabilities.tools) {
|
|
249
|
+
disposers.push(letta.tools.register({
|
|
250
|
+
name: "exit_plan_mode",
|
|
251
|
+
description:
|
|
252
|
+
"Exit plan mode only after the plan file has been written, the full plan has been presented with AskUserQuestion, and the user has approved it.",
|
|
253
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
254
|
+
requiresApproval: false,
|
|
255
|
+
parallelSafe: false,
|
|
256
|
+
run(ctx) {
|
|
257
|
+
const session = getSession(ctx.conversation.id);
|
|
258
|
+
if (!session) {
|
|
259
|
+
return { status: "error", content: "Plan mode is not active for this conversation." };
|
|
260
|
+
}
|
|
261
|
+
if (!planFileExists(session)) {
|
|
262
|
+
return {
|
|
263
|
+
status: "error",
|
|
264
|
+
content:
|
|
265
|
+
`You must write your plan to a plan file before exiting plan mode.\n` +
|
|
266
|
+
`Plan file path: ${session.planFilePath}\n` +
|
|
267
|
+
`Use a write tool to create your plan in ${PLANS_DIR}, then use AskUserQuestion to present the plan to the user.`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
clearSession(ctx.conversation.id);
|
|
272
|
+
return (
|
|
273
|
+
"User has approved your plan. You can now start coding.\n" +
|
|
274
|
+
"Start with updating your todo list if applicable.\n\n" +
|
|
275
|
+
"Tip: If this plan will be referenced in the future by your future-self, other agents, or humans, consider renaming the plan file to something easily identifiable with a timestamp (e.g., `2026-01-auth-refactor.md`) rather than the random name."
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Notes
|
|
283
|
+
|
|
284
|
+
- Keep `exit_plan_mode` as the final state transition and execution handoff. The approved-plan text in its tool return is useful model context.
|
|
285
|
+
- If the user renames the plan file, exit logic can use the newest non-empty `~/.letta/plans/*.md` modified after plan mode started, or accept an optional plan path. Keep the user-facing flow normal: write plan file, ask approval, then exit.
|