@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.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { SupervisorState } from './types.js';
|
|
3
|
+
|
|
4
|
+
const FABRIC_PROVIDER_REGISTER_EVENT = 'pi-fabric:provider:register:v1';
|
|
5
|
+
const FABRIC_PROVIDER_DISCOVER_EVENT = 'pi-fabric:provider:discover:v1';
|
|
6
|
+
|
|
7
|
+
interface FabricActionDescriptor {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
inputSchema: Record<string, unknown>;
|
|
11
|
+
risk: 'read' | 'agent';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FabricInvocationContext {
|
|
15
|
+
extensionContext: ExtensionContext;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FabricProviderDiscovery {
|
|
19
|
+
version: 1;
|
|
20
|
+
register(provider: FabricProvider, options?: { overwrite?: boolean }): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface FabricProvider {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
list(request: { query?: string }): Promise<FabricActionDescriptor[]>;
|
|
27
|
+
describe(actionName: string): Promise<FabricActionDescriptor | undefined>;
|
|
28
|
+
invoke(
|
|
29
|
+
actionName: string,
|
|
30
|
+
args: Record<string, unknown>,
|
|
31
|
+
context: FabricInvocationContext
|
|
32
|
+
): Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SupervisorFabricController {
|
|
36
|
+
start(outcome: string, context: ExtensionContext): Promise<string>;
|
|
37
|
+
getState(): SupervisorState | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const descriptors: FabricActionDescriptor[] = [
|
|
41
|
+
{
|
|
42
|
+
name: 'start',
|
|
43
|
+
description:
|
|
44
|
+
'Start persistent supervision toward an explicit outcome. Active supervision remains locked and cannot be replaced by the model.',
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
outcome: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Specific measurable end-state for the supervisor to enforce',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
required: ['outcome'],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
},
|
|
56
|
+
risk: 'agent',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'status',
|
|
60
|
+
description: 'Read the current supervision state',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {},
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
risk: 'read',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export const registerFabricProvider = (
|
|
71
|
+
pi: ExtensionAPI,
|
|
72
|
+
controller: SupervisorFabricController
|
|
73
|
+
): void => {
|
|
74
|
+
const provider: FabricProvider = {
|
|
75
|
+
name: 'supervisor',
|
|
76
|
+
description: 'Persistent goal supervision from pi-supervisor',
|
|
77
|
+
async list(request) {
|
|
78
|
+
const query = request.query?.toLowerCase();
|
|
79
|
+
return query
|
|
80
|
+
? descriptors.filter((descriptor) =>
|
|
81
|
+
`${descriptor.name} ${descriptor.description}`.toLowerCase().includes(query)
|
|
82
|
+
)
|
|
83
|
+
: descriptors;
|
|
84
|
+
},
|
|
85
|
+
async describe(actionName) {
|
|
86
|
+
return descriptors.find((descriptor) => descriptor.name === actionName);
|
|
87
|
+
},
|
|
88
|
+
async invoke(actionName, args, context) {
|
|
89
|
+
if (actionName === 'status') return controller.getState();
|
|
90
|
+
if (actionName === 'start') {
|
|
91
|
+
return {
|
|
92
|
+
message: await controller.start(String(args.outcome), context.extensionContext),
|
|
93
|
+
state: controller.getState(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Unknown supervisor Fabric action: ${actionName}`);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const register = (): void => {
|
|
101
|
+
pi.events.emit(FABRIC_PROVIDER_REGISTER_EVENT, {
|
|
102
|
+
version: 1,
|
|
103
|
+
provider,
|
|
104
|
+
overwrite: true,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
register();
|
|
109
|
+
pi.events.on(FABRIC_PROVIDER_DISCOVER_EVENT, (value: unknown) => {
|
|
110
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return;
|
|
111
|
+
const event = value as Partial<FabricProviderDiscovery>;
|
|
112
|
+
if (event.version !== 1 || typeof event.register !== 'function') return;
|
|
113
|
+
event.register(provider, { overwrite: true });
|
|
114
|
+
});
|
|
115
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* global-config.ts — workspace-level supervisor configuration.
|
|
3
|
+
*
|
|
4
|
+
* Saves/loads supervisor model selection.
|
|
5
|
+
* Stored at <cwd>/.pi/supervisor-config.json
|
|
6
|
+
*
|
|
7
|
+
* Removed: sensitivity (now automatic)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = '.pi';
|
|
14
|
+
const CONFIG_FILE = 'supervisor-config.json';
|
|
15
|
+
|
|
16
|
+
interface SupervisorConfig {
|
|
17
|
+
model?: {
|
|
18
|
+
provider: string;
|
|
19
|
+
modelId: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Load the supervisor model config from cwd/.pi/supervisor-config.json if it exists. */
|
|
24
|
+
export function loadGlobalModel(): { provider: string; modelId: string } | null {
|
|
25
|
+
const configPath = join(process.cwd(), CONFIG_DIR, CONFIG_FILE);
|
|
26
|
+
if (!existsSync(configPath)) return null;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
30
|
+
const parsed = JSON.parse(content) as SupervisorConfig;
|
|
31
|
+
if (parsed.model?.provider && parsed.model?.modelId) {
|
|
32
|
+
return { provider: parsed.model.provider, modelId: parsed.model.modelId };
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore parse errors
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save the supervisor model config to <cwd>/.pi/supervisor-config.json,
|
|
42
|
+
* preserving any other keys already present. Creates the .pi directory if
|
|
43
|
+
* missing. Returns the written config path.
|
|
44
|
+
*/
|
|
45
|
+
export function saveGlobalModel(cwd: string, model: { provider: string; modelId: string }): string {
|
|
46
|
+
const dir = join(cwd, CONFIG_DIR);
|
|
47
|
+
const configPath = join(dir, CONFIG_FILE);
|
|
48
|
+
|
|
49
|
+
let existing: SupervisorConfig = {};
|
|
50
|
+
if (existsSync(configPath)) {
|
|
51
|
+
try {
|
|
52
|
+
existing = JSON.parse(readFileSync(configPath, 'utf-8')) as SupervisorConfig;
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore parse errors — start fresh
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!existsSync(dir)) {
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const merged: SupervisorConfig = { ...existing, model };
|
|
63
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
64
|
+
return configPath;
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-supervisor — A pi extension that supervises the chat and steers it toward a defined outcome.
|
|
3
|
+
*
|
|
4
|
+
* Uses algorithmic compaction (normalize → filter → build-sections) to build
|
|
5
|
+
* structured conversation context for the supervisor LLM, instead of
|
|
6
|
+
* tracking turns or maintaining rolling message buffers.
|
|
7
|
+
*
|
|
8
|
+
* Commands:
|
|
9
|
+
* /supervise — auto-infer goal from conversation
|
|
10
|
+
* /supervise <outcome> — start supervising with explicit goal
|
|
11
|
+
* /supervise stop — stop supervising
|
|
12
|
+
* /supervise widget — toggle the status widget on/off
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
17
|
+
import { SupervisorStateManager } from './state/manager.js';
|
|
18
|
+
import { analyze } from './core/analyzer.js';
|
|
19
|
+
import { inferOutcome } from './core/inference.js';
|
|
20
|
+
import { loadSystemPrompt } from './core/prompt-loader.js';
|
|
21
|
+
import { updateUI, toggleWidget } from './ui/renderer.js';
|
|
22
|
+
import { pickModel } from './ui/model-picker.js';
|
|
23
|
+
import { loadGlobalModel, saveGlobalModel } from './global-config.js';
|
|
24
|
+
import { disposeSession } from './session/client.js';
|
|
25
|
+
import { Type } from '@sinclair/typebox';
|
|
26
|
+
import { checkChildPiProcesses, waitForSubagents } from './subagent-detector.js';
|
|
27
|
+
import { detectMidRunSignals } from './state/mid-run-signals.js';
|
|
28
|
+
import { registerFabricProvider } from './fabric-provider.js';
|
|
29
|
+
import { createInitialState, type WidgetState } from './ui/types.js';
|
|
30
|
+
import {
|
|
31
|
+
extractMessages,
|
|
32
|
+
buildCompactionSummary,
|
|
33
|
+
formatForSupervisor,
|
|
34
|
+
} from './compaction/index.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract partial reasoning text from the supervisor's streaming JSON response.
|
|
38
|
+
*/
|
|
39
|
+
export function extractThinking(accumulated: string): string {
|
|
40
|
+
const keyIdx = accumulated.indexOf('"reasoning"');
|
|
41
|
+
if (keyIdx === -1) return '';
|
|
42
|
+
const after = accumulated.slice(keyIdx + '"reasoning"'.length);
|
|
43
|
+
const openMatch = after.match(/^\s*:\s*"/);
|
|
44
|
+
if (!openMatch) return '';
|
|
45
|
+
const content = after.slice(openMatch[0].length);
|
|
46
|
+
const closeIdx = content.search(/(?<!\\)"/);
|
|
47
|
+
const raw = closeIdx === -1 ? content : content.slice(0, closeIdx);
|
|
48
|
+
return raw.replace(/\\n/g, ' ').replace(/\\"/g, '"').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function truncateForNotify(message: string, reserveChars: number = 20): string {
|
|
52
|
+
const terminalWidth = process.stdout.columns || 100;
|
|
53
|
+
const maxContentWidth = Math.max(20, terminalWidth - reserveChars);
|
|
54
|
+
return truncateToWidth(message.replace(/\r?\n/g, ' '), maxContentWidth, '…');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check if the session has any user messages in its history. */
|
|
58
|
+
function hasUserMessages(ctx: ExtensionContext): boolean {
|
|
59
|
+
const messages = extractMessages(ctx);
|
|
60
|
+
for (const msg of messages) {
|
|
61
|
+
if (msg.role === 'user') {
|
|
62
|
+
const content =
|
|
63
|
+
typeof msg.content === 'string'
|
|
64
|
+
? msg.content
|
|
65
|
+
: Array.isArray(msg.content)
|
|
66
|
+
? msg.content
|
|
67
|
+
.filter((b: any) => b.type === 'text')
|
|
68
|
+
.map((b: any) => b.text)
|
|
69
|
+
.join('\n')
|
|
70
|
+
.trim()
|
|
71
|
+
: '';
|
|
72
|
+
if (content && content.length > 0) return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function (pi: ExtensionAPI) {
|
|
79
|
+
const state = new SupervisorStateManager(pi);
|
|
80
|
+
const widgetState = createInitialState();
|
|
81
|
+
let currentCtx: ExtensionContext | undefined;
|
|
82
|
+
|
|
83
|
+
const startSupervisionFromModel = async (
|
|
84
|
+
outcome: string,
|
|
85
|
+
ctx: ExtensionContext
|
|
86
|
+
): Promise<string> => {
|
|
87
|
+
if (state.isActive()) {
|
|
88
|
+
const activeState = state.getState()!;
|
|
89
|
+
return (
|
|
90
|
+
`Supervision is already active and cannot be changed by the model.\n` +
|
|
91
|
+
`Active outcome: "${activeState.outcome}"\n` +
|
|
92
|
+
`Only the user can stop or modify supervision via /supervise.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const globalModel = loadGlobalModel();
|
|
97
|
+
const sessionModel = ctx.model;
|
|
98
|
+
const provider = globalModel?.provider ?? sessionModel?.provider ?? 'unknown';
|
|
99
|
+
const modelId = globalModel?.modelId ?? sessionModel?.id ?? 'unknown';
|
|
100
|
+
|
|
101
|
+
state.start(outcome, provider, modelId);
|
|
102
|
+
currentCtx = ctx;
|
|
103
|
+
updateUI(ctx, widgetState, state.getState());
|
|
104
|
+
|
|
105
|
+
if (ctx.isIdle()) {
|
|
106
|
+
pi.sendUserMessage(`Please start working on this goal: ${outcome}`, {
|
|
107
|
+
deliverAs: 'followUp',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ctx.ui.notify(`Supervisor started by agent: "${truncateForNotify(outcome, 30)}"`, 'info');
|
|
112
|
+
return `Supervision active. Outcome: "${outcome}"`;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
registerFabricProvider(pi, {
|
|
116
|
+
start: startSupervisionFromModel,
|
|
117
|
+
getState: () => state.getState(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---- Session lifecycle: restore state ----
|
|
121
|
+
|
|
122
|
+
const onSessionLoad = (ctx: ExtensionContext) => {
|
|
123
|
+
currentCtx = ctx;
|
|
124
|
+
state.loadFromSession(ctx);
|
|
125
|
+
|
|
126
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
127
|
+
state.stop();
|
|
128
|
+
disposeSession();
|
|
129
|
+
ctx.ui.notify('Supervision cleared: agent is idle', 'info');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
updateUI(ctx, widgetState, state.getState());
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
pi.on('session_start', async (_event, ctx) => onSessionLoad(ctx));
|
|
136
|
+
pi.on('session_start', async (event, ctx) => {
|
|
137
|
+
if (event.reason === 'startup' || event.reason === 'reload') return;
|
|
138
|
+
onSessionLoad(ctx);
|
|
139
|
+
});
|
|
140
|
+
pi.on('session_tree', async (_event, ctx) => onSessionLoad(ctx));
|
|
141
|
+
|
|
142
|
+
// ---- Compaction survival: persist state BEFORE compaction ----
|
|
143
|
+
pi.on('session_before_compact', async (_event, ctx) => {
|
|
144
|
+
if (state.isActive()) {
|
|
145
|
+
state.persist();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---- After compaction: reload state and continue if agent is working ----
|
|
150
|
+
pi.on('session_compact', async (event, ctx) => {
|
|
151
|
+
currentCtx = ctx;
|
|
152
|
+
state.loadFromSession(ctx);
|
|
153
|
+
|
|
154
|
+
if (!state.isActive()) {
|
|
155
|
+
updateUI(ctx, widgetState, null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Skip the clear-on-idle teardown when an overflow retry is pending
|
|
160
|
+
// (event.willRetry). The aborted turn resumes after compaction, so
|
|
161
|
+
// agent_settled will fire again with the full resumed run and the
|
|
162
|
+
// supervision loop should stay attached.
|
|
163
|
+
if (ctx.isIdle() && !event.willRetry) {
|
|
164
|
+
state.stop();
|
|
165
|
+
disposeSession();
|
|
166
|
+
ctx.ui.notify('Supervision cleared: compaction complete, agent idle', 'info');
|
|
167
|
+
updateUI(ctx, widgetState, null);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
172
|
+
type: 'watching',
|
|
173
|
+
reframeTier: state.getReframeTier(),
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---- Keep ctx fresh ----
|
|
178
|
+
|
|
179
|
+
pi.on('turn_start', async (_event, ctx) => {
|
|
180
|
+
currentCtx = ctx;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---- Mid-run steering: signal-based ----
|
|
184
|
+
// turn_end fires after each LLM sub-turn while agent is still running.
|
|
185
|
+
// Instead of a blind turn counter, we check for reactive signals:
|
|
186
|
+
// - just steered → verify it worked
|
|
187
|
+
// - tool error → check if the agent is stuck
|
|
188
|
+
// - file read loop → same file read 4+ times without an edit
|
|
189
|
+
// - read-only stagnation → 8+ consecutive read calls without a mutation
|
|
190
|
+
|
|
191
|
+
pi.on('turn_end', async (_event, ctx) => {
|
|
192
|
+
currentCtx = ctx;
|
|
193
|
+
if (!state.isActive()) return;
|
|
194
|
+
|
|
195
|
+
const messages = extractMessages(ctx);
|
|
196
|
+
const signal = detectMidRunSignals(messages);
|
|
197
|
+
if (!signal) return;
|
|
198
|
+
|
|
199
|
+
let decision;
|
|
200
|
+
try {
|
|
201
|
+
decision = await analyze(ctx, state.getState()!, false /* agent still working */);
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (decision.action === 'steer' && decision.message && decision.confidence >= 0.85) {
|
|
207
|
+
state.addIntervention({
|
|
208
|
+
message: decision.message,
|
|
209
|
+
reasoning: decision.reasoning,
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
asi: decision.asi,
|
|
212
|
+
});
|
|
213
|
+
updateUI(ctx, widgetState, state.getState(), { type: 'steering', message: decision.message });
|
|
214
|
+
pi.sendUserMessage(decision.message, { deliverAs: 'steer' });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---- After each agent run: analyze + steer ----
|
|
219
|
+
// agent_settled fires once pi has fully settled — auto-retries, overflow-
|
|
220
|
+
// compaction recovery, and queued follow-up messages are all done.
|
|
221
|
+
|
|
222
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
223
|
+
currentCtx = ctx;
|
|
224
|
+
if (!state.isActive()) return;
|
|
225
|
+
|
|
226
|
+
const s = state.getState()!;
|
|
227
|
+
|
|
228
|
+
// Check for child subagent processes
|
|
229
|
+
const subagentStatus = await checkChildPiProcesses();
|
|
230
|
+
if (subagentStatus.hasActiveSubagents) {
|
|
231
|
+
updateUI(ctx, widgetState, s, {
|
|
232
|
+
type: 'waiting',
|
|
233
|
+
message: `Waiting for ${subagentStatus.count} subagent(s)...`,
|
|
234
|
+
reframeTier: state.getReframeTier(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const { completed, finalStatus } = await waitForSubagents(2000, 120000);
|
|
238
|
+
|
|
239
|
+
if (!completed && finalStatus.hasActiveSubagents) {
|
|
240
|
+
ctx.ui.notify(
|
|
241
|
+
`Supervisor: ${finalStatus.count} subagent(s) still running after timeout, proceeding with analysis`,
|
|
242
|
+
'warning'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
updateUI(ctx, widgetState, s, {
|
|
247
|
+
type: 'analyzing',
|
|
248
|
+
reframeTier: state.getReframeTier(),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check for ineffective steering patterns
|
|
253
|
+
const ineffectivePattern = state.detectIneffectivePattern();
|
|
254
|
+
if (ineffectivePattern.detected && state.getReframeTier() < 4) {
|
|
255
|
+
state.escalateReframeTier();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
updateUI(ctx, widgetState, state.getState()!, {
|
|
259
|
+
type: 'analyzing',
|
|
260
|
+
reframeTier: state.getReframeTier(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const decision = await analyze(
|
|
264
|
+
ctx,
|
|
265
|
+
state.getState()!,
|
|
266
|
+
true /* always idle at agent_end */,
|
|
267
|
+
ineffectivePattern,
|
|
268
|
+
undefined,
|
|
269
|
+
(accumulated) => {
|
|
270
|
+
const thinking = extractThinking(accumulated);
|
|
271
|
+
updateUI(ctx, widgetState, state.getState()!, {
|
|
272
|
+
type: 'analyzing',
|
|
273
|
+
reframeTier: state.getReframeTier(),
|
|
274
|
+
thinking,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (decision.action === 'steer' && decision.message) {
|
|
280
|
+
state.incrementIdleSteers();
|
|
281
|
+
state.addIntervention({
|
|
282
|
+
message: decision.message,
|
|
283
|
+
reasoning: decision.reasoning,
|
|
284
|
+
timestamp: Date.now(),
|
|
285
|
+
asi: decision.asi,
|
|
286
|
+
});
|
|
287
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
288
|
+
type: 'steering',
|
|
289
|
+
message: decision.message,
|
|
290
|
+
reframeTier: state.getReframeTier(),
|
|
291
|
+
});
|
|
292
|
+
pi.sendUserMessage(decision.message, { deliverAs: 'steer' });
|
|
293
|
+
} else if (decision.action === 'done') {
|
|
294
|
+
state.resetIdleSteers();
|
|
295
|
+
state.resetReframeTier();
|
|
296
|
+
// Show 'done' with the outcome still visible before stopping
|
|
297
|
+
updateUI(ctx, widgetState, state.getState(), { type: 'done' });
|
|
298
|
+
state.stop();
|
|
299
|
+
disposeSession();
|
|
300
|
+
} else {
|
|
301
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
302
|
+
type: 'watching',
|
|
303
|
+
reframeTier: state.getReframeTier(),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---- /supervise command ----
|
|
309
|
+
|
|
310
|
+
pi.registerCommand('supervise', {
|
|
311
|
+
description: 'Supervise the chat toward a desired outcome (/supervise or /supervise <outcome>)',
|
|
312
|
+
getArgumentCompletions(prefix: string) {
|
|
313
|
+
const subcommands = [
|
|
314
|
+
{ value: 'model', label: 'model', description: 'Pick the supervisor model' },
|
|
315
|
+
{ value: 'stop', label: 'stop', description: 'Stop active supervision' },
|
|
316
|
+
{ value: 'widget', label: 'widget', description: 'Toggle the status widget' },
|
|
317
|
+
];
|
|
318
|
+
const matches = subcommands.filter((s) => s.value.startsWith(prefix));
|
|
319
|
+
return matches.length > 0 ? matches : null;
|
|
320
|
+
},
|
|
321
|
+
handler: async (args, ctx) => {
|
|
322
|
+
currentCtx = ctx;
|
|
323
|
+
const trimmed = args?.trim() ?? '';
|
|
324
|
+
|
|
325
|
+
// --- subcommands ---
|
|
326
|
+
|
|
327
|
+
if (trimmed === 'widget') {
|
|
328
|
+
const visible = toggleWidget(widgetState);
|
|
329
|
+
if (state.isActive()) {
|
|
330
|
+
updateUI(ctx, widgetState, state.getState());
|
|
331
|
+
}
|
|
332
|
+
ctx.ui.notify(`Supervisor widget ${visible ? 'shown' : 'hidden'}.`, 'info');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (trimmed === 'stop') {
|
|
337
|
+
if (!state.isActive()) {
|
|
338
|
+
ctx.ui.notify('Supervisor is not active.', 'warning');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
state.stop();
|
|
342
|
+
state.resetIdleSteers();
|
|
343
|
+
disposeSession();
|
|
344
|
+
updateUI(ctx, widgetState, state.getState());
|
|
345
|
+
ctx.ui.notify('Supervisor stopped.', 'info');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// /supervise model — pick the supervisor model and persist it to
|
|
350
|
+
// <cwd>/.pi/supervisor-config.json. Pre-highlights the model that the
|
|
351
|
+
// supervisor would currently use (active state > config > chat model).
|
|
352
|
+
// If supervision is active, the live session model is updated too.
|
|
353
|
+
if (trimmed === 'model') {
|
|
354
|
+
const existing = state.getState();
|
|
355
|
+
const globalModel = loadGlobalModel();
|
|
356
|
+
const sessionModel = ctx.model;
|
|
357
|
+
const currentProvider =
|
|
358
|
+
existing?.provider ?? globalModel?.provider ?? sessionModel?.provider;
|
|
359
|
+
const currentModelId = existing?.modelId ?? globalModel?.modelId ?? sessionModel?.id;
|
|
360
|
+
|
|
361
|
+
const picked = await pickModel(ctx, currentProvider, currentModelId);
|
|
362
|
+
if (!picked) {
|
|
363
|
+
ctx.ui.notify('Supervisor model selection cancelled.', 'info');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const configPath = saveGlobalModel(ctx.cwd, {
|
|
368
|
+
provider: picked.provider,
|
|
369
|
+
modelId: picked.id,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (state.isActive() && existing) {
|
|
373
|
+
state.setModel(picked.provider, picked.id);
|
|
374
|
+
updateUI(ctx, widgetState, state.getState());
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
ctx.ui.notify(
|
|
378
|
+
`Supervisor model set to ${picked.provider}/${picked.id} (saved to ${configPath}).`,
|
|
379
|
+
'info'
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- infer goal from conversation (no args) ---
|
|
385
|
+
|
|
386
|
+
if (!trimmed) {
|
|
387
|
+
const s = state.getState();
|
|
388
|
+
const globalModel = loadGlobalModel();
|
|
389
|
+
const sessionModel = ctx.model;
|
|
390
|
+
let provider = s?.provider ?? globalModel?.provider ?? sessionModel?.provider ?? 'unknown';
|
|
391
|
+
let modelId = s?.modelId ?? globalModel?.modelId ?? sessionModel?.id ?? 'unknown';
|
|
392
|
+
|
|
393
|
+
const hasConversation = !s?.active && hasUserMessages(ctx);
|
|
394
|
+
if (!hasConversation) {
|
|
395
|
+
ctx.ui.notify(
|
|
396
|
+
'No conversation history found. Use /supervise <goal> to set an explicit goal.',
|
|
397
|
+
'warning'
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!s) {
|
|
403
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider);
|
|
404
|
+
if (!apiKey) {
|
|
405
|
+
ctx.ui.notify(
|
|
406
|
+
`No API key for "${provider}/${modelId}" — pick a model with an available key.`,
|
|
407
|
+
'warning'
|
|
408
|
+
);
|
|
409
|
+
const picked = await pickModel(ctx, provider, modelId);
|
|
410
|
+
if (!picked) return;
|
|
411
|
+
provider = picked.provider;
|
|
412
|
+
modelId = picked.id;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
updateUI(ctx, widgetState, state.getState(), { type: 'inferring' });
|
|
417
|
+
const inferred = await inferOutcome(ctx, provider, modelId);
|
|
418
|
+
updateUI(ctx, widgetState, state.getState());
|
|
419
|
+
|
|
420
|
+
if (!inferred) {
|
|
421
|
+
ctx.ui.notify(
|
|
422
|
+
'Could not infer goal from conversation. Use /supervise <goal> to set an explicit goal.',
|
|
423
|
+
'warning'
|
|
424
|
+
);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
state.start(inferred, provider, modelId);
|
|
429
|
+
updateUI(ctx, widgetState, state.getState());
|
|
430
|
+
|
|
431
|
+
if (ctx.isIdle()) {
|
|
432
|
+
pi.sendUserMessage(`Please start working on this goal: ${inferred}`, {
|
|
433
|
+
deliverAs: 'followUp',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
ctx.ui.notify(`Supervisor active: "${truncateForNotify(inferred, 25)}"`, 'info');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Resolve model settings
|
|
442
|
+
const existing = state.getState();
|
|
443
|
+
const globalModel = loadGlobalModel();
|
|
444
|
+
const sessionModel = ctx.model;
|
|
445
|
+
let provider =
|
|
446
|
+
existing?.provider ?? globalModel?.provider ?? sessionModel?.provider ?? 'unknown';
|
|
447
|
+
let modelId = existing?.modelId ?? globalModel?.modelId ?? sessionModel?.id ?? 'unknown';
|
|
448
|
+
|
|
449
|
+
if (state.isActive() && existing) {
|
|
450
|
+
const appendedOutcome = `${existing.outcome}. Additionally: ${trimmed}`;
|
|
451
|
+
state.updateOutcome(appendedOutcome);
|
|
452
|
+
updateUI(ctx, widgetState, state.getState());
|
|
453
|
+
|
|
454
|
+
ctx.ui.notify(
|
|
455
|
+
`Supervisor goal expanded: "${truncateForNotify(trimmed, 30)}" added to active supervision.`,
|
|
456
|
+
'info'
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!existing) {
|
|
462
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider);
|
|
463
|
+
if (!apiKey) {
|
|
464
|
+
ctx.ui.notify(
|
|
465
|
+
`No API key for "${provider}/${modelId}" — pick a model with an available key.`,
|
|
466
|
+
'warning'
|
|
467
|
+
);
|
|
468
|
+
const picked = await pickModel(ctx, provider, modelId);
|
|
469
|
+
if (!picked) return;
|
|
470
|
+
provider = picked.provider;
|
|
471
|
+
modelId = picked.id;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
state.start(trimmed, provider, modelId);
|
|
476
|
+
updateUI(ctx, widgetState, state.getState());
|
|
477
|
+
|
|
478
|
+
if (ctx.isIdle()) {
|
|
479
|
+
pi.sendUserMessage(`Please start working on this goal: ${trimmed}`, {
|
|
480
|
+
deliverAs: 'followUp',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
ctx.ui.notify(`Supervisor active: "${truncateForNotify(trimmed, 25)}"`, 'info');
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ---- Tool: model can initiate supervision but never modify an active session ----
|
|
489
|
+
|
|
490
|
+
pi.registerTool({
|
|
491
|
+
name: 'start_supervision',
|
|
492
|
+
label: 'Start Supervision',
|
|
493
|
+
description:
|
|
494
|
+
'Activate the supervisor to track the conversation toward a specific outcome. ' +
|
|
495
|
+
'The supervisor will observe every turn and steer the agent if it drifts. ' +
|
|
496
|
+
'Once supervision is active it is locked — only the user can change or stop it. ' +
|
|
497
|
+
'Uses the global config model or active chat model (model cannot be specified).',
|
|
498
|
+
parameters: Type.Object({
|
|
499
|
+
outcome: Type.String({
|
|
500
|
+
description:
|
|
501
|
+
'The desired end-state to supervise toward. Be specific and measurable ' +
|
|
502
|
+
"(e.g. 'Implement JWT auth with refresh tokens and full test coverage').",
|
|
503
|
+
}),
|
|
504
|
+
}),
|
|
505
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
506
|
+
const text = (msg: string) => ({
|
|
507
|
+
content: [{ type: 'text' as const, text: msg }],
|
|
508
|
+
details: undefined,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return text(await startSupervisionFromModel(params.outcome, ctx));
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|