@underactive/pi-topping-moa-fusion 0.1.0 → 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +54 -12
  3. package/agents/moa-synthesizer.md +9 -6
  4. package/package.json +5 -5
  5. package/src/config/planName.ts +29 -24
  6. package/src/config/settings.ts +38 -1
  7. package/src/debate/debateFanout.ts +16 -6
  8. package/src/debate/runDebate.ts +13 -4
  9. package/src/index.ts +14 -3
  10. package/src/moa/conflictContract.ts +2 -1
  11. package/src/moa/contextContract.ts +1 -1
  12. package/src/moa/fanout.ts +115 -4
  13. package/src/moa/fanoutWiring.ts +13 -3
  14. package/src/moa/implementationRetry.ts +2 -2
  15. package/src/moa/orchestration.ts +3 -0
  16. package/src/moa/panelDependence.ts +87 -0
  17. package/src/moa/reviewLoop.ts +2 -2
  18. package/src/moa/synthesis.ts +12 -8
  19. package/src/moa/verification.ts +117 -20
  20. package/src/moa/verificationCriteria.ts +1 -1
  21. package/src/opinion/opinionFanout.ts +5 -4
  22. package/src/opinion/runOpinion.ts +14 -13
  23. package/src/planning/contextEdits.ts +62 -0
  24. package/src/planning/modeState.ts +17 -0
  25. package/src/planning/planFile.ts +30 -12
  26. package/src/planning/planMode.ts +144 -11
  27. package/src/planning/tools/exitPlanMode.ts +7 -2
  28. package/src/planning/tools/mfPlanSubagent.ts +3 -0
  29. package/src/planning/tools/shared.ts +1 -0
  30. package/src/runtime/activityTracking.ts +28 -12
  31. package/src/runtime/processPool.ts +2 -0
  32. package/src/runtime/results.ts +18 -0
  33. package/src/runtime/runner.ts +45 -9
  34. package/src/runtime/wire.ts +11 -0
  35. package/src/spec/runSpec.ts +343 -0
  36. package/src/spec/specContract.ts +324 -0
  37. package/src/ui/cancelOverlay.ts +1 -1
  38. package/src/ui/moaModelPicker.ts +32 -4
  39. package/src/ui/moaProgressWidget.ts +114 -12
  40. package/src/ui/moaSetupOverlay.ts +51 -7
  41. package/src/ui/planReviewOverlay.ts +16 -8
  42. package/src/ui/rosterEditor.ts +18 -12
  43. package/src/ui/workingOverlay.ts +265 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Working overlay — a small "please wait" card for blocking, non-agent work
3
+ * that has nowhere else to draw while it runs (e.g. summarizing a plan
4
+ * prompt into a name between the prompt editor closing and the next picker
5
+ * mounting). This is not the cancel-run surface: there is no agent table
6
+ * and no subagents to cancel. Esc abandons the wait rather than the flow
7
+ * that is waiting on it — the calling flow owns the overlay's lifetime
8
+ * through a session bridge, mirroring `CancelSession.closeOverlay`.
9
+ */
10
+
11
+ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
12
+ import { isKeyRelease, Key, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
13
+ import { setTimeout as sleep } from "node:timers/promises";
14
+ import { formatElapsed } from "./agentStatus.ts";
15
+ import { BRAILLE_SPINNER_FRAMES, ROUNDED_SINGLE_BOX, UI_TICK_MS, createFrame } from "./chrome.ts";
16
+ import { shimmerString, type ShimmerTheme } from "./shimmer.ts";
17
+
18
+ const OVERLAY_MARGIN = 1;
19
+ /** Grace before mounting: work that settles inside this never flashes a card. */
20
+ const SHOW_DELAY_MS = 200;
21
+ /** Once shown, stay up at least this long so the card is readable, not a blink. */
22
+ const MIN_VISIBLE_MS = 500;
23
+
24
+ export interface WorkingOverlayOptions<T> {
25
+ /** Accent title row, e.g. "Naming the plan". */
26
+ title: string;
27
+ /** Dim, shimmering body line naming the work. */
28
+ detail: string;
29
+ /** Footer hint. Defaults to a plain wait line when `skip` is omitted. */
30
+ hint?: string;
31
+ /** Value to resolve with when the user presses esc. Omit to make the wait un-skippable. */
32
+ skip?: () => T;
33
+ }
34
+
35
+ /** Bridge an outside owner uses to close an overlay it does not render. Mirrors `CancelSession.closeOverlay`. */
36
+ export interface WorkingOverlaySession {
37
+ closeOverlay?: () => void;
38
+ /** Set when close was requested before the component mounted. */
39
+ closeRequested?: boolean;
40
+ }
41
+
42
+ /** Ask a (possibly not-yet-mounted) overlay to close. Safe to call before `showWorkingOverlay`'s factory has run. */
43
+ export function requestWorkingOverlayClose(session: WorkingOverlaySession): void {
44
+ session.closeRequested = true;
45
+ session.closeOverlay?.();
46
+ }
47
+
48
+ interface WorkingOverlayRenderOptions {
49
+ title: string;
50
+ detail: string;
51
+ hint?: string;
52
+ onSkip?: () => void;
53
+ }
54
+
55
+ /** Show the working overlay. Resolves when closed. Outside TUI mode there is no overlay surface. */
56
+ export async function showWorkingOverlay(
57
+ ctx: ExtensionContext,
58
+ options: WorkingOverlayRenderOptions,
59
+ session: WorkingOverlaySession,
60
+ ): Promise<void> {
61
+ if (ctx.mode !== "tui") return;
62
+ await ctx.ui.custom<void>(
63
+ (tui, theme, _keybindings, done) => new WorkingOverlayComponent(tui, theme, options, session, done),
64
+ {
65
+ overlay: true,
66
+ overlayOptions: {
67
+ anchor: "center",
68
+ width: "50%",
69
+ minWidth: 40,
70
+ margin: OVERLAY_MARGIN,
71
+ },
72
+ },
73
+ );
74
+ }
75
+
76
+ type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
77
+
78
+ function unwrap<T>(settled: Settled<T>): T {
79
+ if (settled.ok) return settled.value;
80
+ throw settled.error;
81
+ }
82
+
83
+ /**
84
+ * Run `task` behind the working overlay. The overlay only mounts if `task`
85
+ * is still pending after `SHOW_DELAY_MS`, and once mounted stays up at
86
+ * least `MIN_VISIBLE_MS` so it never reads as a flicker. There is no
87
+ * automatic cutoff: a task that never settles keeps the card up until the
88
+ * user presses Esc (when `options.skip` is set) — deliberate, not an
89
+ * oversight, since the hint row is what turns that into a user choice
90
+ * rather than a hang.
91
+ */
92
+ export async function withWorkingOverlay<T>(
93
+ ctx: ExtensionContext,
94
+ options: WorkingOverlayOptions<T>,
95
+ task: (signal: AbortSignal) => Promise<T>,
96
+ ): Promise<T> {
97
+ const controller = new AbortController();
98
+ const work = task(controller.signal);
99
+ // No terminal surface: the caller still waits, but there is nothing to draw on.
100
+ if (ctx.mode !== "tui") return work;
101
+
102
+ // Reflect instead of letting `work` reject on its own timeline: it is raced
103
+ // against the grace timer and abandoned outright when the user skips, and an
104
+ // abandoned rejecting promise would otherwise surface as an unhandled rejection.
105
+ let pending = true;
106
+ const settled: Promise<Settled<T>> = work.then(
107
+ (value): Settled<T> => ({ ok: true, value }),
108
+ (error): Settled<T> => ({ ok: false, error }),
109
+ );
110
+ void settled.then(() => {
111
+ pending = false;
112
+ });
113
+
114
+ await Promise.race([settled, sleep(SHOW_DELAY_MS)]);
115
+ if (!pending) return unwrap(await settled);
116
+
117
+ const session: WorkingOverlaySession = {};
118
+ const shownAt = Date.now();
119
+ let skipped: { value: T } | undefined;
120
+ void settled.then(async () => {
121
+ if (skipped) return;
122
+ await sleep(Math.max(0, MIN_VISIBLE_MS - (Date.now() - shownAt)));
123
+ requestWorkingOverlayClose(session);
124
+ });
125
+
126
+ const skip = options.skip;
127
+ try {
128
+ await showWorkingOverlay(
129
+ ctx,
130
+ {
131
+ title: options.title,
132
+ detail: options.detail,
133
+ hint: options.hint,
134
+ // Esc skips: abort what we can, then leave the rest behind. The flow
135
+ // must move on even when a provider ignores the abort signal.
136
+ onSkip: skip
137
+ ? () => {
138
+ skipped = { value: skip() };
139
+ controller.abort();
140
+ }
141
+ : undefined,
142
+ },
143
+ session,
144
+ );
145
+ } catch {
146
+ // A stale ctx (e.g. after /reload) must not break the flow that is waiting.
147
+ }
148
+
149
+ if (skipped) return skipped.value;
150
+ return unwrap(await settled);
151
+ }
152
+
153
+ /** Copy for the plan/opinion/debate/spec naming wait. No trailing ellipses — see CHANGELOG 0.2.0. */
154
+ export function planNamingOverlay(subject: "plan" | "opinion" | "debate" | "spec", skip: () => string): WorkingOverlayOptions<string> {
155
+ return {
156
+ title: `Naming the ${subject}`,
157
+ detail: `summarizing your prompt into a short ${subject} name`,
158
+ hint: "esc skip naming · a name from your prompt is used instead",
159
+ skip,
160
+ };
161
+ }
162
+
163
+ /** The slice of pi's `Theme` this overlay needs. Structural so tests can stub it without `getFgAnsi`. */
164
+ interface WorkingOverlayTheme {
165
+ fg(color: ThemeColor, text: string): string;
166
+ bg(color: Parameters<Theme["bg"]>[0], text: string): string;
167
+ /** 24-bit ANSI escape for a color. Absent on test stubs, so the detail line falls back to flat text. */
168
+ getFgAnsi?(color: ThemeColor): string;
169
+ }
170
+
171
+ class WorkingOverlayComponent implements Component {
172
+ private readonly tui: TUI;
173
+ private readonly theme: WorkingOverlayTheme;
174
+ private readonly options: WorkingOverlayRenderOptions;
175
+ private readonly session: WorkingOverlaySession;
176
+ private readonly done: (value: void) => void;
177
+ private readonly startedAt: number;
178
+ private readonly timer: ReturnType<typeof setInterval>;
179
+ private spinFrame = 0;
180
+ private closed = false;
181
+
182
+ constructor(
183
+ tui: TUI,
184
+ theme: WorkingOverlayTheme,
185
+ options: WorkingOverlayRenderOptions,
186
+ session: WorkingOverlaySession,
187
+ done: (value: void) => void,
188
+ ) {
189
+ this.tui = tui;
190
+ this.theme = theme;
191
+ this.options = options;
192
+ this.session = session;
193
+ this.done = done;
194
+ this.startedAt = Date.now();
195
+ this.timer = setInterval(() => {
196
+ this.spinFrame = (this.spinFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
197
+ tui.requestRender();
198
+ }, UI_TICK_MS);
199
+ this.timer.unref();
200
+
201
+ session.closeOverlay = () => this.finish();
202
+ // The task can settle before `ctx.ui.custom` invokes this factory (only
203
+ // mounting triggers the factory). Defer so `done` is never called
204
+ // synchronously from inside the factory that constructs this component.
205
+ if (session.closeRequested) setTimeout(() => this.finish(), 0);
206
+ }
207
+
208
+ private finish(): void {
209
+ if (this.closed) return;
210
+ this.closed = true;
211
+ this.done(undefined);
212
+ }
213
+
214
+ handleInput(data: string): void {
215
+ // Under the Kitty keyboard protocol, the *release* of the key that
216
+ // closed the previous surface (e.g. Enter submitting the prompt editor)
217
+ // can land on this freshly mounted overlay a tick later. Only a press
218
+ // may skip. Same bug class as test/cancel-overlay-escape-release.test.mjs.
219
+ if (isKeyRelease(data)) return;
220
+ if (matchesKey(data, Key.escape)) {
221
+ if (this.options.onSkip) {
222
+ this.options.onSkip();
223
+ this.finish();
224
+ }
225
+ return;
226
+ }
227
+ // Everything else is swallowed: this is a blocking wait, not an
228
+ // interactive list, and stray keystrokes must not queue into whatever
229
+ // mounts next.
230
+ }
231
+
232
+ render(width: number): string[] {
233
+ const th = this.theme;
234
+ const innerWidth = Math.max(20, width - 4);
235
+ const frame = createFrame(th, innerWidth, {
236
+ glyphs: ROUNDED_SINGLE_BOX,
237
+ horizontalPadding: 1,
238
+ truncationMark: "…",
239
+ padToWidth: true,
240
+ minimumBodyWidth: 10,
241
+ });
242
+
243
+ const elapsed = formatElapsed(Date.now() - this.startedAt);
244
+ const titlePad = Math.max(1, frame.bodyWidth - visibleWidth(this.options.title) - visibleWidth(elapsed));
245
+ const titleRow = frame.row(`${th.fg("accent", this.options.title)}${" ".repeat(titlePad)}${th.fg("dim", elapsed)}`);
246
+
247
+ const spin = th.fg("accent", BRAILLE_SPINNER_FRAMES[this.spinFrame % BRAILLE_SPINNER_FRAMES.length]!);
248
+ const detailText = th.getFgAnsi
249
+ ? shimmerString(this.options.detail, Date.now() - this.startedAt, th as ShimmerTheme, "ltr", "normal")
250
+ : th.fg("dim", this.options.detail);
251
+ const detailRow = frame.row(`${spin} ${detailText}`);
252
+
253
+ const hint = this.options.hint ?? (this.options.onSkip ? "esc skip" : "please wait");
254
+ const hintRow = frame.row(th.fg("dim", hint));
255
+
256
+ return [frame.top(), titleRow, frame.separator(), detailRow, frame.separator(), hintRow, frame.bottom()];
257
+ }
258
+
259
+ invalidate(): void {}
260
+
261
+ dispose(): void {
262
+ clearInterval(this.timer);
263
+ if (this.session.closeOverlay) this.session.closeOverlay = undefined;
264
+ }
265
+ }