pi-better-harness 0.3.2 → 0.3.4
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 +5 -3
- package/extensions/plan/index.ts +1 -0
- package/extensions/ssh/index.ts +1 -0
- package/lib/cli.mjs +2 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +25 -4
- package/node_modules/pi-better-plan/LICENSE +21 -0
- package/node_modules/pi-better-plan/README.md +39 -0
- package/node_modules/pi-better-plan/package.json +73 -0
- package/node_modules/pi-better-plan/src/index.ts +379 -0
- package/node_modules/pi-better-plan/src/plan-render.ts +97 -0
- package/node_modules/pi-better-plan/src/plan-state.ts +154 -0
- package/node_modules/pi-better-plan/src/types.ts +38 -0
- package/node_modules/pi-better-ssh/LICENSE +21 -0
- package/node_modules/pi-better-ssh/README.md +99 -0
- package/node_modules/pi-better-ssh/package.json +57 -0
- package/node_modules/pi-better-ssh/src/index.ts +314 -0
- package/node_modules/pi-better-ssh/src/mux-registry.ts +86 -0
- package/node_modules/pi-better-ssh/src/process-runner.ts +166 -0
- package/node_modules/pi-better-ssh/src/profile.ts +132 -0
- package/node_modules/pi-better-ssh/src/remote-bash.ts +184 -0
- package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +902 -0
- package/node_modules/pi-better-subagents/shared-navigator.ts +25 -4
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-better-harness
|
|
2
2
|
|
|
3
|
-
`pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks,
|
|
3
|
+
`pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, synchronous SSH commands, goal tracking, and structured plans.
|
|
4
4
|
|
|
5
5
|
## Quick Answer
|
|
6
6
|
|
|
@@ -9,9 +9,11 @@ Use `pi-better-harness` when you want the full working set for Pi. It manages:
|
|
|
9
9
|
- `pi-better-sandbox` for an opt-in write sandbox around Pi's foreground tools.
|
|
10
10
|
- `pi-better-subagents` for detached, sandboxed subagent runs.
|
|
11
11
|
- `pi-better-background-tasks` for durable shell tasks and watchers.
|
|
12
|
+
- `pi-better-ssh` for short remote commands over reusable SSH connections.
|
|
12
13
|
- `pi-better-goal` for objective tracking that is aware of background work.
|
|
14
|
+
- `pi-better-plan` for persistent structured plans and explicit checklist progress.
|
|
13
15
|
|
|
14
|
-
`pi-better-
|
|
16
|
+
`pi-better-read-aloud` is intentionally not included yet.
|
|
15
17
|
|
|
16
18
|
## Screenshots
|
|
17
19
|
|
|
@@ -60,7 +62,7 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
|
|
|
60
62
|
|
|
61
63
|
## When To Use
|
|
62
64
|
|
|
63
|
-
Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision,
|
|
65
|
+
Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, synchronous SSH, goal tracking, or plans.
|
|
64
66
|
|
|
65
67
|
## Compatibility
|
|
66
68
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../../node_modules/pi-better-plan/src/index.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../../node_modules/pi-better-ssh/src/index.ts";
|
package/lib/cli.mjs
CHANGED
|
@@ -12,7 +12,9 @@ export const componentPackages = [
|
|
|
12
12
|
"pi-better-sandbox",
|
|
13
13
|
"pi-better-subagents",
|
|
14
14
|
"pi-better-background-tasks",
|
|
15
|
+
"pi-better-ssh",
|
|
15
16
|
"pi-better-goal",
|
|
17
|
+
"pi-better-plan",
|
|
16
18
|
];
|
|
17
19
|
|
|
18
20
|
const usage = `Usage: pi-better-harness <install|uninstall> [--local]
|
|
@@ -121,7 +121,12 @@ function state(): NavigatorState {
|
|
|
121
121
|
return g[GLOBAL_KEY]!;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
type PlanNavigationState = {
|
|
124
|
+
type PlanNavigationState = {
|
|
125
|
+
visible: boolean;
|
|
126
|
+
progressLabel?: string;
|
|
127
|
+
releaseWorkFocus?: () => void;
|
|
128
|
+
refreshNavigationHint?: () => void;
|
|
129
|
+
};
|
|
125
130
|
|
|
126
131
|
function planNavigationState(): PlanNavigationState {
|
|
127
132
|
const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: PlanNavigationState };
|
|
@@ -129,6 +134,10 @@ function planNavigationState(): PlanNavigationState {
|
|
|
129
134
|
return global[PLAN_NAVIGATION_KEY]!;
|
|
130
135
|
}
|
|
131
136
|
|
|
137
|
+
function refreshPlanNavigationHint(): void {
|
|
138
|
+
refreshBackgroundWorkNavigator();
|
|
139
|
+
}
|
|
140
|
+
|
|
132
141
|
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
133
142
|
const s = state();
|
|
134
143
|
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
@@ -170,7 +179,12 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
export function navigatorFooterHint(count: number): string | null {
|
|
173
|
-
|
|
182
|
+
const work = count > 0 ? `← work · ${count}` : null;
|
|
183
|
+
const navigation = planNavigationState();
|
|
184
|
+
const plan = navigation.visible
|
|
185
|
+
? `→ plan${navigation.progressLabel ? ` · ${navigation.progressLabel}` : ""}`
|
|
186
|
+
: null;
|
|
187
|
+
return [work, plan].filter((hint): hint is string => hint !== null).join(" ") || null;
|
|
174
188
|
}
|
|
175
189
|
|
|
176
190
|
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
@@ -194,7 +208,10 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
194
208
|
s.mainListRequestRender = undefined;
|
|
195
209
|
s.mainListDeadlineScheduler?.dispose();
|
|
196
210
|
s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
|
|
197
|
-
planNavigationState()
|
|
211
|
+
const planNavigation = planNavigationState();
|
|
212
|
+
planNavigation.releaseWorkFocus = unfocusMainList;
|
|
213
|
+
planNavigation.refreshNavigationHint = refreshPlanNavigationHint;
|
|
214
|
+
try { (ctx.ui as any).setStatus?.("pi-better-plan-nav", undefined); } catch { /* ignore */ }
|
|
198
215
|
installNavigatorEditor(ctx.ui as any, deps);
|
|
199
216
|
s.lastHint = undefined;
|
|
200
217
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -223,7 +240,11 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
223
240
|
s.mainListRequestRender = undefined;
|
|
224
241
|
s.editorComponent = undefined;
|
|
225
242
|
s.detailOverlayRows = undefined;
|
|
226
|
-
planNavigationState()
|
|
243
|
+
const planNavigation = planNavigationState();
|
|
244
|
+
planNavigation.releaseWorkFocus = undefined;
|
|
245
|
+
if (planNavigation.refreshNavigationHint === refreshPlanNavigationHint) {
|
|
246
|
+
planNavigation.refreshNavigationHint = undefined;
|
|
247
|
+
}
|
|
227
248
|
s.mainListSelectedId = undefined;
|
|
228
249
|
s.mainListFocused = false;
|
|
229
250
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1aboveio
|
|
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.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# pi-better-plan
|
|
2
|
+
|
|
3
|
+
`pi-better-plan` keeps a structured execution plan visible while Pi works.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
|
|
8
|
+
- Shows the complete checklist of completed, active, pending, and blocked steps above the editor.
|
|
9
|
+
- Uses `→` from an empty editor to focus the plan; normal cursor movement is preserved when the editor contains text.
|
|
10
|
+
- Persists plan state and display preferences on the active Pi session branch.
|
|
11
|
+
- Opens the complete plan with `/plan`.
|
|
12
|
+
|
|
13
|
+
Plan progress is checklist progress, not an estimate of effort. The extension never infers completion from prose or successful tool calls.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi install npm:pi-better-plan
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
/plan
|
|
25
|
+
/plan clear
|
|
26
|
+
/plan hide
|
|
27
|
+
/plan show
|
|
28
|
+
/plan pin auto
|
|
29
|
+
/plan pin on
|
|
30
|
+
/plan pin off
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`pin auto` and `pin on` currently use the compact widget until Pi exposes a reserved right-rail extension interface. A floating overlay is intentionally not used as a substitute because it would cover transcript content.
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm run verify
|
|
39
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-better-plan",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Structured execution plans with persistent progress for Pi.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"pi",
|
|
15
|
+
"pi-extension",
|
|
16
|
+
"plan",
|
|
17
|
+
"progress"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/1aboveio/pi-better-harness.git",
|
|
22
|
+
"directory": "packages/pi-better-plan"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/1aboveio/pi-better-harness/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-plan#readme",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"verify": "npm run typecheck && npm test"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*",
|
|
45
|
+
"typebox": "*"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@earendil-works/pi-ai": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@earendil-works/pi-coding-agent": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@earendil-works/pi-tui": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"typebox": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@earendil-works/pi-ai": "^0.82.0",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^0.82.0",
|
|
64
|
+
"@earendil-works/pi-tui": "^0.82.0",
|
|
65
|
+
"@types/node": "^25.0.0",
|
|
66
|
+
"tsx": "^4.22.3",
|
|
67
|
+
"typebox": "^1.1.39",
|
|
68
|
+
"typescript": "^6.0.0"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=22.0.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key, matchesKey, Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
planClearEntry,
|
|
8
|
+
planDisplayEntry,
|
|
9
|
+
planProgress,
|
|
10
|
+
planSetEntry,
|
|
11
|
+
reconstructPlanState,
|
|
12
|
+
replacePlan,
|
|
13
|
+
} from "./plan-state.js";
|
|
14
|
+
import { createFullPlanComponent, renderCompactPlan } from "./plan-render.js";
|
|
15
|
+
import {
|
|
16
|
+
EXTENSION_NAME,
|
|
17
|
+
type PlanDisplayMode,
|
|
18
|
+
type PlanProgress,
|
|
19
|
+
type PlanSnapshot,
|
|
20
|
+
type PlanStepInput,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
interface GetPlanDetails {
|
|
24
|
+
hasPlan: boolean;
|
|
25
|
+
plan: PlanSnapshot | null;
|
|
26
|
+
progress: PlanProgress | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PlanStepSchema = Type.Object({
|
|
30
|
+
step: Type.String({ description: "A concise, verifiable execution step." }),
|
|
31
|
+
status: StringEnum(["pending", "in_progress", "completed", "blocked"] as const),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const UpdatePlanSchema = Type.Object({
|
|
35
|
+
explanation: Type.Optional(Type.String({ description: "Why the plan or its status changed." })),
|
|
36
|
+
plan: Type.Array(PlanStepSchema, { minItems: 1, maxItems: 50 }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
|
|
40
|
+
const PLAN_EDITOR_FACTORY_MARK = "__piBetterPlanFactory";
|
|
41
|
+
const PLAN_EDITOR_FACTORY_REFRESH = "__piBetterPlanRefresh";
|
|
42
|
+
const PLAN_NAVIGATION_KEY = Symbol.for("pi-better-harness.plan-navigation.state");
|
|
43
|
+
|
|
44
|
+
interface SharedPlanNavigationState {
|
|
45
|
+
visible: boolean;
|
|
46
|
+
progressLabel?: string;
|
|
47
|
+
releaseWorkFocus?: () => void;
|
|
48
|
+
refreshNavigationHint?: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sharedPlanNavigationState(): SharedPlanNavigationState {
|
|
52
|
+
const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: SharedPlanNavigationState };
|
|
53
|
+
if (!global[PLAN_NAVIGATION_KEY]) global[PLAN_NAVIGATION_KEY] = { visible: false };
|
|
54
|
+
return global[PLAN_NAVIGATION_KEY]!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function planExtension(pi: ExtensionAPI): void {
|
|
58
|
+
let currentPlan: PlanSnapshot | null = null;
|
|
59
|
+
let displayMode: PlanDisplayMode = "auto";
|
|
60
|
+
let selectedIndex = 0;
|
|
61
|
+
let focused = false;
|
|
62
|
+
let refreshWidget: ((force?: boolean) => void) | undefined;
|
|
63
|
+
let currentCtx: ExtensionContext | undefined;
|
|
64
|
+
|
|
65
|
+
const refresh = (force = false): void => refreshWidget?.(force);
|
|
66
|
+
|
|
67
|
+
const updateNavigationHint = (ctx = currentCtx): void => {
|
|
68
|
+
if (!ctx?.hasUI) return;
|
|
69
|
+
const progress = currentPlan ? planProgress(currentPlan) : null;
|
|
70
|
+
const visible = currentPlan !== null && displayMode !== "hidden";
|
|
71
|
+
const navigation = sharedPlanNavigationState();
|
|
72
|
+
navigation.visible = visible;
|
|
73
|
+
const suffix = progress?.blocked ? "blocked" : progress ? `${progress.completed}/${progress.total}` : "";
|
|
74
|
+
if (suffix) navigation.progressLabel = suffix;
|
|
75
|
+
else delete navigation.progressLabel;
|
|
76
|
+
if (navigation.refreshNavigationHint) {
|
|
77
|
+
ctx.ui.setStatus(PLAN_NAV_STATUS_KEY, undefined);
|
|
78
|
+
navigation.refreshNavigationHint();
|
|
79
|
+
} else {
|
|
80
|
+
ctx.ui.setStatus(PLAN_NAV_STATUS_KEY, visible ? `→ plan · ${suffix}` : undefined);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const restore = (ctx: ExtensionContext): void => {
|
|
85
|
+
const state = reconstructPlanState(ctx.sessionManager.getBranch());
|
|
86
|
+
currentPlan = state.plan;
|
|
87
|
+
displayMode = state.displayMode;
|
|
88
|
+
selectedIndex = preferredIndex(currentPlan);
|
|
89
|
+
focused = false;
|
|
90
|
+
updateNavigationHint(ctx);
|
|
91
|
+
refresh(true);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const persistPlan = (plan: PlanSnapshot): void => {
|
|
95
|
+
currentPlan = plan;
|
|
96
|
+
selectedIndex = preferredIndex(plan);
|
|
97
|
+
focused = false;
|
|
98
|
+
pi.appendEntry(EXTENSION_NAME, planSetEntry(plan));
|
|
99
|
+
updateNavigationHint();
|
|
100
|
+
refresh(true);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const setDisplayMode = (mode: PlanDisplayMode): void => {
|
|
104
|
+
displayMode = mode;
|
|
105
|
+
pi.appendEntry(EXTENSION_NAME, planDisplayEntry(mode));
|
|
106
|
+
updateNavigationHint();
|
|
107
|
+
refresh(true);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const installWidget = (ctx: ExtensionContext): void => {
|
|
111
|
+
if (!ctx.hasUI) return;
|
|
112
|
+
ctx.ui.setWidget(
|
|
113
|
+
EXTENSION_NAME,
|
|
114
|
+
(tui, theme) => {
|
|
115
|
+
const localRefresh = (force = false): void => tui.requestRender(force);
|
|
116
|
+
refreshWidget = localRefresh;
|
|
117
|
+
return {
|
|
118
|
+
render(width: number): string[] {
|
|
119
|
+
if (!currentPlan || displayMode === "hidden") return [];
|
|
120
|
+
return renderCompactPlan(currentPlan, width, theme as never, { focused, selectedIndex });
|
|
121
|
+
},
|
|
122
|
+
invalidate() {},
|
|
123
|
+
dispose() {
|
|
124
|
+
if (refreshWidget === localRefresh) refreshWidget = undefined;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
{ placement: "aboveEditor" },
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const showFullPlan = async (ctx: ExtensionContext): Promise<void> => {
|
|
133
|
+
if (!currentPlan) {
|
|
134
|
+
if (ctx.hasUI) ctx.ui.notify("No plan is set.", "warning");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (ctx.mode !== "tui") {
|
|
138
|
+
ctx.ui.notify(formatPlan(currentPlan), "info");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const snapshot = currentPlan;
|
|
142
|
+
focused = false;
|
|
143
|
+
refresh();
|
|
144
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
145
|
+
const component = createFullPlanComponent(snapshot, theme as never, () => done());
|
|
146
|
+
return {
|
|
147
|
+
render: (width) => component.render(width),
|
|
148
|
+
handleInput(data) {
|
|
149
|
+
component.handleInput?.(data);
|
|
150
|
+
tui.requestRender();
|
|
151
|
+
},
|
|
152
|
+
invalidate: () => component.invalidate(),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const handlePlanNavigation = (data: string, ctx: ExtensionContext): boolean => {
|
|
158
|
+
if (!currentPlan || displayMode === "hidden") return false;
|
|
159
|
+
if (!focused) {
|
|
160
|
+
if (!matchesKey(data, Key.right)) return false;
|
|
161
|
+
sharedPlanNavigationState().releaseWorkFocus?.();
|
|
162
|
+
focused = true;
|
|
163
|
+
selectedIndex = preferredIndex(currentPlan);
|
|
164
|
+
refresh();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (matchesKey(data, Key.up)) {
|
|
168
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
169
|
+
refresh();
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
if (matchesKey(data, Key.down)) {
|
|
173
|
+
selectedIndex = Math.min(currentPlan.steps.length - 1, selectedIndex + 1);
|
|
174
|
+
refresh();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|
|
178
|
+
void showFullPlan(ctx);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.escape)) {
|
|
182
|
+
focused = false;
|
|
183
|
+
refresh();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
focused = false;
|
|
187
|
+
refresh();
|
|
188
|
+
return false;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const installEditorNavigation = (ctx: ExtensionContext): void => {
|
|
192
|
+
if (ctx.mode !== "tui") return;
|
|
193
|
+
const ui = ctx.ui as typeof ctx.ui & {
|
|
194
|
+
getEditorComponent?(): ((tui: unknown, theme: unknown, keybindings: unknown) => unknown) | undefined;
|
|
195
|
+
setEditorComponent(factory: (tui: unknown, theme: unknown, keybindings: unknown) => unknown): void;
|
|
196
|
+
};
|
|
197
|
+
const previous = ui.getEditorComponent?.() as
|
|
198
|
+
| (((tui: unknown, theme: unknown, keybindings: unknown) => unknown) & Record<string, unknown>)
|
|
199
|
+
| undefined;
|
|
200
|
+
const handlers = { handle: (data: string) => handlePlanNavigation(data, ctx) };
|
|
201
|
+
if (previous?.[PLAN_EDITOR_FACTORY_MARK] === true) {
|
|
202
|
+
const replaceHandlers = previous[PLAN_EDITOR_FACTORY_REFRESH];
|
|
203
|
+
if (typeof replaceHandlers === "function") replaceHandlers(handlers);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let currentHandlers = handlers;
|
|
207
|
+
const factory = ((tui: unknown, theme: unknown, keybindings: unknown) => {
|
|
208
|
+
const inner = (previous
|
|
209
|
+
? previous(tui, theme, keybindings)
|
|
210
|
+
: new CustomEditor(tui as never, theme as never, keybindings as never)) as {
|
|
211
|
+
getText?(): string;
|
|
212
|
+
handleInput(data: string): void;
|
|
213
|
+
};
|
|
214
|
+
return new Proxy(inner, {
|
|
215
|
+
get(target, property) {
|
|
216
|
+
if (property === "handleInput") {
|
|
217
|
+
return (data: string) => {
|
|
218
|
+
if (target.getText?.() === "" && currentHandlers.handle(data)) return;
|
|
219
|
+
target.handleInput(data);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const value = Reflect.get(target, property);
|
|
223
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}) as ((tui: unknown, theme: unknown, keybindings: unknown) => unknown) & Record<string, unknown>;
|
|
227
|
+
factory[PLAN_EDITOR_FACTORY_MARK] = true;
|
|
228
|
+
factory[PLAN_EDITOR_FACTORY_REFRESH] = (next: typeof handlers) => { currentHandlers = next; };
|
|
229
|
+
ui.setEditorComponent(factory);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
pi.registerTool({
|
|
233
|
+
name: "update_plan",
|
|
234
|
+
label: "Update Plan",
|
|
235
|
+
description: "Create or atomically replace the current structured execution plan and its step statuses.",
|
|
236
|
+
promptSnippet: "Create and update a persistent structured execution plan",
|
|
237
|
+
promptGuidelines: [
|
|
238
|
+
"Use update_plan for work with three or more meaningful steps, and update it immediately when a step completes, becomes blocked, or scope changes.",
|
|
239
|
+
"Keep at most one update_plan step in_progress and do not mark a step completed until its required verification succeeds.",
|
|
240
|
+
],
|
|
241
|
+
parameters: UpdatePlanSchema,
|
|
242
|
+
async execute(_toolCallId, params) {
|
|
243
|
+
const input = params as { explanation?: string; plan: PlanStepInput[] };
|
|
244
|
+
const plan = replacePlan(currentPlan, input.plan, input.explanation);
|
|
245
|
+
persistPlan(plan);
|
|
246
|
+
const progress = planProgress(plan);
|
|
247
|
+
return {
|
|
248
|
+
content: [{ type: "text", text: formatProgress(plan) }],
|
|
249
|
+
details: { ok: true, plan, progress },
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
renderCall(args, theme) {
|
|
253
|
+
return new Text(
|
|
254
|
+
theme.fg("toolTitle", theme.bold("update_plan ")) + theme.fg("muted", `${args.plan.length} steps`),
|
|
255
|
+
0,
|
|
256
|
+
0,
|
|
257
|
+
);
|
|
258
|
+
},
|
|
259
|
+
renderResult(result, _options, theme) {
|
|
260
|
+
const text = result.content[0];
|
|
261
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text?.type === "text" ? text.text : "Plan updated"), 0, 0);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
pi.registerTool({
|
|
266
|
+
name: "get_plan",
|
|
267
|
+
label: "Get Plan",
|
|
268
|
+
description: "Inspect the current structured execution plan and derived checklist progress.",
|
|
269
|
+
promptSnippet: "Inspect the current structured execution plan and progress",
|
|
270
|
+
parameters: Type.Object({}),
|
|
271
|
+
async execute() {
|
|
272
|
+
if (!currentPlan) {
|
|
273
|
+
return {
|
|
274
|
+
content: [{ type: "text", text: "No plan is set." }],
|
|
275
|
+
details: { hasPlan: false, plan: null, progress: null } as GetPlanDetails,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const progress = planProgress(currentPlan);
|
|
279
|
+
return {
|
|
280
|
+
content: [{ type: "text", text: formatPlan(currentPlan) }],
|
|
281
|
+
details: { hasPlan: true, plan: currentPlan, progress } as GetPlanDetails,
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
pi.registerCommand("plan", {
|
|
287
|
+
description: "Inspect, show, hide, clear, or configure the current plan",
|
|
288
|
+
handler: async (args, ctx) => {
|
|
289
|
+
const input = args.trim().toLowerCase();
|
|
290
|
+
if (!input) return showFullPlan(ctx);
|
|
291
|
+
if (input === "clear") {
|
|
292
|
+
currentPlan = null;
|
|
293
|
+
focused = false;
|
|
294
|
+
pi.appendEntry(EXTENSION_NAME, planClearEntry());
|
|
295
|
+
updateNavigationHint(ctx);
|
|
296
|
+
refresh(true);
|
|
297
|
+
ctx.ui.notify("Plan cleared.", "info");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (input === "hide") {
|
|
301
|
+
setDisplayMode("hidden");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (input === "show") {
|
|
305
|
+
setDisplayMode("auto");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const pinMode = input.match(/^pin\s+(auto|on|off)$/)?.[1] as PlanDisplayMode | undefined;
|
|
309
|
+
if (pinMode) {
|
|
310
|
+
setDisplayMode(pinMode);
|
|
311
|
+
ctx.ui.notify(`Plan pin mode: ${pinMode}.`, "info");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
ctx.ui.notify("Usage: /plan [clear|hide|show|pin auto|pin on|pin off]", "warning");
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
319
|
+
currentCtx = ctx;
|
|
320
|
+
installWidget(ctx);
|
|
321
|
+
installEditorNavigation(ctx);
|
|
322
|
+
restore(ctx);
|
|
323
|
+
});
|
|
324
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
325
|
+
currentCtx = ctx;
|
|
326
|
+
restore(ctx);
|
|
327
|
+
});
|
|
328
|
+
pi.on("before_agent_start", async (event) => {
|
|
329
|
+
if (!currentPlan) return;
|
|
330
|
+
return {
|
|
331
|
+
systemPrompt: `${event.systemPrompt}\n\n${planPrompt(currentPlan)}`,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
335
|
+
refreshWidget = undefined;
|
|
336
|
+
currentCtx = undefined;
|
|
337
|
+
const navigation = sharedPlanNavigationState();
|
|
338
|
+
navigation.visible = false;
|
|
339
|
+
delete navigation.progressLabel;
|
|
340
|
+
navigation.refreshNavigationHint?.();
|
|
341
|
+
try {
|
|
342
|
+
ctx.ui.setStatus(PLAN_NAV_STATUS_KEY, undefined);
|
|
343
|
+
ctx.ui.setWidget(EXTENSION_NAME, undefined);
|
|
344
|
+
} catch { /* best-effort cleanup */ }
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function preferredIndex(plan: PlanSnapshot | null): number {
|
|
349
|
+
if (!plan) return 0;
|
|
350
|
+
const progress = planProgress(plan);
|
|
351
|
+
if (progress.activeIndex !== null) return progress.activeIndex;
|
|
352
|
+
const next = plan.steps.findIndex((item) => item.status !== "completed");
|
|
353
|
+
return next >= 0 ? next : Math.max(0, plan.steps.length - 1);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function formatProgress(plan: PlanSnapshot): string {
|
|
357
|
+
const progress = planProgress(plan);
|
|
358
|
+
const current = progress.activeIndex === null ? null : plan.steps[progress.activeIndex];
|
|
359
|
+
const status = [`Plan updated: ${progress.completed}/${progress.total} steps completed`];
|
|
360
|
+
if (progress.blocked > 0) status.push(`${progress.blocked} blocked`);
|
|
361
|
+
if (current) status.push(`Current step: ${current.step}`);
|
|
362
|
+
return status.join(". ") + ".";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatPlan(plan: PlanSnapshot): string {
|
|
366
|
+
const progress = planProgress(plan);
|
|
367
|
+
return [
|
|
368
|
+
`Plan: ${progress.completed}/${progress.total} steps completed${progress.blocked ? `, ${progress.blocked} blocked` : ""}`,
|
|
369
|
+
...plan.steps.map((item, index) => `${index + 1}. [${item.status}] ${item.step}`),
|
|
370
|
+
].join("\n");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function planPrompt(plan: PlanSnapshot): string {
|
|
374
|
+
return [
|
|
375
|
+
"Current structured execution plan:",
|
|
376
|
+
...plan.steps.map((item, index) => `${index + 1}. [${item.status}] ${item.step}`),
|
|
377
|
+
"Use update_plan immediately when a step completes, becomes blocked, or the scope changes. Completion must be explicit and evidence-backed.",
|
|
378
|
+
].join("\n");
|
|
379
|
+
}
|