pi-better-harness 0.35.3 → 0.37.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 +14 -2
- package/extensions/shared-navigator-panels.ts +250 -0
- package/extensions/task-context/index.ts +4 -2
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/index.ts +2 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator-panels.ts +250 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +5 -5
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +5 -2
- package/node_modules/pi-better-goal/src/shared-navigator-panels.ts +250 -0
- package/node_modules/pi-better-subagents/README.md +1 -0
- package/node_modules/pi-better-subagents/agent-commands.ts +33 -0
- package/node_modules/pi-better-subagents/agent-inspection.ts +6 -0
- package/node_modules/pi-better-subagents/agent-session-settings.ts +42 -13
- package/node_modules/pi-better-subagents/catalog-preferences.ts +139 -0
- package/node_modules/pi-better-subagents/catalog-resolver.ts +3 -3
- package/node_modules/pi-better-subagents/catalog-store.ts +187 -1
- package/node_modules/pi-better-subagents/catalog-ui.ts +112 -15
- package/node_modules/pi-better-subagents/cleanup.ts +448 -23
- package/node_modules/pi-better-subagents/continuation.ts +944 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +14 -5
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +22 -2
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +4 -2
- package/node_modules/pi-better-subagents/docs/usage.md +55 -2
- package/node_modules/pi-better-subagents/index.ts +367 -38
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +66 -4
- package/node_modules/pi-better-subagents/roles/role.developer.md +3 -1
- package/node_modules/pi-better-subagents/shared-navigator-panels.ts +250 -0
- package/node_modules/pi-better-subagents/shared-navigator.ts +5 -5
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -180,6 +180,13 @@ to the `/agents` catalog. `/sandbox`, `/subagents settings`, `/agents`, and
|
|
|
180
180
|
`/goal settings` remain available in standalone installations.
|
|
181
181
|
Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
|
|
182
182
|
|
|
183
|
+
**Sticky navigator** controls the shared navigator used by Goal, Outcome & Todo,
|
|
184
|
+
Subagents, and Background Tasks. **On** (the default) keeps it above the input;
|
|
185
|
+
**Off** places it in the conversation so it scrolls with chat. Changes apply
|
|
186
|
+
immediately and save under `piBetterHarness.navigator` for future sessions.
|
|
187
|
+
Package controls and detail views remain available in either mode. Standalone
|
|
188
|
+
packages use the same shared navigator and saved preference.
|
|
189
|
+
|
|
183
190
|
The hub includes **Tool output** and **Tool animation** when the bundled renderer
|
|
184
191
|
extension is loaded. Changes apply immediately and save the default for future
|
|
185
192
|
sessions. The animation preference is stored in `piBetterHarness.toolAnimation`;
|
|
@@ -205,8 +212,13 @@ Updates preserve Pi's own settings and other packages' choices. SSH profiles,
|
|
|
205
212
|
plans, run records, and role/agent definitions remain in their existing stores:
|
|
206
213
|
they are session data or reusable definitions, not global UI defaults.
|
|
207
214
|
In `/agents`, model and effort edits are session-local until Ctrl+S saves them
|
|
208
|
-
to the user or project
|
|
209
|
-
|
|
215
|
+
to the user or trusted project's `agents/settings.json`. Markdown definitions
|
|
216
|
+
remain unchanged, so bundled prompt updates keep applying. The Settings,
|
|
217
|
+
Instructions, and Files tabs keep configuration separate from prompts.
|
|
218
|
+
**Reset to bundled** in Settings restores model/effort without changing the
|
|
219
|
+
prompt; in Instructions, it restores the prompt while preserving model/effort.
|
|
220
|
+
A user Markdown override is archived when its prompt is reset. Saved defaults
|
|
221
|
+
survive sessions and upgrades.
|
|
210
222
|
|
|
211
223
|
Next-prompt inference is deferred pending safe public auth/header resolution in
|
|
212
224
|
Pi's SDK. It is not loaded by the bundle, has no toggle or preference store in
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Generated from packages/navigator/panels.ts. Do not edit directly.
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { readHarnessSetting, updateHarnessSetting } from "./shared-harness-settings.js";
|
|
6
|
+
|
|
7
|
+
export const NAVIGATOR_PANEL_WIDGET_KEY = "background-work-list";
|
|
8
|
+
export const NAVIGATOR_ENTRY_TYPE = "pi-better-harness-navigator-panels";
|
|
9
|
+
export const NAVIGATOR_STICKY_SETTING_ID = "navigator-sticky";
|
|
10
|
+
type MouseEvent = { type: string; button: string; x: number; y: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean };
|
|
11
|
+
type PanelComponent = Component & { dispose?(): void; handleMouse?(event: MouseEvent): { handled: true; render: boolean } | undefined };
|
|
12
|
+
export type NavigatorPanelFactory = (tui: TUI, theme: Theme) => PanelComponent;
|
|
13
|
+
type Panel = { factory: NavigatorPanelFactory; component?: PanelComponent | undefined };
|
|
14
|
+
type Preference = { version: 1; sticky: boolean };
|
|
15
|
+
type Owner = { pi: ExtensionAPI; stop?: (() => void) | undefined };
|
|
16
|
+
type State = {
|
|
17
|
+
panels: Map<string, Panel>;
|
|
18
|
+
owners: Map<ExtensionAPI, Owner>;
|
|
19
|
+
ctx?: ExtensionContext | undefined;
|
|
20
|
+
ui?: ExtensionContext["ui"] | undefined;
|
|
21
|
+
pi?: ExtensionAPI | undefined;
|
|
22
|
+
sticky: boolean;
|
|
23
|
+
mounted: boolean;
|
|
24
|
+
tui?: TUI | undefined;
|
|
25
|
+
theme?: Theme | undefined;
|
|
26
|
+
anchor?: string | undefined;
|
|
27
|
+
pendingAnchor: boolean;
|
|
28
|
+
generation: number;
|
|
29
|
+
ranges: Array<{ start: number; end: number; component: PanelComponent }>;
|
|
30
|
+
control?: { id: string; label: string; values: string[]; get(): string; change(value: string, ctx: ExtensionContext): void } | undefined;
|
|
31
|
+
};
|
|
32
|
+
const KEY = Symbol.for("pi-better-harness.navigator-panels.state");
|
|
33
|
+
function state(): State {
|
|
34
|
+
const global = globalThis as typeof globalThis & { [KEY]?: State };
|
|
35
|
+
return global[KEY] ??= { panels: new Map(), owners: new Map(), sticky: true, mounted: false, pendingAnchor: false, generation: 0, ranges: [] };
|
|
36
|
+
}
|
|
37
|
+
function parsePreference(value: unknown): Preference {
|
|
38
|
+
if (value === undefined) return { version: 1, sticky: true };
|
|
39
|
+
const preference = value as Partial<Preference> | null;
|
|
40
|
+
if (!preference || preference.version !== 1 || typeof preference.sticky !== "boolean") {
|
|
41
|
+
throw new Error("Navigator preference must contain version 1 and a boolean sticky value.");
|
|
42
|
+
}
|
|
43
|
+
return { version: 1, sticky: preference.sticky };
|
|
44
|
+
}
|
|
45
|
+
function canScroll(pi: ExtensionAPI | undefined): boolean {
|
|
46
|
+
return typeof pi?.registerEntryRenderer === "function" && typeof pi?.appendEntry === "function";
|
|
47
|
+
}
|
|
48
|
+
function isTui(ctx: ExtensionContext | undefined): ctx is ExtensionContext {
|
|
49
|
+
return ctx?.mode === "tui" && ctx.hasUI && typeof ctx.ui?.setWidget === "function";
|
|
50
|
+
}
|
|
51
|
+
function disposePanels(s: State): void {
|
|
52
|
+
for (const panel of s.panels.values()) {
|
|
53
|
+
try { panel.component?.dispose?.(); } catch { /* stale UI cleanup is best effort */ }
|
|
54
|
+
panel.component = undefined;
|
|
55
|
+
}
|
|
56
|
+
s.ranges = [];
|
|
57
|
+
}
|
|
58
|
+
function orderedPanels(s: State): Panel[] {
|
|
59
|
+
const rank = (key: string) => key === "pi-better-goal" ? 0 : key === "pi-better-plan" ? 1 : 2;
|
|
60
|
+
return [...s.panels.entries()].sort(([a], [b]) => rank(a) - rank(b)).map(([, panel]) => panel);
|
|
61
|
+
}
|
|
62
|
+
function buildPanels(s: State): void {
|
|
63
|
+
if (!s.tui || !s.theme) return;
|
|
64
|
+
for (const panel of orderedPanels(s)) panel.component ??= panel.factory(s.tui, s.theme);
|
|
65
|
+
}
|
|
66
|
+
function renderPanels(s: State, width: number): string[] {
|
|
67
|
+
buildPanels(s);
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
s.ranges = [];
|
|
70
|
+
for (const panel of orderedPanels(s)) {
|
|
71
|
+
if (!panel.component) continue;
|
|
72
|
+
const rendered = panel.component.render(width);
|
|
73
|
+
s.ranges.push({ start: lines.length, end: lines.length + rendered.length, component: panel.component });
|
|
74
|
+
lines.push(...rendered);
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
function mouse(s: State, event: MouseEvent) {
|
|
79
|
+
const range = s.ranges.find(range => event.y >= range.start && event.y < range.end);
|
|
80
|
+
return range?.component.handleMouse?.({ ...event, y: event.y - range.start });
|
|
81
|
+
}
|
|
82
|
+
function restoreAnchor(ctx: ExtensionContext): string | undefined {
|
|
83
|
+
let anchor: string | undefined;
|
|
84
|
+
for (const entry of ctx.sessionManager?.getBranch?.() ?? []) {
|
|
85
|
+
if (entry.type !== "custom" || entry.customType !== NAVIGATOR_ENTRY_TYPE) continue;
|
|
86
|
+
const data = entry.data as { version?: unknown; anchor?: unknown } | undefined;
|
|
87
|
+
if (data?.version === 1 && typeof data.anchor === "string") anchor = data.anchor;
|
|
88
|
+
}
|
|
89
|
+
return anchor;
|
|
90
|
+
}
|
|
91
|
+
function appendAnchor(s: State): void {
|
|
92
|
+
if (s.sticky || s.anchor || !canScroll(s.pi)) return;
|
|
93
|
+
const anchor = randomUUID();
|
|
94
|
+
s.anchor = anchor;
|
|
95
|
+
try { s.pi!.appendEntry(NAVIGATOR_ENTRY_TYPE, { version: 1, anchor }); }
|
|
96
|
+
catch (error) { s.anchor = undefined; throw error; }
|
|
97
|
+
}
|
|
98
|
+
function scheduleAnchor(s: State): void {
|
|
99
|
+
if (s.anchor || s.pendingAnchor || s.sticky) return;
|
|
100
|
+
s.pendingAnchor = true;
|
|
101
|
+
const generation = s.generation;
|
|
102
|
+
// Session writes cannot run inside a component render. Empty sessions stay untouched.
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
if (generation !== s.generation) return;
|
|
105
|
+
s.pendingAnchor = false;
|
|
106
|
+
if (!s.sticky && s.panels.size) appendAnchor(s);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function clearHost(s: State): void {
|
|
110
|
+
s.generation++;
|
|
111
|
+
s.pendingAnchor = false;
|
|
112
|
+
try {
|
|
113
|
+
if (s.mounted && isTui(s.ctx)) s.ctx.ui.setWidget(NAVIGATOR_PANEL_WIDGET_KEY, undefined);
|
|
114
|
+
} catch { /* an old session context may already be detached */ }
|
|
115
|
+
disposePanels(s);
|
|
116
|
+
s.mounted = false;
|
|
117
|
+
s.tui = undefined;
|
|
118
|
+
s.theme = undefined;
|
|
119
|
+
}
|
|
120
|
+
function adoptContext(s: State, ctx: ExtensionContext): void {
|
|
121
|
+
if (s.ui && s.ui !== ctx.ui) {
|
|
122
|
+
clearHost(s);
|
|
123
|
+
s.panels.clear();
|
|
124
|
+
s.anchor = undefined;
|
|
125
|
+
}
|
|
126
|
+
s.ctx = ctx;
|
|
127
|
+
s.ui = ctx.ui;
|
|
128
|
+
}
|
|
129
|
+
function mountHost(s: State): void {
|
|
130
|
+
if (!isTui(s.ctx) || s.mounted || !s.panels.size) return;
|
|
131
|
+
s.mounted = true;
|
|
132
|
+
s.ctx.ui.setWidget(NAVIGATOR_PANEL_WIDGET_KEY, (tui, theme) => {
|
|
133
|
+
if (s.tui !== tui || s.theme !== theme) disposePanels(s);
|
|
134
|
+
s.tui = tui;
|
|
135
|
+
s.theme = theme;
|
|
136
|
+
buildPanels(s);
|
|
137
|
+
return {
|
|
138
|
+
render(width: number) {
|
|
139
|
+
const lines = renderPanels(s, width);
|
|
140
|
+
if (s.sticky) return lines;
|
|
141
|
+
if (lines.length) scheduleAnchor(s);
|
|
142
|
+
return [];
|
|
143
|
+
},
|
|
144
|
+
handleMouse: (event: MouseEvent) => s.sticky ? mouse(s, event) : undefined,
|
|
145
|
+
invalidate() {
|
|
146
|
+
for (const panel of s.panels.values()) panel.component?.invalidate();
|
|
147
|
+
},
|
|
148
|
+
dispose() { disposePanels(s); s.mounted = false; },
|
|
149
|
+
};
|
|
150
|
+
}, { placement: "aboveEditor" });
|
|
151
|
+
}
|
|
152
|
+
function changeSticky(value: string, ctx: ExtensionContext): void {
|
|
153
|
+
if (value !== "On" && value !== "Off") throw new Error("Sticky navigator expects On or Off.");
|
|
154
|
+
const s = state();
|
|
155
|
+
if (value === "Off" && !canScroll(s.pi)) {
|
|
156
|
+
throw new Error("Scrolling navigator requires a Pi version with registerEntryRenderer support.");
|
|
157
|
+
}
|
|
158
|
+
updateHarnessSetting("navigator", current => {
|
|
159
|
+
parsePreference(current);
|
|
160
|
+
return { version: 1, sticky: value === "On" };
|
|
161
|
+
});
|
|
162
|
+
adoptContext(s, ctx);
|
|
163
|
+
if (s.sticky && value === "Off") s.anchor = undefined;
|
|
164
|
+
s.sticky = value === "On";
|
|
165
|
+
refreshNavigatorPanels(ctx);
|
|
166
|
+
}
|
|
167
|
+
export function initializeNavigatorPanels(pi: ExtensionAPI): void {
|
|
168
|
+
const s = state();
|
|
169
|
+
if (s.owners.has(pi)) return;
|
|
170
|
+
s.pi = pi;
|
|
171
|
+
try { s.sticky = parsePreference(readHarnessSetting("navigator")).sticky; }
|
|
172
|
+
catch { s.sticky = true; }
|
|
173
|
+
if (!canScroll(pi)) s.sticky = true;
|
|
174
|
+
if (canScroll(pi)) pi.registerEntryRenderer(NAVIGATOR_ENTRY_TYPE, (entry) => {
|
|
175
|
+
const data = entry.data as { version?: number; anchor?: string } | undefined;
|
|
176
|
+
if (data?.version !== 1 || !data.anchor) return undefined;
|
|
177
|
+
// Pi reconstructs chat before session_start restores the visible branch.
|
|
178
|
+
// Keep the component mounted and resolve its active anchor at render time.
|
|
179
|
+
return {
|
|
180
|
+
render: width => !s.sticky && data.anchor === s.anchor ? renderPanels(s, width) : [],
|
|
181
|
+
handleMouse: event => !s.sticky && data.anchor === s.anchor ? mouse(s, event) : undefined,
|
|
182
|
+
invalidate() { for (const panel of s.panels.values()) panel.component?.invalidate(); },
|
|
183
|
+
} as PanelComponent;
|
|
184
|
+
});
|
|
185
|
+
s.control ??= {
|
|
186
|
+
id: NAVIGATOR_STICKY_SETTING_ID, label: "Sticky navigator", values: ["On", "Off"],
|
|
187
|
+
get: () => state().sticky ? "On" : "Off", change: changeSticky,
|
|
188
|
+
};
|
|
189
|
+
const owner: Owner = { pi };
|
|
190
|
+
s.owners.set(pi, owner);
|
|
191
|
+
// Use one stable contribution callback across vendored copies, but each owner
|
|
192
|
+
// cleans up its own event subscription when its extension is unloaded.
|
|
193
|
+
const register = () => pi.events?.emit?.("harness-settings:register", s.control);
|
|
194
|
+
owner.stop = pi.events?.on?.("harness-settings:request", register);
|
|
195
|
+
register();
|
|
196
|
+
pi.on("session_start", (_event, ctx) => {
|
|
197
|
+
adoptContext(s, ctx);
|
|
198
|
+
s.anchor = restoreAnchor(ctx);
|
|
199
|
+
});
|
|
200
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
201
|
+
adoptContext(s, ctx);
|
|
202
|
+
s.anchor = restoreAnchor(ctx);
|
|
203
|
+
refreshNavigatorPanels(ctx);
|
|
204
|
+
});
|
|
205
|
+
pi.on("session_shutdown", () => {
|
|
206
|
+
owner.stop?.();
|
|
207
|
+
s.owners.delete(pi);
|
|
208
|
+
if (s.owners.size) return;
|
|
209
|
+
clearHost(s);
|
|
210
|
+
s.panels.clear();
|
|
211
|
+
s.anchor = undefined;
|
|
212
|
+
s.ctx = undefined;
|
|
213
|
+
s.ui = undefined;
|
|
214
|
+
s.pi = undefined;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export function setNavigatorPanel(
|
|
218
|
+
ctx: ExtensionContext, key: string, content: NavigatorPanelFactory | undefined,
|
|
219
|
+
_options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
220
|
+
): void {
|
|
221
|
+
const s = state();
|
|
222
|
+
if (!isTui(ctx)) return;
|
|
223
|
+
adoptContext(s, ctx);
|
|
224
|
+
s.panels.get(key)?.component?.dispose?.();
|
|
225
|
+
if (content) s.panels.set(key, { factory: content });
|
|
226
|
+
else s.panels.delete(key);
|
|
227
|
+
if (!s.panels.size) { clearHost(s); return; }
|
|
228
|
+
mountHost(s);
|
|
229
|
+
buildPanels(s);
|
|
230
|
+
s.tui?.requestRender?.();
|
|
231
|
+
}
|
|
232
|
+
export function refreshNavigatorPanels(ctx?: ExtensionContext): void {
|
|
233
|
+
const s = state();
|
|
234
|
+
if (ctx) adoptContext(s, ctx);
|
|
235
|
+
mountHost(s);
|
|
236
|
+
s.tui?.requestRender?.(true);
|
|
237
|
+
}
|
|
238
|
+
export function __resetNavigatorPanelsForTests(): void {
|
|
239
|
+
const s = state();
|
|
240
|
+
clearHost(s);
|
|
241
|
+
for (const owner of s.owners.values()) owner.stop?.();
|
|
242
|
+
s.owners.clear();
|
|
243
|
+
s.panels.clear();
|
|
244
|
+
s.pi = undefined;
|
|
245
|
+
s.ctx = undefined;
|
|
246
|
+
s.ui = undefined;
|
|
247
|
+
s.anchor = undefined;
|
|
248
|
+
s.sticky = true;
|
|
249
|
+
s.control = undefined;
|
|
250
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
3
3
|
import { Text, matchesKey, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.js";
|
|
6
|
+
import { initializeNavigatorPanels, setNavigatorPanel } from "../shared-navigator-panels.ts";
|
|
6
7
|
import { reconstructGoalSnapshot } from "pi-better-goal/src/goal-state.ts";
|
|
7
8
|
import { formatClockDuration, goalTiming } from "pi-better-goal/src/goal-clock.ts";
|
|
8
9
|
import { OUTCOME_ENTRY, prepareOutcome, restoreOutcome, formatOutcome, type Outcome, type OutcomeInput } from "./outcome-state.js";
|
|
@@ -123,6 +124,7 @@ function workflowPlanOwner(ctx: ExtensionContext): string | null {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
127
|
+
initializeNavigatorPanels(pi);
|
|
126
128
|
let enabled = false;
|
|
127
129
|
let display = true;
|
|
128
130
|
let widgetContext: ExtensionContext | undefined;
|
|
@@ -315,7 +317,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
315
317
|
const installWidget = (ctx: ExtensionContext): void => {
|
|
316
318
|
if (!ctx.hasUI) return;
|
|
317
319
|
widgetContext = ctx;
|
|
318
|
-
ctx
|
|
320
|
+
setNavigatorPanel(ctx,
|
|
319
321
|
EXTENSION_NAME,
|
|
320
322
|
(tui, theme) => {
|
|
321
323
|
const localRefresh = (force = false): void => tui.requestRender(force);
|
|
@@ -693,7 +695,7 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
693
695
|
refreshWidget = undefined;
|
|
694
696
|
widgetContext = undefined;
|
|
695
697
|
try {
|
|
696
|
-
ctx
|
|
698
|
+
setNavigatorPanel(ctx, EXTENSION_NAME, undefined);
|
|
697
699
|
} catch { /* best-effort cleanup */ }
|
|
698
700
|
legacyPlanMask?.dispose();
|
|
699
701
|
legacyPlanMask = undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
|
|
3
|
+
import { initializeNavigatorPanels } from "./shared-navigator-panels.ts";
|
|
3
4
|
import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
|
|
4
5
|
import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
|
|
5
6
|
import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
|
|
@@ -8,6 +9,7 @@ import { registerTools } from "./tools.js";
|
|
|
8
9
|
import { appendExecutionPolicy } from "./shared-execution-policy.js";
|
|
9
10
|
|
|
10
11
|
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
12
|
+
initializeNavigatorPanels(pi);
|
|
11
13
|
pi.on("before_agent_start", (event) => ({ systemPrompt: appendExecutionPolicy(event.systemPrompt) }));
|
|
12
14
|
registerBackgroundTasksGoalProvider(pi);
|
|
13
15
|
ensureBackgroundTasksNavigatorProvider(pi);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Generated from packages/navigator/panels.ts. Do not edit directly.
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { readHarnessSetting, updateHarnessSetting } from "./shared-harness-settings.js";
|
|
6
|
+
|
|
7
|
+
export const NAVIGATOR_PANEL_WIDGET_KEY = "background-work-list";
|
|
8
|
+
export const NAVIGATOR_ENTRY_TYPE = "pi-better-harness-navigator-panels";
|
|
9
|
+
export const NAVIGATOR_STICKY_SETTING_ID = "navigator-sticky";
|
|
10
|
+
type MouseEvent = { type: string; button: string; x: number; y: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean };
|
|
11
|
+
type PanelComponent = Component & { dispose?(): void; handleMouse?(event: MouseEvent): { handled: true; render: boolean } | undefined };
|
|
12
|
+
export type NavigatorPanelFactory = (tui: TUI, theme: Theme) => PanelComponent;
|
|
13
|
+
type Panel = { factory: NavigatorPanelFactory; component?: PanelComponent | undefined };
|
|
14
|
+
type Preference = { version: 1; sticky: boolean };
|
|
15
|
+
type Owner = { pi: ExtensionAPI; stop?: (() => void) | undefined };
|
|
16
|
+
type State = {
|
|
17
|
+
panels: Map<string, Panel>;
|
|
18
|
+
owners: Map<ExtensionAPI, Owner>;
|
|
19
|
+
ctx?: ExtensionContext | undefined;
|
|
20
|
+
ui?: ExtensionContext["ui"] | undefined;
|
|
21
|
+
pi?: ExtensionAPI | undefined;
|
|
22
|
+
sticky: boolean;
|
|
23
|
+
mounted: boolean;
|
|
24
|
+
tui?: TUI | undefined;
|
|
25
|
+
theme?: Theme | undefined;
|
|
26
|
+
anchor?: string | undefined;
|
|
27
|
+
pendingAnchor: boolean;
|
|
28
|
+
generation: number;
|
|
29
|
+
ranges: Array<{ start: number; end: number; component: PanelComponent }>;
|
|
30
|
+
control?: { id: string; label: string; values: string[]; get(): string; change(value: string, ctx: ExtensionContext): void } | undefined;
|
|
31
|
+
};
|
|
32
|
+
const KEY = Symbol.for("pi-better-harness.navigator-panels.state");
|
|
33
|
+
function state(): State {
|
|
34
|
+
const global = globalThis as typeof globalThis & { [KEY]?: State };
|
|
35
|
+
return global[KEY] ??= { panels: new Map(), owners: new Map(), sticky: true, mounted: false, pendingAnchor: false, generation: 0, ranges: [] };
|
|
36
|
+
}
|
|
37
|
+
function parsePreference(value: unknown): Preference {
|
|
38
|
+
if (value === undefined) return { version: 1, sticky: true };
|
|
39
|
+
const preference = value as Partial<Preference> | null;
|
|
40
|
+
if (!preference || preference.version !== 1 || typeof preference.sticky !== "boolean") {
|
|
41
|
+
throw new Error("Navigator preference must contain version 1 and a boolean sticky value.");
|
|
42
|
+
}
|
|
43
|
+
return { version: 1, sticky: preference.sticky };
|
|
44
|
+
}
|
|
45
|
+
function canScroll(pi: ExtensionAPI | undefined): boolean {
|
|
46
|
+
return typeof pi?.registerEntryRenderer === "function" && typeof pi?.appendEntry === "function";
|
|
47
|
+
}
|
|
48
|
+
function isTui(ctx: ExtensionContext | undefined): ctx is ExtensionContext {
|
|
49
|
+
return ctx?.mode === "tui" && ctx.hasUI && typeof ctx.ui?.setWidget === "function";
|
|
50
|
+
}
|
|
51
|
+
function disposePanels(s: State): void {
|
|
52
|
+
for (const panel of s.panels.values()) {
|
|
53
|
+
try { panel.component?.dispose?.(); } catch { /* stale UI cleanup is best effort */ }
|
|
54
|
+
panel.component = undefined;
|
|
55
|
+
}
|
|
56
|
+
s.ranges = [];
|
|
57
|
+
}
|
|
58
|
+
function orderedPanels(s: State): Panel[] {
|
|
59
|
+
const rank = (key: string) => key === "pi-better-goal" ? 0 : key === "pi-better-plan" ? 1 : 2;
|
|
60
|
+
return [...s.panels.entries()].sort(([a], [b]) => rank(a) - rank(b)).map(([, panel]) => panel);
|
|
61
|
+
}
|
|
62
|
+
function buildPanels(s: State): void {
|
|
63
|
+
if (!s.tui || !s.theme) return;
|
|
64
|
+
for (const panel of orderedPanels(s)) panel.component ??= panel.factory(s.tui, s.theme);
|
|
65
|
+
}
|
|
66
|
+
function renderPanels(s: State, width: number): string[] {
|
|
67
|
+
buildPanels(s);
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
s.ranges = [];
|
|
70
|
+
for (const panel of orderedPanels(s)) {
|
|
71
|
+
if (!panel.component) continue;
|
|
72
|
+
const rendered = panel.component.render(width);
|
|
73
|
+
s.ranges.push({ start: lines.length, end: lines.length + rendered.length, component: panel.component });
|
|
74
|
+
lines.push(...rendered);
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
function mouse(s: State, event: MouseEvent) {
|
|
79
|
+
const range = s.ranges.find(range => event.y >= range.start && event.y < range.end);
|
|
80
|
+
return range?.component.handleMouse?.({ ...event, y: event.y - range.start });
|
|
81
|
+
}
|
|
82
|
+
function restoreAnchor(ctx: ExtensionContext): string | undefined {
|
|
83
|
+
let anchor: string | undefined;
|
|
84
|
+
for (const entry of ctx.sessionManager?.getBranch?.() ?? []) {
|
|
85
|
+
if (entry.type !== "custom" || entry.customType !== NAVIGATOR_ENTRY_TYPE) continue;
|
|
86
|
+
const data = entry.data as { version?: unknown; anchor?: unknown } | undefined;
|
|
87
|
+
if (data?.version === 1 && typeof data.anchor === "string") anchor = data.anchor;
|
|
88
|
+
}
|
|
89
|
+
return anchor;
|
|
90
|
+
}
|
|
91
|
+
function appendAnchor(s: State): void {
|
|
92
|
+
if (s.sticky || s.anchor || !canScroll(s.pi)) return;
|
|
93
|
+
const anchor = randomUUID();
|
|
94
|
+
s.anchor = anchor;
|
|
95
|
+
try { s.pi!.appendEntry(NAVIGATOR_ENTRY_TYPE, { version: 1, anchor }); }
|
|
96
|
+
catch (error) { s.anchor = undefined; throw error; }
|
|
97
|
+
}
|
|
98
|
+
function scheduleAnchor(s: State): void {
|
|
99
|
+
if (s.anchor || s.pendingAnchor || s.sticky) return;
|
|
100
|
+
s.pendingAnchor = true;
|
|
101
|
+
const generation = s.generation;
|
|
102
|
+
// Session writes cannot run inside a component render. Empty sessions stay untouched.
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
if (generation !== s.generation) return;
|
|
105
|
+
s.pendingAnchor = false;
|
|
106
|
+
if (!s.sticky && s.panels.size) appendAnchor(s);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function clearHost(s: State): void {
|
|
110
|
+
s.generation++;
|
|
111
|
+
s.pendingAnchor = false;
|
|
112
|
+
try {
|
|
113
|
+
if (s.mounted && isTui(s.ctx)) s.ctx.ui.setWidget(NAVIGATOR_PANEL_WIDGET_KEY, undefined);
|
|
114
|
+
} catch { /* an old session context may already be detached */ }
|
|
115
|
+
disposePanels(s);
|
|
116
|
+
s.mounted = false;
|
|
117
|
+
s.tui = undefined;
|
|
118
|
+
s.theme = undefined;
|
|
119
|
+
}
|
|
120
|
+
function adoptContext(s: State, ctx: ExtensionContext): void {
|
|
121
|
+
if (s.ui && s.ui !== ctx.ui) {
|
|
122
|
+
clearHost(s);
|
|
123
|
+
s.panels.clear();
|
|
124
|
+
s.anchor = undefined;
|
|
125
|
+
}
|
|
126
|
+
s.ctx = ctx;
|
|
127
|
+
s.ui = ctx.ui;
|
|
128
|
+
}
|
|
129
|
+
function mountHost(s: State): void {
|
|
130
|
+
if (!isTui(s.ctx) || s.mounted || !s.panels.size) return;
|
|
131
|
+
s.mounted = true;
|
|
132
|
+
s.ctx.ui.setWidget(NAVIGATOR_PANEL_WIDGET_KEY, (tui, theme) => {
|
|
133
|
+
if (s.tui !== tui || s.theme !== theme) disposePanels(s);
|
|
134
|
+
s.tui = tui;
|
|
135
|
+
s.theme = theme;
|
|
136
|
+
buildPanels(s);
|
|
137
|
+
return {
|
|
138
|
+
render(width: number) {
|
|
139
|
+
const lines = renderPanels(s, width);
|
|
140
|
+
if (s.sticky) return lines;
|
|
141
|
+
if (lines.length) scheduleAnchor(s);
|
|
142
|
+
return [];
|
|
143
|
+
},
|
|
144
|
+
handleMouse: (event: MouseEvent) => s.sticky ? mouse(s, event) : undefined,
|
|
145
|
+
invalidate() {
|
|
146
|
+
for (const panel of s.panels.values()) panel.component?.invalidate();
|
|
147
|
+
},
|
|
148
|
+
dispose() { disposePanels(s); s.mounted = false; },
|
|
149
|
+
};
|
|
150
|
+
}, { placement: "aboveEditor" });
|
|
151
|
+
}
|
|
152
|
+
function changeSticky(value: string, ctx: ExtensionContext): void {
|
|
153
|
+
if (value !== "On" && value !== "Off") throw new Error("Sticky navigator expects On or Off.");
|
|
154
|
+
const s = state();
|
|
155
|
+
if (value === "Off" && !canScroll(s.pi)) {
|
|
156
|
+
throw new Error("Scrolling navigator requires a Pi version with registerEntryRenderer support.");
|
|
157
|
+
}
|
|
158
|
+
updateHarnessSetting("navigator", current => {
|
|
159
|
+
parsePreference(current);
|
|
160
|
+
return { version: 1, sticky: value === "On" };
|
|
161
|
+
});
|
|
162
|
+
adoptContext(s, ctx);
|
|
163
|
+
if (s.sticky && value === "Off") s.anchor = undefined;
|
|
164
|
+
s.sticky = value === "On";
|
|
165
|
+
refreshNavigatorPanels(ctx);
|
|
166
|
+
}
|
|
167
|
+
export function initializeNavigatorPanels(pi: ExtensionAPI): void {
|
|
168
|
+
const s = state();
|
|
169
|
+
if (s.owners.has(pi)) return;
|
|
170
|
+
s.pi = pi;
|
|
171
|
+
try { s.sticky = parsePreference(readHarnessSetting("navigator")).sticky; }
|
|
172
|
+
catch { s.sticky = true; }
|
|
173
|
+
if (!canScroll(pi)) s.sticky = true;
|
|
174
|
+
if (canScroll(pi)) pi.registerEntryRenderer(NAVIGATOR_ENTRY_TYPE, (entry) => {
|
|
175
|
+
const data = entry.data as { version?: number; anchor?: string } | undefined;
|
|
176
|
+
if (data?.version !== 1 || !data.anchor) return undefined;
|
|
177
|
+
// Pi reconstructs chat before session_start restores the visible branch.
|
|
178
|
+
// Keep the component mounted and resolve its active anchor at render time.
|
|
179
|
+
return {
|
|
180
|
+
render: width => !s.sticky && data.anchor === s.anchor ? renderPanels(s, width) : [],
|
|
181
|
+
handleMouse: event => !s.sticky && data.anchor === s.anchor ? mouse(s, event) : undefined,
|
|
182
|
+
invalidate() { for (const panel of s.panels.values()) panel.component?.invalidate(); },
|
|
183
|
+
} as PanelComponent;
|
|
184
|
+
});
|
|
185
|
+
s.control ??= {
|
|
186
|
+
id: NAVIGATOR_STICKY_SETTING_ID, label: "Sticky navigator", values: ["On", "Off"],
|
|
187
|
+
get: () => state().sticky ? "On" : "Off", change: changeSticky,
|
|
188
|
+
};
|
|
189
|
+
const owner: Owner = { pi };
|
|
190
|
+
s.owners.set(pi, owner);
|
|
191
|
+
// Use one stable contribution callback across vendored copies, but each owner
|
|
192
|
+
// cleans up its own event subscription when its extension is unloaded.
|
|
193
|
+
const register = () => pi.events?.emit?.("harness-settings:register", s.control);
|
|
194
|
+
owner.stop = pi.events?.on?.("harness-settings:request", register);
|
|
195
|
+
register();
|
|
196
|
+
pi.on("session_start", (_event, ctx) => {
|
|
197
|
+
adoptContext(s, ctx);
|
|
198
|
+
s.anchor = restoreAnchor(ctx);
|
|
199
|
+
});
|
|
200
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
201
|
+
adoptContext(s, ctx);
|
|
202
|
+
s.anchor = restoreAnchor(ctx);
|
|
203
|
+
refreshNavigatorPanels(ctx);
|
|
204
|
+
});
|
|
205
|
+
pi.on("session_shutdown", () => {
|
|
206
|
+
owner.stop?.();
|
|
207
|
+
s.owners.delete(pi);
|
|
208
|
+
if (s.owners.size) return;
|
|
209
|
+
clearHost(s);
|
|
210
|
+
s.panels.clear();
|
|
211
|
+
s.anchor = undefined;
|
|
212
|
+
s.ctx = undefined;
|
|
213
|
+
s.ui = undefined;
|
|
214
|
+
s.pi = undefined;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export function setNavigatorPanel(
|
|
218
|
+
ctx: ExtensionContext, key: string, content: NavigatorPanelFactory | undefined,
|
|
219
|
+
_options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
220
|
+
): void {
|
|
221
|
+
const s = state();
|
|
222
|
+
if (!isTui(ctx)) return;
|
|
223
|
+
adoptContext(s, ctx);
|
|
224
|
+
s.panels.get(key)?.component?.dispose?.();
|
|
225
|
+
if (content) s.panels.set(key, { factory: content });
|
|
226
|
+
else s.panels.delete(key);
|
|
227
|
+
if (!s.panels.size) { clearHost(s); return; }
|
|
228
|
+
mountHost(s);
|
|
229
|
+
buildPanels(s);
|
|
230
|
+
s.tui?.requestRender?.();
|
|
231
|
+
}
|
|
232
|
+
export function refreshNavigatorPanels(ctx?: ExtensionContext): void {
|
|
233
|
+
const s = state();
|
|
234
|
+
if (ctx) adoptContext(s, ctx);
|
|
235
|
+
mountHost(s);
|
|
236
|
+
s.tui?.requestRender?.(true);
|
|
237
|
+
}
|
|
238
|
+
export function __resetNavigatorPanelsForTests(): void {
|
|
239
|
+
const s = state();
|
|
240
|
+
clearHost(s);
|
|
241
|
+
for (const owner of s.owners.values()) owner.stop?.();
|
|
242
|
+
s.owners.clear();
|
|
243
|
+
s.panels.clear();
|
|
244
|
+
s.pi = undefined;
|
|
245
|
+
s.ctx = undefined;
|
|
246
|
+
s.ui = undefined;
|
|
247
|
+
s.anchor = undefined;
|
|
248
|
+
s.sticky = true;
|
|
249
|
+
s.control = undefined;
|
|
250
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { truncateToWidth as piTruncateToWidth, visibleWidth as piVisibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { Component } from "@earendil-works/pi-tui";
|
|
4
4
|
import { createRenderScheduler, type RenderScheduler } from "./shared-render-scheduler.ts";
|
|
5
|
+
import { setNavigatorPanel } from "./shared-navigator-panels.ts";
|
|
5
6
|
|
|
6
7
|
export default function navigatorExtension(): void {
|
|
7
8
|
// Internal shared package. Pi may scan its symlink in the extension directory
|
|
@@ -195,7 +196,7 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
195
196
|
...deps,
|
|
196
197
|
createTranscriptComponent: deps.createTranscriptComponent ?? s.deps?.createTranscriptComponent,
|
|
197
198
|
};
|
|
198
|
-
try { (ctx
|
|
199
|
+
try { setNavigatorPanel(ctx, MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
199
200
|
s.mainListWidgetInstalled = false;
|
|
200
201
|
s.mainListRequestRender = undefined;
|
|
201
202
|
s.mainListDeadlineScheduler?.dispose();
|
|
@@ -215,8 +216,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
215
216
|
const activeCtx = ctx ?? s.uiCtx;
|
|
216
217
|
s.uiCtx = undefined;
|
|
217
218
|
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
218
|
-
|
|
219
|
-
try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
219
|
+
try { setNavigatorPanel(activeCtx, MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
220
220
|
}
|
|
221
221
|
s.lastHint = undefined;
|
|
222
222
|
s.lastMainListLines = undefined;
|
|
@@ -291,7 +291,7 @@ function refreshMainListWidget(): void {
|
|
|
291
291
|
})] : undefined;
|
|
292
292
|
if (!rows.length) {
|
|
293
293
|
if (!s.mainListWidgetInstalled) return;
|
|
294
|
-
try { (ctx
|
|
294
|
+
try { setNavigatorPanel(ctx, MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
295
295
|
s.mainListWidgetInstalled = false;
|
|
296
296
|
s.mainListRequestRender = undefined;
|
|
297
297
|
s.lastMainListSignature = undefined;
|
|
@@ -299,7 +299,7 @@ function refreshMainListWidget(): void {
|
|
|
299
299
|
}
|
|
300
300
|
if (!s.mainListWidgetInstalled) {
|
|
301
301
|
try {
|
|
302
|
-
(ctx
|
|
302
|
+
setNavigatorPanel(ctx, MAIN_LIST_WIDGET_KEY, (tui: { requestRender?(): void }, theme: unknown) => createMainListWidget(tui, theme, deps), { placement: "aboveEditor" });
|
|
303
303
|
s.mainListWidgetInstalled = true;
|
|
304
304
|
} catch { /* ignore */ }
|
|
305
305
|
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
renderGoalClockLine,
|
|
39
39
|
} from "./goal-clock.js";
|
|
40
40
|
import { createRenderScheduler } from "./shared-render-scheduler.js";
|
|
41
|
+
import { initializeNavigatorPanels, setNavigatorPanel } from "./shared-navigator-panels.js";
|
|
41
42
|
import { collectSubagentActivity } from "./subagents.js";
|
|
42
43
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
43
44
|
import { completionGate, pauseGate, schedulerWaitUntil } from "./workflow-gates.js";
|
|
@@ -301,6 +302,7 @@ function formatSnapshot(snapshot: ActivitySnapshot): string {
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
export default function (pi: ExtensionAPI): void {
|
|
305
|
+
initializeNavigatorPanels(pi);
|
|
304
306
|
const providers = new Map<string, BackgroundActivityProvider>();
|
|
305
307
|
const providerUnsubscribers = new Map<string, () => void>();
|
|
306
308
|
providers.set("subagents", {
|
|
@@ -793,7 +795,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
793
795
|
if (!ctx.hasUI) {
|
|
794
796
|
return;
|
|
795
797
|
}
|
|
796
|
-
|
|
798
|
+
setNavigatorPanel(
|
|
799
|
+
ctx,
|
|
797
800
|
EXTENSION_NAME,
|
|
798
801
|
(tui, theme) => {
|
|
799
802
|
const inputIsEditor = (): boolean => {
|
|
@@ -1589,7 +1592,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1589
1592
|
latestSnapshot = null;
|
|
1590
1593
|
collectionPending = false;
|
|
1591
1594
|
try {
|
|
1592
|
-
ctx
|
|
1595
|
+
setNavigatorPanel(ctx, EXTENSION_NAME, undefined);
|
|
1593
1596
|
} catch {
|
|
1594
1597
|
// Best-effort cleanup.
|
|
1595
1598
|
}
|