pi-better-goal 0.10.4 → 0.12.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/package.json +1 -1
- package/src/index.ts +55 -3
- package/src/operation-admission.ts +16 -0
- package/src/shared-navigator-panels.ts +250 -0
- package/src/workflow-gates.ts +15 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Type } from "typebox";
|
|
|
6
6
|
import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
|
|
7
7
|
import { goalPreferencesPath, readGoalPreferences, writeGoalPreference, type GoalPreferences } from "./preferences.js";
|
|
8
8
|
import { createGoalSettingsPage } from "./settings-page.js";
|
|
9
|
+
import { OPERATION_RESUME_ADMISSION, operationOwnerMatches, type OperationResumeRequest } from "./operation-admission.js";
|
|
9
10
|
|
|
10
11
|
import {
|
|
11
12
|
collectActivitySnapshot,
|
|
@@ -38,9 +39,10 @@ import {
|
|
|
38
39
|
renderGoalClockLine,
|
|
39
40
|
} from "./goal-clock.js";
|
|
40
41
|
import { createRenderScheduler } from "./shared-render-scheduler.js";
|
|
42
|
+
import { initializeNavigatorPanels, setNavigatorPanel } from "./shared-navigator-panels.js";
|
|
41
43
|
import { collectSubagentActivity } from "./subagents.js";
|
|
42
44
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
43
|
-
import { completionGate, pauseGate, schedulerWaitUntil } from "./workflow-gates.js";
|
|
45
|
+
import { completionGate, pauseGate, schedulerWaitUntil, operationResumeSchedulerGate } from "./workflow-gates.js";
|
|
44
46
|
import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
|
|
45
47
|
import { requestTaskContext } from "./task-context.js";
|
|
46
48
|
import {
|
|
@@ -301,6 +303,7 @@ function formatSnapshot(snapshot: ActivitySnapshot): string {
|
|
|
301
303
|
}
|
|
302
304
|
|
|
303
305
|
export default function (pi: ExtensionAPI): void {
|
|
306
|
+
initializeNavigatorPanels(pi);
|
|
304
307
|
const providers = new Map<string, BackgroundActivityProvider>();
|
|
305
308
|
const providerUnsubscribers = new Map<string, () => void>();
|
|
306
309
|
providers.set("subagents", {
|
|
@@ -311,6 +314,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
311
314
|
|
|
312
315
|
let currentCtx: ExtensionContext | undefined;
|
|
313
316
|
let executionGeneration = 0;
|
|
317
|
+
let operationAdmissionRevision = 0;
|
|
314
318
|
let agentGeneration = -1;
|
|
315
319
|
let turnGeneration = 0;
|
|
316
320
|
let escapeAbortSuppression: { session: number; turn: number; execution: number } | undefined;
|
|
@@ -793,7 +797,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
793
797
|
if (!ctx.hasUI) {
|
|
794
798
|
return;
|
|
795
799
|
}
|
|
796
|
-
|
|
800
|
+
setNavigatorPanel(
|
|
801
|
+
ctx,
|
|
797
802
|
EXTENSION_NAME,
|
|
798
803
|
(tui, theme) => {
|
|
799
804
|
const inputIsEditor = (): boolean => {
|
|
@@ -859,6 +864,51 @@ export default function (pi: ExtensionAPI): void {
|
|
|
859
864
|
);
|
|
860
865
|
};
|
|
861
866
|
|
|
867
|
+
const unsubscribeOperationAdmission = pi.events.on(OPERATION_RESUME_ADMISSION, (value) => {
|
|
868
|
+
const request = value as Partial<OperationResumeRequest> | undefined;
|
|
869
|
+
if (request?.version !== 1 || !request.owner || typeof request.reply !== "function") return;
|
|
870
|
+
const ctx = currentCtx;
|
|
871
|
+
const revision = executionGeneration;
|
|
872
|
+
const preferencesRevision = operationAdmissionRevision;
|
|
873
|
+
const reply = request.reply;
|
|
874
|
+
const held = (reason: string) => reply({ allowed: false, reason, revision: executionGeneration });
|
|
875
|
+
if (!operationOwnerMatches(ctx, request.owner) || !ctx) { held("Owning parent session or branch is inactive"); return; }
|
|
876
|
+
const goal = getGoal(ctx);
|
|
877
|
+
if ((request.owner.goalId ?? null) !== (goal?.goalId ?? null)) { held("Owning Goal is no longer current"); return; }
|
|
878
|
+
if (goal && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
|
|
879
|
+
if (goal && !isPokeable(goal)) { held("Goal is not active"); return; }
|
|
880
|
+
if (goal && currentPermissionHold(ctx, goal.goalId).blockers.length) { held("Goal has an active permission hold"); return; }
|
|
881
|
+
if (goal && currentContinuationState(ctx, goal.goalId)?.blocked) { held("Goal continuation requires human resume"); return; }
|
|
882
|
+
if (goal && !boundCommandReady(goal, ctx)) { held("Bound workflow is not ready"); return; }
|
|
883
|
+
const workflow = getWorkflow(ctx);
|
|
884
|
+
void (workflow ? operationResumeSchedulerGate(ctx.sessionManager.getBranch(), workflow) : Promise.resolve({ ok: true as const, waitUntil: undefined })).then((gate) => {
|
|
885
|
+
if (ctx !== currentCtx || revision !== executionGeneration || preferencesRevision !== operationAdmissionRevision || !operationOwnerMatches(currentCtx, request.owner!)) {
|
|
886
|
+
held("Parent execution changed during resume admission"); return;
|
|
887
|
+
}
|
|
888
|
+
const latest = getGoal(ctx);
|
|
889
|
+
if ((request.owner!.goalId ?? null) !== (latest?.goalId ?? null)) { held("Owning Goal changed during resume admission"); return; }
|
|
890
|
+
if (latest && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
|
|
891
|
+
if (latest && (!isPokeable(latest) || currentPermissionHold(ctx, latest.goalId).blockers.length || currentContinuationState(ctx, latest.goalId)?.blocked)) {
|
|
892
|
+
held("Goal became held during resume admission"); return;
|
|
893
|
+
}
|
|
894
|
+
if (!gate.ok) { held(gate.reason); return; }
|
|
895
|
+
if (gate.waitUntil && gate.waitUntil > Date.now()) { held("Workflow scheduler is waiting"); return; }
|
|
896
|
+
// An in-process capability lets the dispatcher validate this admission
|
|
897
|
+
// after its last await and immediately before releasing execution.
|
|
898
|
+
const isCurrent = (): boolean => {
|
|
899
|
+
if (ctx !== currentCtx || revision !== executionGeneration || preferencesRevision !== operationAdmissionRevision
|
|
900
|
+
|| !operationOwnerMatches(currentCtx, request.owner!)) return false;
|
|
901
|
+
const active = getGoal(ctx);
|
|
902
|
+
if ((request.owner!.goalId ?? null) !== (active?.goalId ?? null)) return false;
|
|
903
|
+
if (JSON.stringify(getWorkflow(ctx)) !== JSON.stringify(workflow)) return false;
|
|
904
|
+
return !active || (preferences.autoContinue && isPokeable(active)
|
|
905
|
+
&& !currentPermissionHold(ctx, active.goalId).blockers.length
|
|
906
|
+
&& !currentContinuationState(ctx, active.goalId)?.blocked && boundCommandReady(active, ctx));
|
|
907
|
+
};
|
|
908
|
+
reply({ allowed: true, revision, isCurrent });
|
|
909
|
+
}, () => held("Workflow admission evidence is unavailable"));
|
|
910
|
+
});
|
|
911
|
+
|
|
862
912
|
pi.events.on(EVENT_REGISTER_PROVIDER, (provider) => {
|
|
863
913
|
const candidate = provider as Partial<BackgroundActivityProvider> | undefined;
|
|
864
914
|
if (!candidate || typeof candidate.id !== "string" || typeof candidate.getActivity !== "function") {
|
|
@@ -877,6 +927,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
877
927
|
const changePreference = async (ctx: ExtensionCommandContext, key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
|
|
878
928
|
const previous = preferences;
|
|
879
929
|
preferences = writeGoalPreference(key, enabled);
|
|
930
|
+
operationAdmissionRevision += 1;
|
|
880
931
|
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
881
932
|
syncResumeTool(getGoal(ctx));
|
|
882
933
|
if (!previous.autoContinue && preferences.autoContinue) {
|
|
@@ -1573,6 +1624,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1573
1624
|
|
|
1574
1625
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1575
1626
|
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
1627
|
+
if (typeof unsubscribeOperationAdmission === "function") unsubscribeOperationAdmission();
|
|
1576
1628
|
|
|
1577
1629
|
executionGeneration += 1;
|
|
1578
1630
|
sessionGeneration += 1;
|
|
@@ -1589,7 +1641,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1589
1641
|
latestSnapshot = null;
|
|
1590
1642
|
collectionPending = false;
|
|
1591
1643
|
try {
|
|
1592
|
-
ctx
|
|
1644
|
+
setNavigatorPanel(ctx, EXTENSION_NAME, undefined);
|
|
1593
1645
|
} catch {
|
|
1594
1646
|
// Best-effort cleanup.
|
|
1595
1647
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {ExtensionContext} from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export const OPERATION_RESUME_ADMISSION='pi-better-goal:operation-resume-admission';
|
|
4
|
+
export interface OperationResumeOwner {sessionId:string;workspace:string;branchId?:string|null;goalId?:string|null}
|
|
5
|
+
export interface OperationResumeGate {allowed:boolean;reason?:string;revision:number;isCurrent?:()=>boolean}
|
|
6
|
+
export interface OperationResumeRequest {version:1;owner:OperationResumeOwner;reply:(gate:OperationResumeGate)=>void}
|
|
7
|
+
|
|
8
|
+
/** A recorded branch anchor remains valid on descendants, not on an abandoned fork. */
|
|
9
|
+
export function operationOwnerMatches(ctx:ExtensionContext|undefined,owner:OperationResumeOwner):boolean {
|
|
10
|
+
if(!ctx||ctx.cwd!==owner.workspace)return false;
|
|
11
|
+
try{
|
|
12
|
+
if(ctx.sessionManager.getSessionId()!==owner.sessionId)return false;
|
|
13
|
+
if(owner.branchId&&!ctx.sessionManager.getBranch().some(entry=>entry.id===owner.branchId))return false;
|
|
14
|
+
return true;
|
|
15
|
+
}catch{return false;}
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|
package/src/workflow-gates.ts
CHANGED
|
@@ -262,6 +262,21 @@ export function waitUntil(due: readonly SchedulerAction[]): number | undefined {
|
|
|
262
262
|
return times.length > 0 ? Math.min(...times) : undefined;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
export type OperationResumeSchedulerGate = { ok: true; waitUntil: number | undefined } | { ok: false; reason: string };
|
|
266
|
+
|
|
267
|
+
/** Automatic worker admission cannot treat unavailable workflow evidence as permission. */
|
|
268
|
+
export async function operationResumeSchedulerGate(
|
|
269
|
+
entries: Iterable<EntryLike>,
|
|
270
|
+
owner: WorkflowOwner,
|
|
271
|
+
options: { timeoutMs?: number } = {},
|
|
272
|
+
): Promise<OperationResumeSchedulerGate> {
|
|
273
|
+
const located = locateRun(entries, owner);
|
|
274
|
+
if ("warning" in located) return { ok: false, reason: located.warning };
|
|
275
|
+
const run = await runScheduler(located.script, located.runDir, options.timeoutMs);
|
|
276
|
+
if (!run.ok) return { ok: false, reason: `Workflow admission evidence unavailable: ${run.reason}` };
|
|
277
|
+
return { ok: true, waitUntil: waitUntil(run.due) };
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
/** When the bound run next wants a continuation, or undefined when it is not only waiting. */
|
|
266
281
|
export async function schedulerWaitUntil(
|
|
267
282
|
entries: Iterable<EntryLike>,
|