@monotykamary/pi-supervisor 0.5.9

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 (62) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +341 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/screenshot.png +0 -0
  6. package/package.json +87 -0
  7. package/src/compaction/brief.ts +841 -0
  8. package/src/compaction/build-sections.ts +340 -0
  9. package/src/compaction/causal-keys.ts +138 -0
  10. package/src/compaction/content.ts +68 -0
  11. package/src/compaction/extract/commits.ts +78 -0
  12. package/src/compaction/extract/goals.ts +79 -0
  13. package/src/compaction/extract/preferences.ts +52 -0
  14. package/src/compaction/extract/shared-symbols.ts +376 -0
  15. package/src/compaction/filter-noise.ts +47 -0
  16. package/src/compaction/format.ts +89 -0
  17. package/src/compaction/index.ts +38 -0
  18. package/src/compaction/normalize.ts +73 -0
  19. package/src/compaction/sanitize.ts +5 -0
  20. package/src/compaction/sections.ts +19 -0
  21. package/src/compaction/skill-collapse.ts +35 -0
  22. package/src/compaction/tool-args.ts +14 -0
  23. package/src/compaction/types.ts +26 -0
  24. package/src/core/analyzer.ts +58 -0
  25. package/src/core/index.ts +8 -0
  26. package/src/core/inference.ts +77 -0
  27. package/src/core/prompt-builder.ts +137 -0
  28. package/src/core/prompt-loader.ts +125 -0
  29. package/src/core/reframe.ts +27 -0
  30. package/src/fabric-provider.ts +115 -0
  31. package/src/global-config.ts +65 -0
  32. package/src/index.ts +514 -0
  33. package/src/session/client.ts +46 -0
  34. package/src/session/response-parser.ts +37 -0
  35. package/src/session/supervisor-session.ts +102 -0
  36. package/src/state/manager.ts +133 -0
  37. package/src/state/mid-run-signals.ts +103 -0
  38. package/src/state/patterns.ts +82 -0
  39. package/src/state/reframe.ts +27 -0
  40. package/src/subagent-detector.ts +94 -0
  41. package/src/types.ts +42 -0
  42. package/src/ui/animations.ts +95 -0
  43. package/src/ui/model-picker.ts +72 -0
  44. package/src/ui/model-settings-selector.ts +440 -0
  45. package/src/ui/model-sort.ts +101 -0
  46. package/src/ui/renderer.ts +314 -0
  47. package/src/ui/types.ts +48 -0
  48. package/tests/compaction.test.ts +507 -0
  49. package/tests/engine.test.ts +622 -0
  50. package/tests/ephemeral-supervision.test.ts +347 -0
  51. package/tests/fabric-provider.test.ts +55 -0
  52. package/tests/full-fidelity-snapshot.test.ts +250 -0
  53. package/tests/global-config.test.ts +74 -0
  54. package/tests/model-sort.test.ts +157 -0
  55. package/tests/parsing.test.ts +303 -0
  56. package/tests/state.test.ts +474 -0
  57. package/tests/status-widget.test.ts +539 -0
  58. package/tests/subagent-detector.test.ts +191 -0
  59. package/tests/supervise-command.test.ts +363 -0
  60. package/tests/supervise-model-command.test.ts +184 -0
  61. package/tsconfig.json +14 -0
  62. package/vitest.config.ts +15 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * model-sort.ts — integration with the pi-model-sort extension.
3
+ *
4
+ * The supervisor's model picker copies pi-core's /model selector. To "work
5
+ * together with" pi-model-sort (which monkey-patches pi-core's selector to
6
+ * sort by last usage), we read pi-model-sort's persisted timestamps and
7
+ * re-apply the identical sort order here, so the supervisor picker lists
8
+ * models in the user's actual usage order — matching what they see in pi's
9
+ * own /model selector.
10
+ *
11
+ * Mirrors pi-model-sort's sortByLastUsed algorithm exactly:
12
+ * 1. Current model first (if currentModelKey is provided)
13
+ * 2. Most recently used (highest timestamp) first
14
+ * 3. Provider name alphabetically
15
+ * 4. Model id alphabetically
16
+ *
17
+ * When pi-model-sort isn't installed or has no recorded usage, callers
18
+ * should fall back to pi-core's default provider sort.
19
+ *
20
+ * Config path: ~/.pi/agent/extensions/pi-model-sort.json (same file
21
+ * pi-model-sort reads/writes — sharing it keeps both pickers in sync).
22
+ */
23
+
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
27
+
28
+ const MODEL_SORT_CONFIG_PATH = join(getAgentDir(), 'extensions', 'pi-model-sort.json');
29
+
30
+ export interface LastUsedMap {
31
+ /** Map of "provider/modelId" → last-used Unix timestamp (ms). */
32
+ [providerModelKey: string]: number;
33
+ }
34
+
35
+ /** Build a stable model key from provider and model id (matches pi-model-sort). */
36
+ export function buildModelKey(provider: string, id: string): string {
37
+ return `${provider}/${id}`;
38
+ }
39
+
40
+ /**
41
+ * Read pi-model-sort's last-used timestamps from disk.
42
+ * Returns null when pi-model-sort isn't installed, has no config, or the
43
+ * config is unreadable — callers then fall back to the default sort.
44
+ */
45
+ export function readModelSortLastUsed(): LastUsedMap | null {
46
+ if (!existsSync(MODEL_SORT_CONFIG_PATH)) return null;
47
+ try {
48
+ const raw = readFileSync(MODEL_SORT_CONFIG_PATH, 'utf-8');
49
+ const parsed = JSON.parse(raw) as { lastUsed?: LastUsedMap };
50
+ if (!parsed.lastUsed || typeof parsed.lastUsed !== 'object') return null;
51
+ return parsed.lastUsed;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Whether enough usage data exists to sort by last-used (vs. falling back to
59
+ * the default provider sort). Exposed for tests and the selector.
60
+ */
61
+ export function hasUsageData(lastUsed: LastUsedMap | null): lastUsed is LastUsedMap {
62
+ return !!lastUsed && Object.keys(lastUsed).length > 0;
63
+ }
64
+
65
+ /**
66
+ * Sort models by last-used recency — a faithful copy of pi-model-sort's
67
+ * sortByLastUsed. Non-mutating.
68
+ *
69
+ * Sort order:
70
+ * 1. Current model first (if currentModelKey is provided)
71
+ * 2. Most recently used (highest timestamp) first
72
+ * 3. Provider name alphabetically
73
+ * 4. Model id alphabetically
74
+ *
75
+ * Models with no recorded usage get timestamp 0 (sorted last).
76
+ */
77
+ export function sortByLastUsed<T extends { provider: string; id: string }>(
78
+ items: T[],
79
+ lastUsed: LastUsedMap,
80
+ currentModelKey: string | null
81
+ ): T[] {
82
+ const sorted = [...items];
83
+ sorted.sort((a, b) => {
84
+ const aKey = buildModelKey(a.provider, a.id);
85
+ const bKey = buildModelKey(b.provider, b.id);
86
+
87
+ if (currentModelKey !== null) {
88
+ const aIsCurrent = aKey === currentModelKey;
89
+ const bIsCurrent = bKey === currentModelKey;
90
+ if (aIsCurrent && !bIsCurrent) return -1;
91
+ if (!aIsCurrent && bIsCurrent) return 1;
92
+ }
93
+
94
+ const aLast = lastUsed[aKey] ?? 0;
95
+ const bLast = lastUsed[bKey] ?? 0;
96
+ if (aLast !== bLast) return bLast - aLast;
97
+
98
+ return a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
99
+ });
100
+ return sorted;
101
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Widget renderer - handles the visual rendering of the supervisor status widget.
3
+ */
4
+
5
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
6
+ import { truncateToWidth } from '@earendil-works/pi-tui';
7
+ import type { SupervisorIntervention, SupervisorState } from '../types.js';
8
+ import type { WidgetAction, WidgetState } from './types.js';
9
+ import { WIDGET_ID, CLEAR_DELAY_MS } from './types.js';
10
+ import { startLineClearAnimation, type RenderFn } from './animations.js';
11
+
12
+ /** Toggle the widget on/off. Returns the new visibility state. */
13
+ export function toggleWidget(state: WidgetState): boolean {
14
+ state.widgetVisible = !state.widgetVisible;
15
+ return state.widgetVisible;
16
+ }
17
+
18
+ /** Update footer + widget. Call this every time state or action changes. */
19
+ export function updateUI(
20
+ ctx: ExtensionContext,
21
+ state: WidgetState,
22
+ supervisorState: SupervisorState | null,
23
+ action: WidgetAction = { type: 'watching' }
24
+ ): void {
25
+ // Check if we're receiving new thinking content
26
+ const hasNewThinking =
27
+ action.type === 'analyzing' && action.thinking && action.thinking !== state.lastThinking;
28
+
29
+ // Detect when leaving analyzing mode (for clear animation)
30
+ const wasAnalyzing = state.lastActionType === 'analyzing';
31
+ const isNowAnalyzing = action.type === 'analyzing';
32
+ const leavingAnalyzing = wasAnalyzing && !isNowAnalyzing;
33
+
34
+ if (state.clearTimer) {
35
+ clearTimeout(state.clearTimer);
36
+ state.clearTimer = null;
37
+ }
38
+ if (state.animationTimer) {
39
+ clearTimeout(state.animationTimer);
40
+ state.animationTimer = null;
41
+ }
42
+
43
+ // Reset animation state for new thinking (streaming replacement)
44
+ if (hasNewThinking) {
45
+ state.hiddenFromBottomCount = 0;
46
+ state.lastThinkingLines = [];
47
+ }
48
+
49
+ // When leaving analyzing, keep the thinking visible so it can animate away.
50
+ // For done: shown before delayed clear animation.
51
+ // For steering/watching: animated away immediately (no delay).
52
+ // Clear lastThinking when leaving to a non-done action so the render falls back
53
+ // to preserved lines (for animation) or shows no thinking (no lines to animate).
54
+ if (leavingAnalyzing && action.type !== 'done') {
55
+ state.lastThinking = '';
56
+ }
57
+
58
+ // Always update last state first
59
+ // For 'done', supervisorState may already be inactive (stopped before this call),
60
+ // so we also capture state on that transition.
61
+ if (supervisorState?.active || action.type === 'done') {
62
+ if (supervisorState) {
63
+ state.lastActiveState = {
64
+ outcome: supervisorState.outcome,
65
+ interventions: [...supervisorState.interventions],
66
+ };
67
+ }
68
+ state.lastActionType = action.type;
69
+ if (action.type === 'analyzing' && action.thinking) {
70
+ state.lastThinking = action.thinking;
71
+ }
72
+ }
73
+
74
+ // Handle inferring specially — but only if there's no thinking to animate away first.
75
+ // When transitioning from analyzing (with thinking lines visible) to inferring,
76
+ // we animate the thinking down before showing the inferring state.
77
+ if (action.type === 'inferring' && !(leavingAnalyzing && state.lastThinkingLines.length > 0)) {
78
+ // Clear any stale thinking when inferring from a non-analyzing state
79
+ if (leavingAnalyzing) {
80
+ state.lastThinking = '';
81
+ }
82
+ if (state.widgetVisible) {
83
+ const inferState = { outcome: '', interventions: state.lastActiveState?.interventions ?? [] };
84
+ renderWithState(ctx, state, inferState, action, '', 0);
85
+ }
86
+ return;
87
+ }
88
+
89
+ // We need the clear animation when:
90
+ // 1. Supervisor is inactive and thinking lines are still visible (stop), or
91
+ // 2. Leaving analyzing with thinking lines to a non-analyzing action (steering/watching).
92
+ // This animates the thinking text down instead of vanishing it instantly.
93
+ // 3. Done action — always show "✓ done" briefly before clearing, even without thinking.
94
+ const hasThinkingToAnimate = state.lastActiveState && state.lastThinkingLines.length > 0;
95
+ const isDoneTransition = action.type === 'done' && state.lastActiveState;
96
+ const needsClearAnimation =
97
+ (hasThinkingToAnimate && (!supervisorState?.active || leavingAnalyzing)) || isDoneTransition;
98
+
99
+ // Leaving analyzing to a non-done, non-analyzing action — animate the thinking
100
+ // text away immediately (no delay), so it doesn't vanish instantly.
101
+ // For 'done', use the delayed clear path instead so "✓ done" stays visible briefly.
102
+ if (needsClearAnimation && leavingAnalyzing && !isDoneTransition) {
103
+ const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => {
104
+ renderWithState(ctx, state, snap, action, thinking, hideFromBottom);
105
+ };
106
+ let fallbackAction: WidgetAction;
107
+ if (action.type === 'steering') {
108
+ fallbackAction = { type: 'steering', message: '', reframeTier: action.reframeTier };
109
+ } else if (action.type === 'waiting') {
110
+ fallbackAction = {
111
+ type: 'waiting',
112
+ message: action.message,
113
+ reframeTier: action.reframeTier,
114
+ };
115
+ } else if (action.type === 'inferring') {
116
+ fallbackAction = { type: 'inferring' };
117
+ } else {
118
+ const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0;
119
+ fallbackAction = { type: action.type, reframeTier } as WidgetAction;
120
+ }
121
+ state.lastActionType = fallbackAction.type;
122
+ state.storedAction = fallbackAction;
123
+ startLineClearAnimation(ctx, state, boundRender);
124
+ return;
125
+ }
126
+
127
+ // When supervisor becomes inactive (after stop), start the clear animation.
128
+ // This handles the 'done' transition: the widget shows all thinking lines,
129
+ // then after CLEAR_DELAY_MS they animate away.
130
+ if (needsClearAnimation) {
131
+ state.clearTimer = setTimeout(() => {
132
+ const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => {
133
+ renderWithState(ctx, state, snap, action, thinking, hideFromBottom);
134
+ };
135
+ startLineClearAnimation(ctx, state, boundRender);
136
+ }, CLEAR_DELAY_MS);
137
+ const fallbackAction: WidgetAction =
138
+ action.type === 'steering'
139
+ ? { type: 'steering', message: '', reframeTier: action.reframeTier }
140
+ : { type: 'done', reframeTier: 0 };
141
+ state.lastActionType = fallbackAction.type;
142
+ state.storedAction = fallbackAction;
143
+ // Render with lastThinkingLines content when lastThinking is empty
144
+ renderWithState(
145
+ ctx,
146
+ state,
147
+ state.lastActiveState!,
148
+ fallbackAction,
149
+ state.lastThinking,
150
+ state.hiddenFromBottomCount
151
+ );
152
+ return;
153
+ }
154
+
155
+ if (!supervisorState || !supervisorState.active) {
156
+ // Don't clear if a done clear animation is scheduled — it handles the teardown.
157
+ if (state.clearTimer || state.animationTimer) return;
158
+ state.lastThinkingLines = [];
159
+ ctx.ui.setWidget(WIDGET_ID, undefined);
160
+ return;
161
+ }
162
+
163
+ if (!state.widgetVisible) {
164
+ ctx.ui.setWidget(WIDGET_ID, undefined);
165
+ return;
166
+ }
167
+
168
+ renderWithState(
169
+ ctx,
170
+ state,
171
+ state.lastActiveState!,
172
+ action,
173
+ state.lastThinking,
174
+ state.hiddenFromBottomCount
175
+ );
176
+ }
177
+
178
+ /** Main render function - creates the widget content */
179
+ function renderWithState(
180
+ ctx: ExtensionContext,
181
+ widgetState: WidgetState,
182
+ snap: { outcome: string; interventions: SupervisorIntervention[] },
183
+ action: WidgetAction,
184
+ lastThinking: string,
185
+ hideFromBottom: number = 0
186
+ ): void {
187
+ ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
188
+ let actionStr: string;
189
+ let thinking = lastThinking;
190
+
191
+ switch (action.type) {
192
+ case 'watching':
193
+ actionStr = theme.fg('dim', 'watching');
194
+ break;
195
+ case 'analyzing':
196
+ actionStr = theme.fg('warning', '⟳ analyzing');
197
+ thinking = action.thinking ?? lastThinking;
198
+ break;
199
+ case 'steering':
200
+ actionStr = theme.fg('warning', 'steering');
201
+ break;
202
+ case 'done':
203
+ actionStr = theme.fg('accent', '✓ done');
204
+ break;
205
+ case 'waiting':
206
+ actionStr = theme.fg('warning', `⏳ ${action.message}`);
207
+ break;
208
+ break;
209
+ case 'inferring':
210
+ actionStr = theme.fg('dim', 'scanning');
211
+ break;
212
+ }
213
+
214
+ const sep = theme.fg('dim', ' · ');
215
+ let headerText: string;
216
+ if (action.type === 'done') headerText = 'Supervised';
217
+ else if (action.type === 'inferring') headerText = 'Inferring';
218
+ else headerText = 'Supervising';
219
+ const header = `${theme.fg('accent', '◉')} ${theme.fg('accent', headerText)}`;
220
+ const hasGoal = snap.outcome.length > 0;
221
+ const goalLabel = hasGoal ? `${theme.fg('dim', 'Goal:')} ` : '';
222
+ const goalQuoteOpen = hasGoal ? theme.fg('muted', '"') : '';
223
+ const goalQuoteClose = hasGoal ? theme.fg('muted', '"') : '';
224
+
225
+ const steerCount = snap.interventions.length;
226
+ const steers = steerCount > 0 ? theme.fg('dim', `↗ ${steerCount}`) : '';
227
+ const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0;
228
+ const reframeStr = reframeTier > 0 ? theme.fg('error', `↻${reframeTier}`) : '';
229
+ const suffixParts = [steers, reframeStr, actionStr].filter(Boolean);
230
+
231
+ const thinkingPrefix = theme.fg('dim', ' ');
232
+ const rawThinking = thinking;
233
+
234
+ return {
235
+ render: (width: number) => {
236
+ const paddedWidth = Math.max(0, width - 1);
237
+ widgetState.lastRenderedWidth = paddedWidth;
238
+ const suffix = suffixParts.length > 0 ? sep + suffixParts.join(sep) : '';
239
+
240
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
241
+
242
+ let line: string;
243
+ if (hasGoal) {
244
+ const prefix = header + sep + goalLabel + goalQuoteOpen;
245
+ const prefixWidth = stripAnsi(prefix).length;
246
+ const suffixWidth = stripAnsi(suffix).length;
247
+ const closeQuoteWidth = stripAnsi(goalQuoteClose).length;
248
+ const availableForGoal = Math.max(
249
+ 0,
250
+ paddedWidth - prefixWidth - suffixWidth - closeQuoteWidth
251
+ );
252
+ const rawGoal = snap.outcome.replace(/\r?\n/g, ' ');
253
+ const truncatedGoal = truncateToWidth(rawGoal, availableForGoal);
254
+ const goalText = theme.fg('muted', truncatedGoal);
255
+ line = prefix + goalText + goalQuoteClose + suffix;
256
+ } else {
257
+ const parts = [header, ...suffixParts].filter(Boolean);
258
+ line = parts.join(sep);
259
+ }
260
+ const l1 = truncateToWidth(line, paddedWidth);
261
+
262
+ if (!rawThinking) {
263
+ // No live thinking text — render from preserved lines for display/animation
264
+ // (e.g. after the supervisor is done, lastThinking is empty but lastThinkingLines
265
+ // holds the previously rendered lines for display/animation).
266
+ if (widgetState.lastThinkingLines.length > 0) {
267
+ const visibleCount = Math.max(0, widgetState.lastThinkingLines.length - hideFromBottom);
268
+ const visibleLines = widgetState.lastThinkingLines
269
+ .slice(0, visibleCount)
270
+ .map((ln) => truncateToWidth(theme.fg('dim', ln), paddedWidth));
271
+ if (visibleCount === 0) return [l1];
272
+ return [l1, ...visibleLines];
273
+ }
274
+ return [l1];
275
+ }
276
+
277
+ const thinkingIndent = stripAnsi(thinkingPrefix).length;
278
+ const maxContentWidth = Math.max(0, paddedWidth - thinkingIndent);
279
+ const thinkingWords = rawThinking.replace(/[\r\n]+/g, ' ').split(' ');
280
+ const thinkingLines: string[] = [];
281
+ const plainLines: string[] = [];
282
+ let currentThinkingLine = '';
283
+ let currentPlainLine = '';
284
+
285
+ for (const word of thinkingWords) {
286
+ const testLine = currentPlainLine ? `${currentPlainLine} ${word}` : word;
287
+ if (testLine.length <= maxContentWidth) {
288
+ currentPlainLine = testLine;
289
+ currentThinkingLine = currentThinkingLine ? `${currentThinkingLine} ${word}` : word;
290
+ } else {
291
+ if (currentThinkingLine) {
292
+ thinkingLines.push(
293
+ truncateToWidth(thinkingPrefix + theme.fg('dim', currentThinkingLine), paddedWidth)
294
+ );
295
+ plainLines.push(' ' + currentPlainLine);
296
+ }
297
+ currentPlainLine = word;
298
+ currentThinkingLine = word;
299
+ }
300
+ }
301
+ if (currentThinkingLine) {
302
+ thinkingLines.push(
303
+ truncateToWidth(thinkingPrefix + theme.fg('dim', currentThinkingLine), paddedWidth)
304
+ );
305
+ plainLines.push(' ' + currentPlainLine);
306
+ }
307
+
308
+ widgetState.lastThinkingLines = plainLines;
309
+ return [l1, ...thinkingLines];
310
+ },
311
+ invalidate: () => {},
312
+ };
313
+ });
314
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * UI types and state for the supervisor widget.
3
+ */
4
+
5
+ import type { SupervisorIntervention } from '../types.js';
6
+
7
+ export type WidgetAction =
8
+ | { type: 'watching'; reframeTier?: number }
9
+ | { type: 'analyzing'; reframeTier?: number; thinking?: string }
10
+ | { type: 'steering'; message: string; reframeTier?: number }
11
+ | { type: 'done'; reframeTier?: number }
12
+ | { type: 'waiting'; message: string; reframeTier?: number }
13
+ | { type: 'inferring' };
14
+
15
+ /** Internal UI state for the widget */
16
+ export interface WidgetState {
17
+ widgetVisible: boolean;
18
+ lastActiveState: { outcome: string; interventions: SupervisorIntervention[] } | null;
19
+ lastThinking: string;
20
+ lastActionType: WidgetAction['type'];
21
+ storedAction: WidgetAction | null;
22
+ lastRenderedWidth: number;
23
+ lastThinkingLines: string[];
24
+ hiddenFromBottomCount: number;
25
+ clearTimer: ReturnType<typeof setTimeout> | null;
26
+ animationTimer: ReturnType<typeof setTimeout> | null;
27
+ }
28
+
29
+ /** Create initial widget state */
30
+ export function createInitialState(): WidgetState {
31
+ return {
32
+ widgetVisible: true,
33
+ lastActiveState: null,
34
+ lastThinking: '',
35
+ lastActionType: 'watching',
36
+ storedAction: null,
37
+ lastRenderedWidth: 80,
38
+ lastThinkingLines: [],
39
+ hiddenFromBottomCount: 0,
40
+ clearTimer: null,
41
+ animationTimer: null,
42
+ };
43
+ }
44
+
45
+ /** Constants for widget behavior */
46
+ export const WIDGET_ID = 'supervisor';
47
+ export const CLEAR_DELAY_MS = 15000;
48
+ export const ANIMATION_STEP_MS = 500;