@tt-a1i/openpi 0.1.0
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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-mode: explore read-only, then get explicit approval before writing.
|
|
3
|
+
*
|
|
4
|
+
* `/plan` arms a session flag; while armed, a `tool_call` handler allows only
|
|
5
|
+
* explicitly read-only tools and tells the model to finish planning instead.
|
|
6
|
+
* `bash` is judged per command rather than as a whole (see `bash-policy.ts`),
|
|
7
|
+
* because history and diffs are what a plan is grounded in.
|
|
8
|
+
* `/plan done` asks the model to finalize through `plan_ready`; that explicit
|
|
9
|
+
* terminating tool records the complete plan while keeping the write gate
|
|
10
|
+
* closed. A later `/plan` lets the user continue planning or prefill an
|
|
11
|
+
* implementation prompt in this session or a fresh linked session.
|
|
12
|
+
*
|
|
13
|
+
* SCOPE, deliberately stated: a `tool_call` handler gates only THIS
|
|
14
|
+
* interactive session. It cannot reach a headless subagent's or a workflow
|
|
15
|
+
* child's writes — those run in their own sessions.
|
|
16
|
+
*
|
|
17
|
+
* Delegation is still allowed, because that limitation is answered elsewhere:
|
|
18
|
+
* `subagent_spawn` narrows a child to `PLAN_MODE_CHILD_TOOLS` (see
|
|
19
|
+
* `../shared/plan-mode-state.ts`), and a child tool allowlist is enforced by
|
|
20
|
+
* the harness rather than by prompt. Blocking it outright was the first
|
|
21
|
+
* answer and it was the wrong trade: parallel read-only exploration, kept out
|
|
22
|
+
* of the main context, is one of the most useful things to do while planning.
|
|
23
|
+
* `workflow` stays blocked because `agent_type` is optional and the DSL has
|
|
24
|
+
* no run-wide plan-mode narrowing: an untyped or implementation call may
|
|
25
|
+
* still write. `subagent_send` stays blocked because it resumes a child that
|
|
26
|
+
* may predate the plan and still hold the full tool set.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
ExtensionAPI,
|
|
31
|
+
ExtensionCommandContext,
|
|
32
|
+
ExtensionContext,
|
|
33
|
+
} from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { Type } from "typebox";
|
|
35
|
+
import {
|
|
36
|
+
PLAN_MODE_CHANNEL,
|
|
37
|
+
type PlanModeState,
|
|
38
|
+
} from "../shared/plan-mode-state.ts";
|
|
39
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
40
|
+
import { planBashDecision } from "./bash-policy.ts";
|
|
41
|
+
|
|
42
|
+
export const MAX_READY_PLAN_CHARS = 50_000;
|
|
43
|
+
export const MAX_READY_PLAN_UTF8_BYTES = 48_000;
|
|
44
|
+
export const PLAN_MODE_STATE_ENTRY = "my-pi-setup-plan-mode-state";
|
|
45
|
+
|
|
46
|
+
export type PersistedPlanModeState =
|
|
47
|
+
| { version: 1; status: "inactive" | "planning" }
|
|
48
|
+
| { version: 1; status: "ready"; plan: string };
|
|
49
|
+
|
|
50
|
+
export interface RestoredPlanModeState {
|
|
51
|
+
planning: boolean;
|
|
52
|
+
readyPlan?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isBoundedReadyPlan(value: unknown): value is string {
|
|
61
|
+
return (
|
|
62
|
+
typeof value === "string" &&
|
|
63
|
+
sanitizeTerminalText(value) === value &&
|
|
64
|
+
value.trim().length > 0 &&
|
|
65
|
+
value.length <= MAX_READY_PLAN_CHARS &&
|
|
66
|
+
new TextEncoder().encode(value.trim()).byteLength <=
|
|
67
|
+
MAX_READY_PLAN_UTF8_BYTES
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decodePlanModeState(data: unknown): RestoredPlanModeState | undefined {
|
|
72
|
+
if (
|
|
73
|
+
!isRecord(data) ||
|
|
74
|
+
data.version !== 1 ||
|
|
75
|
+
typeof data.status !== "string"
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const keys = Object.keys(data).sort().join(",");
|
|
80
|
+
if (data.status === "inactive" || data.status === "planning") {
|
|
81
|
+
if (keys !== "status,version") return;
|
|
82
|
+
return { planning: data.status === "planning" };
|
|
83
|
+
}
|
|
84
|
+
if (data.status === "ready" && keys === "plan,status,version") {
|
|
85
|
+
if (!isBoundedReadyPlan(data.plan)) return;
|
|
86
|
+
return { planning: true, readyPlan: data.plan.trim() };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function latestAssistantToolCallCount(entries: readonly unknown[]) {
|
|
91
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
92
|
+
const entry = entries[index];
|
|
93
|
+
if (!isRecord(entry) || entry.type !== "message") continue;
|
|
94
|
+
const message = entry.message;
|
|
95
|
+
if (!isRecord(message) || message.role !== "assistant") continue;
|
|
96
|
+
if (!Array.isArray(message.content)) return 0;
|
|
97
|
+
return message.content.filter(
|
|
98
|
+
(part) => isRecord(part) && part.type === "toolCall",
|
|
99
|
+
).length;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function planReadyBatchDecision(toolName: string, callCount: number) {
|
|
105
|
+
if (toolName !== "plan_ready" || callCount === 1) return;
|
|
106
|
+
return {
|
|
107
|
+
block: true as const,
|
|
108
|
+
reason:
|
|
109
|
+
"plan_ready must be the only tool call in its assistant message so Pi can terminate planning deterministically. Retry it alone after the other tool results return.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Restore only the newest branch-local state; malformed state fails closed. */
|
|
114
|
+
export function restorePlanModeState(
|
|
115
|
+
entries: readonly unknown[],
|
|
116
|
+
): RestoredPlanModeState {
|
|
117
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
118
|
+
const entry = entries[index];
|
|
119
|
+
if (
|
|
120
|
+
!isRecord(entry) ||
|
|
121
|
+
entry.type !== "custom" ||
|
|
122
|
+
entry.customType !== PLAN_MODE_STATE_ENTRY
|
|
123
|
+
) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
decodePlanModeState(entry.data) ?? {
|
|
128
|
+
planning: true,
|
|
129
|
+
error:
|
|
130
|
+
"The latest persisted Plan Mode state is malformed; writes remain blocked. Use `/plan off` to clear it explicitly.",
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return { planning: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const PLAN_READY_ACTIONS = {
|
|
138
|
+
continue: "Continue planning",
|
|
139
|
+
current: "Implement in this session",
|
|
140
|
+
fresh: "Start a fresh session",
|
|
141
|
+
} as const;
|
|
142
|
+
|
|
143
|
+
export function buildPlanImplementationPrompt(plan: string) {
|
|
144
|
+
return [
|
|
145
|
+
"Implement the approved plan below. Re-check the repository state before editing, follow the project instructions, and verify the finished change.",
|
|
146
|
+
"",
|
|
147
|
+
"--- APPROVED PLAN ---",
|
|
148
|
+
plan,
|
|
149
|
+
"--- END APPROVED PLAN ---",
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fail-closed allow-list for planning. Unknown and newly installed tools are
|
|
155
|
+
* blocked until they are deliberately classified here as observational.
|
|
156
|
+
* `bash` is absent from this set on purpose but is NOT blocked outright:
|
|
157
|
+
* whether an arbitrary command is read-only cannot be decided by tool name, so
|
|
158
|
+
* it is decided one level down, per command, by `planBashDecision`.
|
|
159
|
+
*/
|
|
160
|
+
export const PLAN_SAFE_TOOLS = new Set([
|
|
161
|
+
// Local repository investigation.
|
|
162
|
+
"read",
|
|
163
|
+
"grep",
|
|
164
|
+
"find",
|
|
165
|
+
"ls",
|
|
166
|
+
"fd",
|
|
167
|
+
"rg",
|
|
168
|
+
// Web research without write actions.
|
|
169
|
+
"web_search",
|
|
170
|
+
"source_check",
|
|
171
|
+
"fetch_content",
|
|
172
|
+
"get_search_content",
|
|
173
|
+
// Read-only inspection of work already in flight.
|
|
174
|
+
"bg_status",
|
|
175
|
+
"bg_list",
|
|
176
|
+
"bg_watch",
|
|
177
|
+
"subagent_check",
|
|
178
|
+
"subagent_list",
|
|
179
|
+
"subagent_wait",
|
|
180
|
+
"workflow_status",
|
|
181
|
+
// Delegated investigation. Exploring several subsystems in parallel without
|
|
182
|
+
// dragging the noise into the main context is one of the most useful things
|
|
183
|
+
// to do while planning, so spawning is allowed — but only because the
|
|
184
|
+
// child's tool allowlist is enforced by the harness. See
|
|
185
|
+
// `plan-mode-state.ts`: subagents narrows a planning child to
|
|
186
|
+
// PLAN_MODE_CHILD_TOOLS, which has no write, edit, or bash in it. A
|
|
187
|
+
// tool_call handler could never gate a child's writes itself, since the
|
|
188
|
+
// child runs in its own session.
|
|
189
|
+
//
|
|
190
|
+
// `subagent_send` is NOT here for the same reason: it resumes an existing
|
|
191
|
+
// child's session, and one started before `/plan` was armed still holds the
|
|
192
|
+
// full tool set. Narrowing applies at spawn, so only spawning is safe.
|
|
193
|
+
"subagent_spawn",
|
|
194
|
+
// Advisory state reads and an explicit user clarification.
|
|
195
|
+
"tasks_list",
|
|
196
|
+
"get_goal",
|
|
197
|
+
"ask_user",
|
|
198
|
+
// The model's explicit, terminating transition from planning to ready.
|
|
199
|
+
"plan_ready",
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
export const BLOCK_REASON =
|
|
203
|
+
"Plan mode is active: no changes yet. Keep investigating with read-only tools (read, fd, rg, web search, read-only bash like git log/diff/status, and subagent_spawn for parallel exploration — planning children get read-only tools). When the plan is decision-complete, call plan_ready alone with the complete Markdown plan; the user then chooses the next action with `/plan`, or cancels with `/plan off`.";
|
|
204
|
+
|
|
205
|
+
export function planToolCallDecision(
|
|
206
|
+
toolName: string,
|
|
207
|
+
input?: Record<string, unknown>,
|
|
208
|
+
) {
|
|
209
|
+
// Creating a worktree changes Git metadata before the child even starts;
|
|
210
|
+
// delegation is safe in plan mode only in the existing checkout.
|
|
211
|
+
if (toolName === "subagent_spawn" && input?.isolation === "worktree") {
|
|
212
|
+
return {
|
|
213
|
+
block: true as const,
|
|
214
|
+
reason: `Plan mode cannot create an isolated worktree. Omit isolation for read-only delegation. ${BLOCK_REASON}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (PLAN_SAFE_TOOLS.has(toolName)) return;
|
|
218
|
+
// bash is decided per command, not per tool: the read-only investigation a
|
|
219
|
+
// plan is built on (history, diffs, PR state) lives behind it.
|
|
220
|
+
if (toolName === "bash") {
|
|
221
|
+
const decision = planBashDecision(input?.command);
|
|
222
|
+
if (decision.allowed) return;
|
|
223
|
+
return {
|
|
224
|
+
block: true as const,
|
|
225
|
+
reason: `${decision.reason ?? "plan mode could not verify this command is read-only"}. ${BLOCK_REASON}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { block: true as const, reason: BLOCK_REASON };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export default function planMode(pi: ExtensionAPI) {
|
|
232
|
+
let planning = false;
|
|
233
|
+
let readyPlan: string | undefined;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Publish the stance and reflect it in the footer. Every place `planning`
|
|
237
|
+
* changes goes through here, because a subagent spawned while the broadcast
|
|
238
|
+
* was stale would be a child with write tools during a plan — the one thing
|
|
239
|
+
* this extension exists to prevent.
|
|
240
|
+
*/
|
|
241
|
+
const setStatus = (ctx: {
|
|
242
|
+
hasUI: boolean;
|
|
243
|
+
ui: { setStatus: (key: string, value?: string) => void };
|
|
244
|
+
}) => {
|
|
245
|
+
pi.events.emit(PLAN_MODE_CHANNEL, { planning } satisfies PlanModeState);
|
|
246
|
+
if (!ctx.hasUI) return;
|
|
247
|
+
ctx.ui.setStatus(
|
|
248
|
+
"plan-mode",
|
|
249
|
+
readyPlan
|
|
250
|
+
? "plan mode · ready"
|
|
251
|
+
: planning
|
|
252
|
+
? "plan mode · read-only"
|
|
253
|
+
: undefined,
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const commitPlanState = (
|
|
258
|
+
state: PersistedPlanModeState,
|
|
259
|
+
ctx: {
|
|
260
|
+
hasUI: boolean;
|
|
261
|
+
ui: { setStatus: (key: string, value?: string) => void };
|
|
262
|
+
},
|
|
263
|
+
) => {
|
|
264
|
+
// Persist before mutating memory: a failed append must never open the gate
|
|
265
|
+
// or discard a ready plan only in RAM.
|
|
266
|
+
pi.appendEntry(PLAN_MODE_STATE_ENTRY, state);
|
|
267
|
+
planning = state.status !== "inactive";
|
|
268
|
+
readyPlan = state.status === "ready" ? state.plan : undefined;
|
|
269
|
+
setStatus(ctx);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const clearPlan = (ctx: {
|
|
273
|
+
hasUI: boolean;
|
|
274
|
+
ui: { setStatus: (key: string, value?: string) => void };
|
|
275
|
+
}) => {
|
|
276
|
+
commitPlanState({ version: 1, status: "inactive" }, ctx);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const continuePlanning = (ctx: ExtensionCommandContext) => {
|
|
280
|
+
commitPlanState({ version: 1, status: "planning" }, ctx);
|
|
281
|
+
ctx.ui.notify(
|
|
282
|
+
"Still in plan mode. Enter the revision you want; writes remain blocked.",
|
|
283
|
+
"info",
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const implementHere = (ctx: ExtensionCommandContext) => {
|
|
288
|
+
if (!readyPlan) {
|
|
289
|
+
ctx.ui.notify("No ready plan is available.", "warning");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!ctx.hasUI) {
|
|
293
|
+
ctx.ui.notify(
|
|
294
|
+
"Implementing a ready plan requires an interactive editor.",
|
|
295
|
+
"warning",
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const prompt = buildPlanImplementationPrompt(readyPlan);
|
|
300
|
+
ctx.ui.setEditorText(prompt);
|
|
301
|
+
clearPlan(ctx);
|
|
302
|
+
ctx.ui.notify(
|
|
303
|
+
"Plan mode is off. Review the implementation prompt, then submit it when ready.",
|
|
304
|
+
"info",
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const implementFresh = async (ctx: ExtensionCommandContext) => {
|
|
309
|
+
if (!readyPlan) {
|
|
310
|
+
ctx.ui.notify("No ready plan is available.", "warning");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!ctx.hasUI) {
|
|
314
|
+
ctx.ui.notify(
|
|
315
|
+
"Starting a fresh implementation requires an interactive editor.",
|
|
316
|
+
"warning",
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const prompt = buildPlanImplementationPrompt(readyPlan);
|
|
321
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
322
|
+
await ctx.waitForIdle();
|
|
323
|
+
const result = await ctx.newSession({
|
|
324
|
+
...(parentSession ? { parentSession } : {}),
|
|
325
|
+
withSession: async (nextCtx) => {
|
|
326
|
+
nextCtx.ui.setEditorText(prompt);
|
|
327
|
+
nextCtx.ui.notify(
|
|
328
|
+
"Fresh implementation session ready. Review the prompt, then submit it when ready.",
|
|
329
|
+
"info",
|
|
330
|
+
);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
if (result.cancelled) {
|
|
334
|
+
ctx.ui.notify(
|
|
335
|
+
"Fresh session cancelled; the plan is still ready.",
|
|
336
|
+
"info",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const showReadyActions = async (ctx: ExtensionCommandContext) => {
|
|
342
|
+
if (!readyPlan) {
|
|
343
|
+
ctx.ui.notify("No ready plan is available.", "warning");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!ctx.hasUI) {
|
|
347
|
+
ctx.ui.notify(
|
|
348
|
+
"Use `/plan implement`, `/plan fresh`, or `/plan off` in a UI session.",
|
|
349
|
+
"warning",
|
|
350
|
+
);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const choice = await ctx.ui.select(
|
|
354
|
+
"Plan Ready — choose what happens next",
|
|
355
|
+
Object.values(PLAN_READY_ACTIONS),
|
|
356
|
+
);
|
|
357
|
+
if (choice === PLAN_READY_ACTIONS.continue) {
|
|
358
|
+
continuePlanning(ctx);
|
|
359
|
+
} else if (choice === PLAN_READY_ACTIONS.current) {
|
|
360
|
+
implementHere(ctx);
|
|
361
|
+
} else if (choice === PLAN_READY_ACTIONS.fresh) {
|
|
362
|
+
await implementFresh(ctx);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
pi.registerTool({
|
|
367
|
+
name: "plan_ready",
|
|
368
|
+
label: "Plan Ready",
|
|
369
|
+
description:
|
|
370
|
+
"Finish an active Plan Mode workflow with the complete implementation-ready Markdown plan. Call it alone as the final action only after research and material user decisions are complete.",
|
|
371
|
+
promptSnippet:
|
|
372
|
+
"Complete active Plan Mode with one explicit implementation-ready plan",
|
|
373
|
+
promptGuidelines: [
|
|
374
|
+
"When Plan Mode is active and the plan is decision-complete, call plan_ready alone as the final action with the complete Markdown plan; do not infer approval or begin implementation.",
|
|
375
|
+
],
|
|
376
|
+
parameters: Type.Object({
|
|
377
|
+
plan: Type.String({
|
|
378
|
+
minLength: 1,
|
|
379
|
+
maxLength: MAX_READY_PLAN_CHARS,
|
|
380
|
+
description: "The complete implementation-ready Markdown plan",
|
|
381
|
+
}),
|
|
382
|
+
}),
|
|
383
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
384
|
+
if (signal?.aborted) {
|
|
385
|
+
return {
|
|
386
|
+
content: [
|
|
387
|
+
{ type: "text" as const, text: "Plan completion cancelled." },
|
|
388
|
+
],
|
|
389
|
+
details: { status: "cancelled" as const },
|
|
390
|
+
terminate: true,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (!planning) {
|
|
394
|
+
throw new Error("plan_ready requires active Plan Mode.");
|
|
395
|
+
}
|
|
396
|
+
const plan = sanitizeTerminalText(params.plan).trim();
|
|
397
|
+
if (!plan) throw new Error("plan_ready requires a non-empty plan.");
|
|
398
|
+
if (
|
|
399
|
+
new TextEncoder().encode(plan).byteLength > MAX_READY_PLAN_UTF8_BYTES
|
|
400
|
+
) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`plan_ready plans must be at most ${MAX_READY_PLAN_UTF8_BYTES} UTF-8 bytes.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
commitPlanState({ version: 1, status: "ready", plan }, ctx);
|
|
406
|
+
ctx.ui.notify(
|
|
407
|
+
"Plan ready. Run `/plan` to continue planning or prepare implementation.",
|
|
408
|
+
"info",
|
|
409
|
+
);
|
|
410
|
+
return {
|
|
411
|
+
content: [
|
|
412
|
+
{
|
|
413
|
+
type: "text" as const,
|
|
414
|
+
text: `Plan ready for explicit user action. No implementation has started.\n\n${plan}`,
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
details: { status: "ready" as const, plan },
|
|
418
|
+
terminate: true,
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
pi.registerCommand("plan", {
|
|
424
|
+
description:
|
|
425
|
+
"Plan read-only, then use an explicit Plan Ready action: `/plan`, `/plan done`, `/plan implement`, `/plan fresh`, `/plan off`",
|
|
426
|
+
handler: async (rawArgs, ctx) => {
|
|
427
|
+
const action = rawArgs.trim().toLowerCase();
|
|
428
|
+
|
|
429
|
+
if (action === "off" || action === "cancel") {
|
|
430
|
+
clearPlan(ctx);
|
|
431
|
+
ctx.ui.notify("Plan mode off. No implementation was started.", "info");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (action === "implement" || action === "current") {
|
|
436
|
+
implementHere(ctx);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (action === "fresh") {
|
|
441
|
+
await implementFresh(ctx);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (action === "done" || action === "approve") {
|
|
446
|
+
if (readyPlan) {
|
|
447
|
+
await showReadyActions(ctx);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (!planning) {
|
|
451
|
+
ctx.ui.notify("Plan mode is not active.", "warning");
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
pi.sendMessage(
|
|
455
|
+
{
|
|
456
|
+
customType: "plan-finalize-requested",
|
|
457
|
+
content:
|
|
458
|
+
"Finalize the plan now. Resolve any remaining material ambiguity with ask_user; otherwise call plan_ready alone with the complete implementation-ready Markdown plan. Do not implement it.",
|
|
459
|
+
display: true,
|
|
460
|
+
details: {},
|
|
461
|
+
},
|
|
462
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
463
|
+
);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (readyPlan && !action) {
|
|
468
|
+
await showReadyActions(ctx);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (planning) {
|
|
473
|
+
ctx.ui.notify(
|
|
474
|
+
"Plan mode is already active. `/plan done` requests completion; `/plan off` cancels.",
|
|
475
|
+
"info",
|
|
476
|
+
);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
commitPlanState({ version: 1, status: "planning" }, ctx);
|
|
481
|
+
const objective = rawArgs.trim();
|
|
482
|
+
pi.sendMessage(
|
|
483
|
+
{
|
|
484
|
+
customType: "plan-mode-armed",
|
|
485
|
+
content:
|
|
486
|
+
`Plan mode is on: investigate and propose a plan, but do not change anything yet. Edits and writes are blocked until the user explicitly chooses an implementation action. For files use the read, ls, grep and fd tools; bash is limited to read-only git and gh history commands such as \`git log\`, \`git diff\`, \`git status\`, \`git show\`, \`git blame\` and \`gh pr view\` — one plain command, no pipes or redirects. You can still delegate with \`subagent_spawn\` to explore several parts of the codebase at once: children spawned while planning receive read-only tools, so use them for investigation rather than for work to be done later.${objective ? `\n\nPlan for: ${objective}` : ""}` +
|
|
487
|
+
"\n\nUse read-only tools to ground the plan. When it is decision-complete, call `plan_ready` alone with the complete Markdown plan. That records the plan but does not start implementation; the user chooses the next action with `/plan`.",
|
|
488
|
+
display: true,
|
|
489
|
+
details: {},
|
|
490
|
+
},
|
|
491
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
492
|
+
);
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
pi.on("tool_call", (event, ctx) => {
|
|
497
|
+
if (!planning) return;
|
|
498
|
+
if (readyPlan) {
|
|
499
|
+
return {
|
|
500
|
+
block: true as const,
|
|
501
|
+
reason:
|
|
502
|
+
"The plan is ready and the write gate remains closed. Wait for the user to choose the next action with `/plan`; do not call more tools.",
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const batchDecision =
|
|
506
|
+
event.toolName === "plan_ready"
|
|
507
|
+
? planReadyBatchDecision(
|
|
508
|
+
event.toolName,
|
|
509
|
+
latestAssistantToolCallCount(ctx.sessionManager.getBranch()),
|
|
510
|
+
)
|
|
511
|
+
: undefined;
|
|
512
|
+
if (batchDecision) return batchDecision;
|
|
513
|
+
return planToolCallDecision(event.toolName, event.input);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const restoreRuntimeState = (ctx: ExtensionContext) => {
|
|
517
|
+
const restored = restorePlanModeState(ctx.sessionManager.getBranch());
|
|
518
|
+
planning = restored.planning;
|
|
519
|
+
readyPlan = restored.readyPlan;
|
|
520
|
+
setStatus(ctx);
|
|
521
|
+
if (restored.error) ctx.ui.notify(restored.error, "error");
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
pi.on("session_start", (_event, ctx) => {
|
|
525
|
+
restoreRuntimeState(ctx);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
529
|
+
restoreRuntimeState(ctx);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
pi.on("session_shutdown", () => {
|
|
533
|
+
planning = false;
|
|
534
|
+
readyPlan = undefined;
|
|
535
|
+
// Broadcast without a ctx: subagents keeps its own copy of the stance, and
|
|
536
|
+
// leaving it armed would restrict children in whatever session comes next.
|
|
537
|
+
pi.events.emit(PLAN_MODE_CHANNEL, { planning } satisfies PlanModeState);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* post-edit: run ONE configured command after a turn with successful Write/Edit operations.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not an event-hook engine. The trust surface is a single
|
|
5
|
+
* user-typed command string in the package config, off (empty) by default. The
|
|
6
|
+
* tool_result handler only flips a flag — it never awaits or executes, so it
|
|
7
|
+
* cannot slow or wedge the tool pipeline. Execution happens once per turn on
|
|
8
|
+
* agent_settled, which debounces an edit burst into a single run, and is
|
|
9
|
+
* fire-and-forget so a failing command cannot block the session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ExtensionAPI,
|
|
14
|
+
ExtensionContext,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
loadSetupConfig,
|
|
18
|
+
SETUP_CONFIG_CHANGED_CHANNEL,
|
|
19
|
+
} from "../shared/setup-config.ts";
|
|
20
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
21
|
+
|
|
22
|
+
/** Tools whose success means a file on disk changed. */
|
|
23
|
+
const MUTATING_TOOLS = new Set(["write", "edit"]);
|
|
24
|
+
|
|
25
|
+
export default function postEdit(
|
|
26
|
+
pi: ExtensionAPI,
|
|
27
|
+
loadCommand: () => string = () => loadSetupConfig().postEdit.command,
|
|
28
|
+
) {
|
|
29
|
+
let command = loadCommand();
|
|
30
|
+
let filesChanged = false;
|
|
31
|
+
let pendingRuns = 0;
|
|
32
|
+
let generation = 0;
|
|
33
|
+
let active: { controller: AbortController } | undefined;
|
|
34
|
+
|
|
35
|
+
// Re-read on change, matching the sibling extensions' pattern.
|
|
36
|
+
pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, () => {
|
|
37
|
+
command = loadCommand();
|
|
38
|
+
if (!command) pendingRuns = 0;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const runNext = (ctx: ExtensionContext, runGeneration: number) => {
|
|
42
|
+
if (
|
|
43
|
+
runGeneration !== generation ||
|
|
44
|
+
active ||
|
|
45
|
+
pendingRuns === 0 ||
|
|
46
|
+
!command
|
|
47
|
+
) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
pendingRuns--;
|
|
51
|
+
const ran = command;
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
active = { controller };
|
|
54
|
+
|
|
55
|
+
// Notification is best-effort: the context can go stale (session change,
|
|
56
|
+
// shutdown) while the command runs, and a throwing notify must not become
|
|
57
|
+
// an unhandled rejection that takes the process down.
|
|
58
|
+
const warn = (message: string) => {
|
|
59
|
+
if (generation !== runGeneration) return;
|
|
60
|
+
try {
|
|
61
|
+
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
62
|
+
ctx.ui.notify(sanitizeTerminalText(message), "warning");
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Nothing better to do — the session that would have shown it is gone.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Fire-and-forget: never block settlement on the command. Runs are drained
|
|
70
|
+
// serially so two closely settled changed turns cannot lose the latter.
|
|
71
|
+
void pi
|
|
72
|
+
.exec("sh", ["-c", ran], {
|
|
73
|
+
cwd: ctx.cwd,
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
})
|
|
76
|
+
.then((result) => {
|
|
77
|
+
if (result.code === 0) return;
|
|
78
|
+
const detail = sanitizeTerminalText(
|
|
79
|
+
result.stderr || result.stdout || "",
|
|
80
|
+
)
|
|
81
|
+
.trim()
|
|
82
|
+
.slice(0, 500);
|
|
83
|
+
warn(
|
|
84
|
+
`post-edit command failed (exit ${result.code}): ${sanitizeTerminalText(ran)}${detail ? `\n${detail}` : ""}`,
|
|
85
|
+
);
|
|
86
|
+
})
|
|
87
|
+
.catch((error: unknown) => {
|
|
88
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
89
|
+
warn(`post-edit command could not run: ${detail}`);
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
if (active?.controller === controller) active = undefined;
|
|
93
|
+
if (generation === runGeneration) runNext(ctx, runGeneration);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
pi.on("tool_result", (event) => {
|
|
98
|
+
// Hot path: only a boolean flip. No await, no exec, no config read that
|
|
99
|
+
// could throw — anything heavier here would tax every tool call.
|
|
100
|
+
if (event.isError) return;
|
|
101
|
+
if (MUTATING_TOOLS.has(event.toolName)) filesChanged = true;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
pi.on("agent_settled", (_event, ctx: ExtensionContext) => {
|
|
105
|
+
if (!filesChanged) return;
|
|
106
|
+
filesChanged = false;
|
|
107
|
+
// `hasUI` is also true in headless RPC mode. This command is intentionally
|
|
108
|
+
// limited to the interactive terminal session that configured it.
|
|
109
|
+
if (ctx.mode !== "tui" || !command) return;
|
|
110
|
+
pendingRuns++;
|
|
111
|
+
runNext(ctx, generation);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.on("session_start", () => {
|
|
115
|
+
generation++;
|
|
116
|
+
filesChanged = false;
|
|
117
|
+
pendingRuns = 0;
|
|
118
|
+
active?.controller.abort();
|
|
119
|
+
active = undefined;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("session_shutdown", () => {
|
|
123
|
+
generation++;
|
|
124
|
+
filesChanged = false;
|
|
125
|
+
pendingRuns = 0;
|
|
126
|
+
active?.controller.abort();
|
|
127
|
+
active = undefined;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|