@dyyz1993/pi-coding-agent 0.74.28 → 0.74.31
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 +2 -0
- package/dist/core/agent-session.d.ts +1 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +13 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/channel-registry.d.ts +2 -0
- package/dist/core/extensions/channel-registry.d.ts.map +1 -1
- package/dist/core/extensions/channel-registry.js.map +1 -1
- package/dist/core/session-manager.d.ts +5 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +43 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/extensions/bash-ext/index.ts +86 -62
- package/dist/extensions/file-snapshot/index.ts +4 -1
- package/dist/extensions/lsp/lsp/index.ts +21 -3
- package/dist/extensions/lsp/lsp/utils/project-scanner.ts +102 -0
- package/dist/extensions/session-supervisor/config.ts +3 -1
- package/dist/extensions/session-supervisor/index.ts +90 -63
- package/dist/extensions/session-supervisor/types.d.ts +321 -0
- package/dist/extensions/session-supervisor/types.d.ts.map +1 -0
- package/dist/extensions/session-supervisor/types.js +92 -0
- package/dist/extensions/session-supervisor/types.js.map +1 -0
- package/dist/extensions/session-supervisor/types.ts +8 -8
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +1 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -73,6 +73,7 @@ type BashToolDetails = _BashToolDetails & {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
76
|
+
const DEFAULT_BACKGROUND_AFTER_SECONDS = 120;
|
|
76
77
|
|
|
77
78
|
const bashSchema = Type.Object({
|
|
78
79
|
command: Type.String({ description: "Bash command to execute" }),
|
|
@@ -179,83 +180,105 @@ export default function (pi: ExtensionAPI) {
|
|
|
179
180
|
});
|
|
180
181
|
|
|
181
182
|
channel.handle("kill", ({ toolCallId }) => {
|
|
182
|
-
if (!toolCallId) return;
|
|
183
|
+
if (!toolCallId) return { ok: false, reason: "not_found" };
|
|
183
184
|
const m = managed.get(toolCallId);
|
|
184
|
-
if (m
|
|
185
|
-
|
|
186
|
-
m.proc.status = "terminated";
|
|
187
|
-
m.proc.endedAt = Date.now();
|
|
188
|
-
m.resolved = true;
|
|
189
|
-
m.killedByUser = true;
|
|
190
|
-
const durationMs = m.proc.endedAt - m.proc.startedAt;
|
|
191
|
-
if (m.logStream) m.logStream.end();
|
|
185
|
+
if (!m) {
|
|
186
|
+
// Process already exited — emit terminated event so frontend can sync state
|
|
192
187
|
channel?.emit("terminated", {
|
|
193
188
|
type: "terminated",
|
|
194
189
|
toolCallId,
|
|
195
|
-
pid:
|
|
190
|
+
pid: undefined,
|
|
196
191
|
processes: Array.from(managed.values()).map((x) => x.proc),
|
|
197
192
|
timestamp: Date.now(),
|
|
198
193
|
});
|
|
199
|
-
|
|
200
|
-
content: [
|
|
201
|
-
{
|
|
202
|
-
type: "text",
|
|
203
|
-
text: `${m.proc.output || "(no output)"}\n\n[User cancelled after ${formatDuration(durationMs)}, PID: ${m.proc.pid}${m.proc.logPath ? `. Log: ${m.proc.logPath}` : ""}]`,
|
|
204
|
-
},
|
|
205
|
-
],
|
|
206
|
-
details: {
|
|
207
|
-
terminated: {
|
|
208
|
-
reason: "user_cancel",
|
|
209
|
-
pid: m.proc.pid,
|
|
210
|
-
command: m.proc.command,
|
|
211
|
-
startedAt: m.proc.startedAt,
|
|
212
|
-
endedAt: m.proc.endedAt,
|
|
213
|
-
durationMs,
|
|
214
|
-
logPath: m.proc.logPath,
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
});
|
|
194
|
+
return { ok: true, alreadyExited: true };
|
|
218
195
|
}
|
|
196
|
+
if (m.proc.pid) {
|
|
197
|
+
killProcessTree(m.proc.pid);
|
|
198
|
+
}
|
|
199
|
+
m.proc.status = "terminated";
|
|
200
|
+
m.proc.endedAt = Date.now();
|
|
201
|
+
m.resolved = true;
|
|
202
|
+
m.killedByUser = true;
|
|
203
|
+
const durationMs = m.proc.endedAt - m.proc.startedAt;
|
|
204
|
+
if (m.logStream) m.logStream.end();
|
|
205
|
+
channel?.emit("terminated", {
|
|
206
|
+
type: "terminated",
|
|
207
|
+
toolCallId,
|
|
208
|
+
pid: m.proc.pid,
|
|
209
|
+
processes: Array.from(managed.values()).map((x) => x.proc),
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
});
|
|
212
|
+
m.resolve({
|
|
213
|
+
content: [
|
|
214
|
+
{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: `${m.proc.output || "(no output)"}\n\n[User cancelled after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}${m.proc.logPath ? `. Log: ${m.proc.logPath}` : ""}]`,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
details: {
|
|
220
|
+
terminated: {
|
|
221
|
+
reason: "user_cancel",
|
|
222
|
+
pid: m.proc.pid,
|
|
223
|
+
command: m.proc.command,
|
|
224
|
+
startedAt: m.proc.startedAt,
|
|
225
|
+
endedAt: m.proc.endedAt,
|
|
226
|
+
durationMs,
|
|
227
|
+
logPath: m.proc.logPath,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
return { ok: true };
|
|
219
232
|
});
|
|
220
233
|
|
|
221
234
|
channel.handle("background", ({ toolCallId }) => {
|
|
222
|
-
if (!toolCallId) return;
|
|
235
|
+
if (!toolCallId) return { ok: false, reason: "not_found" };
|
|
223
236
|
const m = managed.get(toolCallId);
|
|
224
|
-
if (m) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
m.outputSubscribed = false;
|
|
229
|
-
createLogStream(m);
|
|
230
|
-
const durationMs = Date.now() - m.proc.startedAt;
|
|
231
|
-
channel?.emit("background", {
|
|
232
|
-
type: "background",
|
|
237
|
+
if (!m) {
|
|
238
|
+
// Process already exited — emit terminated event so frontend can sync state
|
|
239
|
+
channel?.emit("terminated", {
|
|
240
|
+
type: "terminated",
|
|
233
241
|
toolCallId,
|
|
234
|
-
pid:
|
|
235
|
-
data: m.proc.output.slice(-2000),
|
|
242
|
+
pid: undefined,
|
|
236
243
|
processes: Array.from(managed.values()).map((x) => x.proc),
|
|
237
244
|
timestamp: Date.now(),
|
|
238
245
|
});
|
|
239
|
-
|
|
240
|
-
m.resolve({
|
|
241
|
-
content: [
|
|
242
|
-
{
|
|
243
|
-
type: "text",
|
|
244
|
-
text: `${outputPreview}\n\n[Moved to background after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
|
|
245
|
-
},
|
|
246
|
-
],
|
|
247
|
-
details: {
|
|
248
|
-
background: {
|
|
249
|
-
pid: m.proc.pid,
|
|
250
|
-
command: m.proc.command,
|
|
251
|
-
startedAt: m.proc.startedAt,
|
|
252
|
-
durationMs,
|
|
253
|
-
logPath: m.proc.logPath,
|
|
254
|
-
detached: false,
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
});
|
|
246
|
+
return { ok: true, alreadyExited: true };
|
|
258
247
|
}
|
|
248
|
+
m.proc.status = "background";
|
|
249
|
+
m.resolved = true;
|
|
250
|
+
m.backgrounded = true;
|
|
251
|
+
m.outputSubscribed = false;
|
|
252
|
+
createLogStream(m);
|
|
253
|
+
const durationMs = Date.now() - m.proc.startedAt;
|
|
254
|
+
channel?.emit("background", {
|
|
255
|
+
type: "background",
|
|
256
|
+
toolCallId,
|
|
257
|
+
pid: m.proc.pid,
|
|
258
|
+
data: m.proc.output.slice(-2000),
|
|
259
|
+
processes: Array.from(managed.values()).map((x) => x.proc),
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
});
|
|
262
|
+
const outputPreview = m.proc.output ? takeLastLines(m.proc.output, BG_PREVIEW_LINES) : "(no output yet)";
|
|
263
|
+
m.resolve({
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: `${outputPreview}\n\n[Moved to background after ${formatDuration(durationMs)}, PID: ${m.proc.pid ?? "unknown"}. <bashId>${m.proc.bashId}</bashId>. Log: ${m.proc.logPath}. Use get_background_process with <bashId>${m.proc.bashId}</bashId> to check progress.]`,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
details: {
|
|
271
|
+
background: {
|
|
272
|
+
pid: m.proc.pid,
|
|
273
|
+
command: m.proc.command,
|
|
274
|
+
startedAt: m.proc.startedAt,
|
|
275
|
+
durationMs,
|
|
276
|
+
logPath: m.proc.logPath,
|
|
277
|
+
detached: false,
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
return { ok: true };
|
|
259
282
|
});
|
|
260
283
|
|
|
261
284
|
channel.handle("subscribe_output", ({ toolCallId }) => {
|
|
@@ -318,8 +341,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
318
341
|
_ctx?: ExtensionContext,
|
|
319
342
|
): Promise<AgentToolResult<BashToolDetails>> {
|
|
320
343
|
return new Promise((resolve, reject) => {
|
|
321
|
-
|
|
322
|
-
|
|
344
|
+
const effectiveTimeout = timeout ?? DEFAULT_TIMEOUT_SECONDS;
|
|
345
|
+
const rawBackgroundAfter = backgroundAfter ?? DEFAULT_BACKGROUND_AFTER_SECONDS;
|
|
346
|
+
const effectiveBackgroundAfter = rawBackgroundAfter < effectiveTimeout ? rawBackgroundAfter : undefined;
|
|
323
347
|
const cwd = cwdParam ?? _ctx?.cwd ?? process.cwd();
|
|
324
348
|
const bashId = generateBashId();
|
|
325
349
|
|
|
@@ -4,7 +4,10 @@ export default function fileSnapshot(pi: ExtensionAPI) {
|
|
|
4
4
|
const channel = pi.registerChannel("file-snapshot");
|
|
5
5
|
|
|
6
6
|
channel.onReceive(async (msg) => {
|
|
7
|
-
const ctx = msg.context as ExtensionContext;
|
|
7
|
+
const ctx = msg.context as ExtensionContext | undefined;
|
|
8
|
+
if (!ctx) {
|
|
9
|
+
return { error: "Extension context not available in channel message. This operation is not supported via RPC client channel calls." };
|
|
10
|
+
}
|
|
8
11
|
const mgr = ctx.fileSnapshotManager;
|
|
9
12
|
if (!mgr) {
|
|
10
13
|
return { error: "fileSnapshotManager not available" };
|
|
@@ -11,6 +11,7 @@ import { createDependencyResolver } from "./utils/dependency-resolver.js";
|
|
|
11
11
|
import { createWriteThroughHooks } from "./hooks/writethrough.js";
|
|
12
12
|
import { createLspToolRouter } from "./tools/lsp-tool.js";
|
|
13
13
|
import { createServerMetricsCollector } from "./monitoring/server-metrics.js";
|
|
14
|
+
import { scanProjectFileTypes, filterServersByProject } from "./utils/project-scanner.js";
|
|
14
15
|
|
|
15
16
|
export interface LspChannelEvent {
|
|
16
17
|
event:
|
|
@@ -143,14 +144,31 @@ export default function lspExtension(pi: ExtensionAPI): void {
|
|
|
143
144
|
|
|
144
145
|
const config = configResolver.resolve();
|
|
145
146
|
|
|
147
|
+
// Scan project for file types and filter servers
|
|
148
|
+
const cwd = process.cwd();
|
|
149
|
+
const scanResult = scanProjectFileTypes(cwd);
|
|
150
|
+
const filteredServers = filterServersByProject(config.servers, scanResult);
|
|
151
|
+
const skippedNames = config.servers
|
|
152
|
+
.filter((s) => !filteredServers.some((f) => f.name === s.name))
|
|
153
|
+
.map((s) => s.name);
|
|
154
|
+
const discoveredExts = [...scanResult.discoveredExtensions].sort();
|
|
155
|
+
|
|
156
|
+
if (skippedNames.length > 0) {
|
|
157
|
+
console.log(
|
|
158
|
+
`[lsp] Project scan found [${discoveredExts.join(", ")}], starting ${filteredServers.length}/${config.servers.length} servers (skipped: ${skippedNames.join(", ")})`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const filteredConfig = { ...config, servers: filteredServers };
|
|
163
|
+
|
|
146
164
|
lspChannel?.emit("startup_begin", {
|
|
147
165
|
event: "startup_begin",
|
|
148
166
|
timestamp: Date.now(),
|
|
149
|
-
servers:
|
|
150
|
-
totalServers:
|
|
167
|
+
servers: filteredConfig.servers.map((s) => ({ name: s.name, state: "starting", fileTypes: s.fileTypes })),
|
|
168
|
+
totalServers: filteredConfig.servers.length,
|
|
151
169
|
});
|
|
152
170
|
|
|
153
|
-
await runtime.start(
|
|
171
|
+
await runtime.start(filteredConfig);
|
|
154
172
|
const status = runtime.getStatus();
|
|
155
173
|
|
|
156
174
|
for (const srv of status.servers) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import type { ResolvedLspServerConfig } from "../config/resolver.js";
|
|
5
|
+
|
|
6
|
+
export interface ProjectScanResult {
|
|
7
|
+
discoveredExtensions: Set<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scan the project for file types present on disk.
|
|
12
|
+
* Uses `git ls-files` when available (fast, respects .gitignore),
|
|
13
|
+
* falls back to a shallow `find` otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export function scanProjectFileTypes(cwd: string): ProjectScanResult {
|
|
16
|
+
const extensions = new Set<string>();
|
|
17
|
+
|
|
18
|
+
// Strategy 1: git ls-files (fast, respects gitignore)
|
|
19
|
+
const gitFiles = tryGitLsFiles(cwd);
|
|
20
|
+
if (gitFiles.length > 0) {
|
|
21
|
+
for (const file of gitFiles) {
|
|
22
|
+
const ext = extname(file).toLowerCase();
|
|
23
|
+
if (ext) {
|
|
24
|
+
extensions.add(ext);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { discoveredExtensions: extensions };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strategy 2: shallow find (maxdepth 3, skip node_modules etc.)
|
|
31
|
+
try {
|
|
32
|
+
const output = execSync(
|
|
33
|
+
'find . -maxdepth 3 -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/target/*" -not -path "*/dist/*" -not -path "*/.pi/*" 2>/dev/null | head -2000',
|
|
34
|
+
{ cwd, timeout: 3000, encoding: "utf8" },
|
|
35
|
+
);
|
|
36
|
+
for (const line of output.split("\n")) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed) continue;
|
|
39
|
+
const ext = extname(trimmed).toLowerCase();
|
|
40
|
+
if (ext) {
|
|
41
|
+
extensions.add(ext);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// If scan fails, return empty — will fall back to starting all servers
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { discoveredExtensions: extensions };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tryGitLsFiles(cwd: string): string[] {
|
|
52
|
+
// Check if we're in a git repo
|
|
53
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const output = execSync("git ls-files --cached --others --exclude-standard 2>/dev/null | head -2000", {
|
|
59
|
+
cwd,
|
|
60
|
+
timeout: 3000,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
});
|
|
63
|
+
const files = output.split("\n").filter(Boolean);
|
|
64
|
+
return files.length > 0 ? files : [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Filter server configs to only those whose fileTypes match files in the project.
|
|
72
|
+
* Servers WITHOUT fileTypes (catch-all) are always included.
|
|
73
|
+
* If the project has no discoverable files, all servers are started (safe fallback).
|
|
74
|
+
*/
|
|
75
|
+
export function filterServersByProject(
|
|
76
|
+
servers: ResolvedLspServerConfig[],
|
|
77
|
+
scanResult: ProjectScanResult,
|
|
78
|
+
): ResolvedLspServerConfig[] {
|
|
79
|
+
const { discoveredExtensions } = scanResult;
|
|
80
|
+
|
|
81
|
+
// Safe fallback: if scan found nothing, start everything
|
|
82
|
+
if (discoveredExtensions.size === 0) {
|
|
83
|
+
return servers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const filtered: ResolvedLspServerConfig[] = [];
|
|
87
|
+
for (const server of servers) {
|
|
88
|
+
// No fileTypes = catch-all server, always include
|
|
89
|
+
if (!server.fileTypes || server.fileTypes.length === 0) {
|
|
90
|
+
filtered.push(server);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Include if ANY of the server's fileTypes exist in the project
|
|
95
|
+
const hasMatch = server.fileTypes.some((ft) => discoveredExtensions.has(ft.toLowerCase()));
|
|
96
|
+
if (hasMatch) {
|
|
97
|
+
filtered.push(server);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return filtered;
|
|
102
|
+
}
|
|
@@ -15,7 +15,9 @@ const DEFAULT_CONFIG: SupervisorConfig = {
|
|
|
15
15
|
maxContinueCount: 5,
|
|
16
16
|
defaultDelayMs: 30_000,
|
|
17
17
|
pauseThresholdMs: 300_000,
|
|
18
|
-
guards: [
|
|
18
|
+
guards: [
|
|
19
|
+
{ name: "incomplete-keywords", type: "keyword", enable: true, keywords: ["TODO", "FIXME", "WIP", "HACK"] },
|
|
20
|
+
],
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export function loadConfig(sessionDataDir: string, projectDataDir: string): SupervisorConfig {
|
|
@@ -36,6 +36,10 @@ function log(msg: string) {
|
|
|
36
36
|
appendFileSync(LOG_FILE, line);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const DEFAULT_GUARDS: GuardConfig[] = [
|
|
40
|
+
{ name: "incomplete-keywords", type: "keyword", enable: true, keywords: ["TODO", "FIXME", "WIP", "HACK"] },
|
|
41
|
+
];
|
|
42
|
+
|
|
39
43
|
export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
40
44
|
let config: SupervisorConfig;
|
|
41
45
|
let enabled = false;
|
|
@@ -72,8 +76,8 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
72
76
|
const { server: channel } =
|
|
73
77
|
createTypedChannel<SupervisorChannelContract>(rawChannel);
|
|
74
78
|
|
|
75
|
-
channel.handle("
|
|
76
|
-
channel.handle("
|
|
79
|
+
channel.handle("getStatus", async () => getStatus());
|
|
80
|
+
channel.handle("requestPause", async (params) => {
|
|
77
81
|
const delayMs = params.delayMs ?? config.defaultDelayMs;
|
|
78
82
|
const result = schedulerInstance.scheduleContinue(
|
|
79
83
|
"manual-pause",
|
|
@@ -90,7 +94,7 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
90
94
|
return result;
|
|
91
95
|
});
|
|
92
96
|
|
|
93
|
-
channel.handle("
|
|
97
|
+
channel.handle("cancelPause", async () => {
|
|
94
98
|
const cancelled = schedulerInstance.cancelTimer("manual-pause");
|
|
95
99
|
if (cancelled) {
|
|
96
100
|
channel.emit("supervisor.pauseCancelled", { reason: "Cancelled via channel" });
|
|
@@ -98,7 +102,7 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
98
102
|
return { cancelled };
|
|
99
103
|
});
|
|
100
104
|
|
|
101
|
-
channel.handle("
|
|
105
|
+
channel.handle("forceContinue", async (params) => {
|
|
102
106
|
schedulerInstance.cancelAll();
|
|
103
107
|
currentState = "continuing";
|
|
104
108
|
emitStatusChanged();
|
|
@@ -106,7 +110,7 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
106
110
|
return { triggered: true };
|
|
107
111
|
});
|
|
108
112
|
|
|
109
|
-
channel.handle("
|
|
113
|
+
channel.handle("disable", async () => {
|
|
110
114
|
enabled = false;
|
|
111
115
|
schedulerInstance.cancelAll();
|
|
112
116
|
currentState = "disabled";
|
|
@@ -114,16 +118,16 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
114
118
|
return { disabled: true };
|
|
115
119
|
});
|
|
116
120
|
|
|
117
|
-
channel.handle("
|
|
121
|
+
channel.handle("enable", async () => {
|
|
118
122
|
enabled = true;
|
|
119
123
|
currentState = "idle";
|
|
120
124
|
emitStatusChanged();
|
|
121
125
|
return { enabled: true };
|
|
122
126
|
});
|
|
123
127
|
|
|
124
|
-
channel.handle("
|
|
128
|
+
channel.handle("getTaskReport", async () => ({ tasks: lastTaskReports }));
|
|
125
129
|
|
|
126
|
-
channel.handle("
|
|
130
|
+
channel.handle("checkToolStatus", async (params) => {
|
|
127
131
|
const targetChannelName = params.channelName ?? params.toolName;
|
|
128
132
|
try {
|
|
129
133
|
const result = await rawChannel.call(
|
|
@@ -222,8 +226,8 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
222
226
|
config.smallModel = modelFlag;
|
|
223
227
|
}
|
|
224
228
|
|
|
225
|
-
//
|
|
226
|
-
projectRoot = ctx.
|
|
229
|
+
// projectRoot is the git root (worktree-aware), correct for specs file resolution
|
|
230
|
+
projectRoot = ctx.projectRoot ?? ctx.cwd;
|
|
227
231
|
|
|
228
232
|
schedulerInstance = new Scheduler(
|
|
229
233
|
config.maxContinueCount,
|
|
@@ -276,7 +280,29 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
276
280
|
log(`guard[${guard.name}] completed=${result.completed}, remaining=${result.remainingItems.length}`);
|
|
277
281
|
}
|
|
278
282
|
|
|
279
|
-
|
|
283
|
+
lastTaskReports = reports;
|
|
284
|
+
channel.emit("supervisor.taskReport", { tasks: reports });
|
|
285
|
+
|
|
286
|
+
// Phase 2: If any guard says incomplete → continue immediately
|
|
287
|
+
const hasIncompleteGuards = guardResults.some((r) => !r.completed && r.remainingItems.length > 0);
|
|
288
|
+
|
|
289
|
+
if (hasIncompleteGuards) {
|
|
290
|
+
log(`Guards detected incomplete tasks`);
|
|
291
|
+
specsIterationCount++;
|
|
292
|
+
|
|
293
|
+
const continueMessage = generateContinueMessage(
|
|
294
|
+
activeGuards,
|
|
295
|
+
guardResults,
|
|
296
|
+
null,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
lastCheckResult = { completed: false, confidence: 0.9, incompleteTasks: [], guardResults };
|
|
300
|
+
|
|
301
|
+
scheduleContinue(continueMessage);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Phase 3: All guards passed → run fallback model check
|
|
280
306
|
const modelCheck = await checkWithSmallModel(
|
|
281
307
|
event.messages as Array<{ role: string; content: unknown }>,
|
|
282
308
|
config,
|
|
@@ -284,14 +310,9 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
284
310
|
ctx.sessionSignal,
|
|
285
311
|
);
|
|
286
312
|
|
|
287
|
-
lastTaskReports = reports;
|
|
288
|
-
channel.emit("supervisor.taskReport", { tasks: reports });
|
|
289
|
-
|
|
290
|
-
// Phase 2: Determine if we should continue
|
|
291
|
-
const hasIncompleteGuards = guardResults.some((r) => !r.completed && r.remainingItems.length > 0);
|
|
292
313
|
const hasModelIncomplete = modelCheck.completed === false || modelCheck.incompleteTasks.length > 0;
|
|
293
314
|
|
|
294
|
-
if (!
|
|
315
|
+
if (!hasModelIncomplete) {
|
|
295
316
|
log(`All guards passed + model check passed → idle`);
|
|
296
317
|
currentState = "idle";
|
|
297
318
|
lastCheckResult = { ...modelCheck, guardResults };
|
|
@@ -299,10 +320,8 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
299
320
|
return;
|
|
300
321
|
}
|
|
301
322
|
|
|
302
|
-
// Phase
|
|
303
|
-
log(`
|
|
304
|
-
specsIterationCount++;
|
|
305
|
-
|
|
323
|
+
// Phase 4: Model detected incompleteness → continue with model's assessment
|
|
324
|
+
log(`Model detected incomplete tasks`);
|
|
306
325
|
const continueMessage = generateContinueMessage(
|
|
307
326
|
activeGuards,
|
|
308
327
|
guardResults,
|
|
@@ -310,41 +329,7 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
310
329
|
);
|
|
311
330
|
|
|
312
331
|
lastCheckResult = { ...modelCheck, guardResults };
|
|
313
|
-
|
|
314
|
-
// Phase 4: Schedule continue
|
|
315
|
-
const delayMs = config.defaultDelayMs;
|
|
316
|
-
|
|
317
|
-
if (schedulerInstance.shouldPause(delayMs)) {
|
|
318
|
-
currentState = "paused";
|
|
319
|
-
emitStatusChanged();
|
|
320
|
-
channel.emit("supervisor.pauseRequested", {
|
|
321
|
-
delayMs,
|
|
322
|
-
reason: continueMessage.slice(0, 200),
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
pi.background(async (signal) => {
|
|
327
|
-
await new Promise<void>((resolve) => {
|
|
328
|
-
const timer = setTimeout(resolve, delayMs);
|
|
329
|
-
signal.addEventListener("abort", () => {
|
|
330
|
-
clearTimeout(timer);
|
|
331
|
-
resolve();
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
if (signal.aborted) return;
|
|
336
|
-
|
|
337
|
-
currentState = "continuing";
|
|
338
|
-
emitStatusChanged();
|
|
339
|
-
pi.sendMessage(
|
|
340
|
-
{
|
|
341
|
-
customType: "supervisor_continue",
|
|
342
|
-
content: continueMessage,
|
|
343
|
-
display: true,
|
|
344
|
-
},
|
|
345
|
-
{ triggerTurn: true },
|
|
346
|
-
);
|
|
347
|
-
});
|
|
332
|
+
scheduleContinue(continueMessage);
|
|
348
333
|
} catch (err) {
|
|
349
334
|
log(`agent_end error: ${err instanceof Error ? err.message : String(err)}`);
|
|
350
335
|
currentState = "idle";
|
|
@@ -359,8 +344,46 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
359
344
|
|
|
360
345
|
// ── Guard Check Functions ──
|
|
361
346
|
|
|
347
|
+
function scheduleContinue(continueMessage: string): void {
|
|
348
|
+
const delayMs = config.defaultDelayMs;
|
|
349
|
+
|
|
350
|
+
if (schedulerInstance.shouldPause(delayMs)) {
|
|
351
|
+
currentState = "paused";
|
|
352
|
+
emitStatusChanged();
|
|
353
|
+
channel.emit("supervisor.pauseRequested", {
|
|
354
|
+
delayMs,
|
|
355
|
+
reason: continueMessage.slice(0, 200),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
pi.background(async (signal) => {
|
|
360
|
+
await new Promise<void>((resolve) => {
|
|
361
|
+
const timer = setTimeout(resolve, delayMs);
|
|
362
|
+
signal.addEventListener("abort", () => {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
resolve();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (signal.aborted) return;
|
|
369
|
+
|
|
370
|
+
currentState = "continuing";
|
|
371
|
+
emitStatusChanged();
|
|
372
|
+
pi.sendMessage(
|
|
373
|
+
{
|
|
374
|
+
customType: "supervisor_continue",
|
|
375
|
+
content: continueMessage,
|
|
376
|
+
display: true,
|
|
377
|
+
},
|
|
378
|
+
{ triggerTurn: true },
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
362
383
|
function getActiveGuards(): GuardConfig[] {
|
|
363
|
-
|
|
384
|
+
const guards = config.guards ?? [];
|
|
385
|
+
const source = guards.length > 0 ? guards : DEFAULT_GUARDS;
|
|
386
|
+
return source.filter((g) => g.enable !== false);
|
|
364
387
|
}
|
|
365
388
|
|
|
366
389
|
async function runGuardCheck(
|
|
@@ -599,7 +622,7 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
599
622
|
function generateContinueMessage(
|
|
600
623
|
guards: GuardConfig[],
|
|
601
624
|
results: GuardCheckResult[],
|
|
602
|
-
modelCheck: CheckResult,
|
|
625
|
+
modelCheck: CheckResult | null,
|
|
603
626
|
): string {
|
|
604
627
|
// Priority: first incomplete guard generates the message
|
|
605
628
|
for (let i = 0; i < guards.length; i++) {
|
|
@@ -640,11 +663,15 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
|
|
|
640
663
|
}
|
|
641
664
|
|
|
642
665
|
// Fallback: generic continue from model check
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
666
|
+
if (modelCheck) {
|
|
667
|
+
const tasks = modelCheck.incompleteTasks.map((t) => `[${t.severity}] ${t.description}`);
|
|
668
|
+
return CONTINUE_PROMPT(
|
|
669
|
+
modelCheck.modelResponse ?? "Model detected incomplete tasks",
|
|
670
|
+
tasks.length > 0 ? tasks : ["Continue working"],
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return CONTINUE_PROMPT("Incomplete tasks detected", ["Please continue working on remaining items."]);
|
|
648
675
|
}
|
|
649
676
|
|
|
650
677
|
function generateBlockMessage(
|