@cortexkit/aft-opencode 0.61.0 → 0.63.4
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/dist/bash-wait-detach.d.ts +1 -0
- package/dist/bash-wait-detach.d.ts.map +1 -1
- package/dist/bg-notifications.d.ts +37 -12
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/bridge-bootstrap.d.ts +1 -1
- package/dist/bridge-bootstrap.d.ts.map +1 -1
- package/dist/config-live-reload.d.ts +18 -2
- package/dist/config-live-reload.d.ts.map +1 -1
- package/dist/config.d.ts +43 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/entry/server.js +4869 -1622
- package/dist/hooks/auto-update-checker/index.d.ts +5 -1
- package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3996 -1482
- package/dist/logger.d.ts +6 -10
- package/dist/logger.d.ts.map +1 -1
- package/dist/lsp-auto-install.d.ts.map +1 -1
- package/dist/lsp.d.ts +1 -0
- package/dist/lsp.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +2 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/session-cleanup.d.ts +6 -0
- package/dist/shared/session-cleanup.d.ts.map +1 -0
- package/dist/shared/session-directory.d.ts +24 -0
- package/dist/shared/session-directory.d.ts.map +1 -1
- package/dist/shared/status.d.ts +12 -1
- package/dist/shared/status.d.ts.map +1 -1
- package/dist/shared/subagent-detect.d.ts +24 -8
- package/dist/shared/subagent-detect.d.ts.map +1 -1
- package/dist/shared/wake-admission.d.ts +68 -0
- package/dist/shared/wake-admission.d.ts.map +1 -0
- package/dist/shutdown-hooks.d.ts +2 -0
- package/dist/shutdown-hooks.d.ts.map +1 -1
- package/dist/subc-tool-schemas.d.ts +13 -2
- package/dist/subc-tool-schemas.d.ts.map +1 -1
- package/dist/tools/_shared.d.ts +5 -1
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/bash.d.ts +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash_watch.d.ts +4 -0
- package/dist/tools/bash_watch.d.ts.map +1 -1
- package/dist/tools/definitions/v2.d.ts +2 -1
- package/dist/tools/definitions/v2.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts +8 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/metadata.d.ts +7 -0
- package/dist/tools/metadata.d.ts.map +1 -0
- package/dist/tools/outside-permissions.d.ts +35 -0
- package/dist/tools/outside-permissions.d.ts.map +1 -0
- package/dist/tools/permissions.d.ts +9 -11
- package/dist/tools/permissions.d.ts.map +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/safety.d.ts +1 -1
- package/dist/tools/safety.d.ts.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/semantic.d.ts.map +1 -1
- package/dist/tui/notification-socket.d.ts.map +1 -1
- package/dist/tui/preferences.d.ts +11 -0
- package/dist/tui/preferences.d.ts.map +1 -1
- package/dist/v2-session-lifecycle.d.ts +7 -0
- package/dist/v2-session-lifecycle.d.ts.map +1 -1
- package/dist/v2-tool-hooks.d.ts +1 -0
- package/dist/v2-tool-hooks.d.ts.map +1 -1
- package/dist/wakes/runtime-consumer.d.ts +14 -3
- package/dist/wakes/runtime-consumer.d.ts.map +1 -1
- package/dist/wakes/session-delivery.d.ts +24 -4
- package/dist/wakes/session-delivery.d.ts.map +1 -1
- package/dist/wakes/subc-recovery.d.ts +8 -0
- package/dist/wakes/subc-recovery.d.ts.map +1 -0
- package/package.json +10 -10
- package/src/entry/server-runtime.mjs +67 -19
- package/src/logger.ts +12 -5
- package/src/shared/rpc-client.ts +35 -28
- package/src/shared/rpc-notifications.ts +5 -3
- package/src/shared/rpc-server.ts +65 -7
- package/src/shared/session-cleanup.ts +13 -0
- package/src/shared/session-directory.ts +59 -3
- package/src/shared/status.ts +32 -2
- package/src/shared/subagent-detect.ts +53 -25
- package/src/shared/wake-admission.ts +340 -0
- package/src/tui/notification-socket.ts +30 -4
- package/src/tui/preferences.ts +66 -29
- package/src/tui/sidebar-view.tsx +1 -0
- package/src/tui/v2.tsx +49 -1
- package/src/tui-compiled/notification-socket.ts +30 -4
- package/src/tui-compiled/preferences.ts +66 -29
- package/src/tui-compiled/sidebar-view.tsx +2 -1
- package/src/tui-compiled/v2.tsx +54 -1
package/src/shared/rpc-client.ts
CHANGED
|
@@ -250,7 +250,7 @@ export class AftRpcClient {
|
|
|
250
250
|
info: PortInfo,
|
|
251
251
|
signal?: AbortSignal,
|
|
252
252
|
): Promise<T> {
|
|
253
|
-
|
|
253
|
+
return this.fetchWithTimeout(
|
|
254
254
|
`http://127.0.0.1:${info.port}/rpc/${method}`,
|
|
255
255
|
{
|
|
256
256
|
method: "POST",
|
|
@@ -258,12 +258,14 @@ export class AftRpcClient {
|
|
|
258
258
|
body: JSON.stringify({ ...params, token: info.token }),
|
|
259
259
|
},
|
|
260
260
|
signal,
|
|
261
|
+
async (response) => {
|
|
262
|
+
if (!response.ok) {
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
|
|
265
|
+
}
|
|
266
|
+
return (await response.json()) as T;
|
|
267
|
+
},
|
|
261
268
|
);
|
|
262
|
-
if (!response.ok) {
|
|
263
|
-
const text = await response.text();
|
|
264
|
-
throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
|
|
265
|
-
}
|
|
266
|
-
return (await response.json()) as T;
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
/**
|
|
@@ -467,39 +469,43 @@ export class AftRpcClient {
|
|
|
467
469
|
signal?: AbortSignal,
|
|
468
470
|
): Promise<boolean> {
|
|
469
471
|
try {
|
|
470
|
-
|
|
472
|
+
return await this.fetchWithTimeout(
|
|
471
473
|
`http://127.0.0.1:${port}/health`,
|
|
472
474
|
{ method: "GET" },
|
|
473
475
|
signal,
|
|
476
|
+
async (response) => {
|
|
477
|
+
if (!response.ok) return false;
|
|
478
|
+
// Identity check: the port file records the owning pid and `/health`
|
|
479
|
+
// echoes the live server's pid. If they disagree, the port was recycled
|
|
480
|
+
// by a *different* AFT server (or an unrelated localhost service that
|
|
481
|
+
// happens to answer /health), so this port file is stale — reject it so
|
|
482
|
+
// we don't POST this file's token to the wrong server. Only enforced when
|
|
483
|
+
// both sides expose a pid (older port files omit it).
|
|
484
|
+
if (expectedPid !== undefined) {
|
|
485
|
+
try {
|
|
486
|
+
const body = (await response.json()) as { pid?: unknown };
|
|
487
|
+
if (typeof body?.pid === "number" && body.pid !== expectedPid) return false;
|
|
488
|
+
} catch {
|
|
489
|
+
// Non-JSON / unexpected body: not a healthy AFT server we trust.
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (expectedPid === undefined) await response.body?.cancel();
|
|
494
|
+
return true;
|
|
495
|
+
},
|
|
474
496
|
);
|
|
475
|
-
if (!response.ok) return false;
|
|
476
|
-
// Identity check: the port file records the owning pid and `/health`
|
|
477
|
-
// echoes the live server's pid. If they disagree, the port was recycled
|
|
478
|
-
// by a *different* AFT server (or an unrelated localhost service that
|
|
479
|
-
// happens to answer /health), so this port file is stale — reject it so
|
|
480
|
-
// we don't POST this file's token to the wrong server. Only enforced when
|
|
481
|
-
// both sides expose a pid (older port files omit it).
|
|
482
|
-
if (expectedPid !== undefined) {
|
|
483
|
-
try {
|
|
484
|
-
const body = (await response.json()) as { pid?: unknown };
|
|
485
|
-
if (typeof body?.pid === "number" && body.pid !== expectedPid) return false;
|
|
486
|
-
} catch {
|
|
487
|
-
// Non-JSON / unexpected body: not a healthy AFT server we trust.
|
|
488
|
-
return false;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
return true;
|
|
492
497
|
} catch {
|
|
493
498
|
throwIfAborted(signal);
|
|
494
499
|
return false;
|
|
495
500
|
}
|
|
496
501
|
}
|
|
497
502
|
|
|
498
|
-
private async fetchWithTimeout(
|
|
503
|
+
private async fetchWithTimeout<T>(
|
|
499
504
|
url: string,
|
|
500
505
|
options: RequestInit,
|
|
501
|
-
signal
|
|
502
|
-
|
|
506
|
+
signal: AbortSignal | undefined,
|
|
507
|
+
consume: (response: Response) => Promise<T>,
|
|
508
|
+
): Promise<T> {
|
|
503
509
|
throwIfAborted(signal);
|
|
504
510
|
|
|
505
511
|
const controller = new AbortController();
|
|
@@ -507,7 +513,8 @@ export class AftRpcClient {
|
|
|
507
513
|
const onAbort = () => controller.abort();
|
|
508
514
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
509
515
|
try {
|
|
510
|
-
|
|
516
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
517
|
+
return await consume(response);
|
|
511
518
|
} finally {
|
|
512
519
|
clearTimeout(timeout);
|
|
513
520
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -102,12 +102,14 @@ export function pushNotification(
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
const mustKeep = new Set(newestPerSession.values());
|
|
105
|
-
const byNewest = [...queue].sort((a, b) => b.id - a.id);
|
|
106
105
|
const kept: RpcNotification[] = [];
|
|
107
|
-
|
|
106
|
+
// Push assigns monotonic ids and pruning preserves order, so walking backwards is newest-first.
|
|
107
|
+
for (let index = queue.length - 1; index >= 0; index--) {
|
|
108
|
+
const notification = queue[index];
|
|
109
|
+
if (!notification) continue;
|
|
108
110
|
if (kept.length < 50 || mustKeep.has(notification.id)) kept.push(notification);
|
|
109
111
|
}
|
|
110
|
-
queue = kept.
|
|
112
|
+
queue = kept.reverse();
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
readdirSync,
|
|
6
6
|
readFileSync,
|
|
7
7
|
renameSync,
|
|
8
|
+
statSync,
|
|
8
9
|
unlinkSync,
|
|
9
10
|
writeFileSync,
|
|
10
11
|
} from "node:fs";
|
|
@@ -64,6 +65,7 @@ interface WsData {
|
|
|
64
65
|
|
|
65
66
|
const PORT_FILE_HEARTBEAT_MS = 15_000;
|
|
66
67
|
const MAX_BODY_BYTES = 1_048_576;
|
|
68
|
+
const UNREADABLE_TMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
67
69
|
const WS_AUTH_TIMEOUT_MS = 5_000;
|
|
68
70
|
const WS_CLOSE_UNAUTHORIZED = 4401;
|
|
69
71
|
|
|
@@ -97,6 +99,8 @@ export class AftRpcServer {
|
|
|
97
99
|
/** Unique per-instance ID — distinguishes our entry from duplicate plugin loads. */
|
|
98
100
|
private instanceId: string;
|
|
99
101
|
private sockets = new Set<BunServerWebSocket<WsData>>();
|
|
102
|
+
private stopped = false;
|
|
103
|
+
private cancelNodeStartup: (() => void) | null = null;
|
|
100
104
|
|
|
101
105
|
constructor(storageDir: string, directory: string) {
|
|
102
106
|
this.portsDir = rpcPortFileDir(storageDir, directory);
|
|
@@ -148,6 +152,8 @@ export class AftRpcServer {
|
|
|
148
152
|
private async startNode(): Promise<number> {
|
|
149
153
|
return new Promise((resolve, reject) => {
|
|
150
154
|
const server = createServer((req, res) => this.dispatch(req, res));
|
|
155
|
+
this.nodeServer = server;
|
|
156
|
+
this.cancelNodeStartup = () => reject(new Error("RPC server stopped during startup"));
|
|
151
157
|
|
|
152
158
|
server.on("error", (err) => {
|
|
153
159
|
warn(`RPC server error: ${err.message}`);
|
|
@@ -155,6 +161,12 @@ export class AftRpcServer {
|
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
server.listen(0, "127.0.0.1", () => {
|
|
164
|
+
this.cancelNodeStartup = null;
|
|
165
|
+
if (this.stopped || this.nodeServer !== server) {
|
|
166
|
+
server.close();
|
|
167
|
+
reject(new Error("RPC server stopped during startup"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
158
170
|
const addr = server.address();
|
|
159
171
|
if (!addr || typeof addr === "string") {
|
|
160
172
|
reject(new Error("Failed to get server address"));
|
|
@@ -172,6 +184,7 @@ export class AftRpcServer {
|
|
|
172
184
|
}
|
|
173
185
|
|
|
174
186
|
private afterServerStarted(): void {
|
|
187
|
+
if (this.stopped) return;
|
|
175
188
|
try {
|
|
176
189
|
this.writePortFile();
|
|
177
190
|
log(`RPC server listening on 127.0.0.1:${this.port}`);
|
|
@@ -219,7 +232,8 @@ export class AftRpcServer {
|
|
|
219
232
|
return;
|
|
220
233
|
}
|
|
221
234
|
for (const entry of entries) {
|
|
222
|
-
|
|
235
|
+
const temporary = entry.endsWith(".json.tmp");
|
|
236
|
+
if (!temporary && !entry.endsWith(".json")) continue;
|
|
223
237
|
const filePath = join(this.portsDir, entry);
|
|
224
238
|
if (filePath === this.portFilePath) continue;
|
|
225
239
|
try {
|
|
@@ -227,7 +241,13 @@ export class AftRpcServer {
|
|
|
227
241
|
// Unparsable file: stale tmp/corrupt leftover — remove. Parsable but
|
|
228
242
|
// pid-less (legacy): keep, we cannot prove the owner is dead.
|
|
229
243
|
if (record === null) {
|
|
230
|
-
|
|
244
|
+
// An unreadable temporary record may still be in the middle of a live write.
|
|
245
|
+
if (
|
|
246
|
+
!temporary ||
|
|
247
|
+
Date.now() - statSync(join(this.portsDir, entry)).mtimeMs >= UNREADABLE_TMP_MAX_AGE_MS
|
|
248
|
+
) {
|
|
249
|
+
unlinkSync(filePath);
|
|
250
|
+
}
|
|
231
251
|
continue;
|
|
232
252
|
}
|
|
233
253
|
if (record.pid !== undefined && !isPidAlive(record.pid)) {
|
|
@@ -254,6 +274,9 @@ export class AftRpcServer {
|
|
|
254
274
|
|
|
255
275
|
/** Stop the server and clean up port file. */
|
|
256
276
|
stop(): void {
|
|
277
|
+
this.stopped = true;
|
|
278
|
+
this.cancelNodeStartup?.();
|
|
279
|
+
this.cancelNodeStartup = null;
|
|
257
280
|
if (this.heartbeatTimer) {
|
|
258
281
|
clearInterval(this.heartbeatTimer);
|
|
259
282
|
this.heartbeatTimer = null;
|
|
@@ -305,10 +328,30 @@ export class AftRpcServer {
|
|
|
305
328
|
return json({ error: `Unknown method: ${method}` }, 404);
|
|
306
329
|
}
|
|
307
330
|
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
331
|
+
const declaredLength = Number(req.headers.get("content-length"));
|
|
332
|
+
if (declaredLength > MAX_BODY_BYTES) {
|
|
310
333
|
return new Response("Request too large", { status: 413 });
|
|
311
334
|
}
|
|
335
|
+
const chunks: Uint8Array[] = [];
|
|
336
|
+
let bytes = 0;
|
|
337
|
+
const reader = req.body?.getReader();
|
|
338
|
+
if (reader) {
|
|
339
|
+
try {
|
|
340
|
+
for (;;) {
|
|
341
|
+
const { done, value } = await reader.read();
|
|
342
|
+
if (done) break;
|
|
343
|
+
bytes += value.byteLength;
|
|
344
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
345
|
+
void reader.cancel().catch(() => {});
|
|
346
|
+
return new Response("Request too large", { status: 413 });
|
|
347
|
+
}
|
|
348
|
+
chunks.push(value);
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
reader.releaseLock();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const bodyText = Buffer.concat(chunks, bytes).toString("utf8");
|
|
312
355
|
|
|
313
356
|
let params: Record<string, unknown> = {};
|
|
314
357
|
try {
|
|
@@ -435,17 +478,32 @@ export class AftRpcServer {
|
|
|
435
478
|
return;
|
|
436
479
|
}
|
|
437
480
|
|
|
438
|
-
|
|
481
|
+
if (Number(req.headers["content-length"]) > MAX_BODY_BYTES) {
|
|
482
|
+
res.writeHead(413);
|
|
483
|
+
res.end("Request too large");
|
|
484
|
+
req.resume();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const chunks: Buffer[] = [];
|
|
488
|
+
let bytes = 0;
|
|
489
|
+
let oversized = false;
|
|
439
490
|
req.on("data", (chunk: Buffer) => {
|
|
440
|
-
|
|
441
|
-
|
|
491
|
+
if (oversized) return;
|
|
492
|
+
bytes += chunk.byteLength;
|
|
493
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
494
|
+
oversized = true;
|
|
495
|
+
chunks.length = 0;
|
|
442
496
|
res.writeHead(413);
|
|
443
497
|
res.end("Request too large");
|
|
444
498
|
req.destroy();
|
|
499
|
+
return;
|
|
445
500
|
}
|
|
501
|
+
chunks.push(chunk);
|
|
446
502
|
});
|
|
447
503
|
|
|
448
504
|
req.on("end", () => {
|
|
505
|
+
if (oversized) return;
|
|
506
|
+
const body = Buffer.concat(chunks, bytes).toString("utf8");
|
|
449
507
|
let params: Record<string, unknown> = {};
|
|
450
508
|
try {
|
|
451
509
|
if (body.length > 0) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { clearSessionBgState } from "../bg-notifications.js";
|
|
2
|
+
import { clearSyncWatchAbort } from "../sync-watch-abort.js";
|
|
3
|
+
import { clearSessionPermissions } from "../tools/permissions.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A session can start another execution after one completes, so its state is
|
|
7
|
+
* released only when the host removes the session.
|
|
8
|
+
*/
|
|
9
|
+
export function releaseSessionState(sessionID: string): void {
|
|
10
|
+
clearSyncWatchAbort(sessionID);
|
|
11
|
+
clearSessionPermissions(sessionID);
|
|
12
|
+
clearSessionBgState(sessionID);
|
|
13
|
+
}
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* `packages/opencode/src/session/registry.ts`. Until then this
|
|
20
20
|
* workaround makes AFT robust against the wrong cwd.
|
|
21
21
|
*/
|
|
22
|
+
|
|
23
|
+
import type { AftTransportPool } from "@cortexkit/aft-bridge";
|
|
22
24
|
import { Effect } from "effect";
|
|
23
25
|
|
|
24
26
|
import { sessionWarn } from "../logger.js";
|
|
@@ -97,6 +99,7 @@ interface CacheEntry {
|
|
|
97
99
|
const CACHE_MAX_ENTRIES = 200;
|
|
98
100
|
const cache = new Map<string, CacheEntry>();
|
|
99
101
|
const inflight = new Map<string, Promise<string | null>>();
|
|
102
|
+
const lookupGenerations = new Map<string, object>();
|
|
100
103
|
|
|
101
104
|
/**
|
|
102
105
|
* Resolve the project directory the session was created with from the SDK's
|
|
@@ -136,12 +139,18 @@ export async function getSessionDirectory(
|
|
|
136
139
|
return lookup;
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
/**
|
|
142
|
+
/** A late success may repair its provisional answer, but never overwrite a newer lookup. */
|
|
140
143
|
async function boundedLookup(
|
|
141
144
|
client: unknown,
|
|
142
145
|
sessionId: string,
|
|
143
146
|
fallbackDirectory: string,
|
|
144
147
|
): Promise<string | null> {
|
|
148
|
+
const generation = {};
|
|
149
|
+
lookupGenerations.set(sessionId, generation);
|
|
150
|
+
if (lookupGenerations.size > CACHE_MAX_ENTRIES) {
|
|
151
|
+
const oldest = lookupGenerations.keys().next().value;
|
|
152
|
+
if (oldest !== undefined) lookupGenerations.delete(oldest);
|
|
153
|
+
}
|
|
145
154
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
146
155
|
try {
|
|
147
156
|
const timeout = new Promise<null>((resolve) => {
|
|
@@ -154,7 +163,16 @@ async function boundedLookup(
|
|
|
154
163
|
}, LOOKUP_TIMEOUT_MS);
|
|
155
164
|
});
|
|
156
165
|
const directory = await Promise.race([
|
|
157
|
-
lookupSessionDirectory(client, sessionId, fallbackDirectory)
|
|
166
|
+
lookupSessionDirectory(client, sessionId, fallbackDirectory)
|
|
167
|
+
.then((directory) => {
|
|
168
|
+
if (lookupGenerations.get(sessionId) === generation && directory !== null) {
|
|
169
|
+
setCache(sessionId, directory);
|
|
170
|
+
}
|
|
171
|
+
return directory;
|
|
172
|
+
})
|
|
173
|
+
.finally(() => {
|
|
174
|
+
if (lookupGenerations.get(sessionId) === generation) lookupGenerations.delete(sessionId);
|
|
175
|
+
}),
|
|
158
176
|
timeout,
|
|
159
177
|
]);
|
|
160
178
|
setCache(sessionId, directory);
|
|
@@ -308,6 +326,7 @@ export function warmSessionDirectory(
|
|
|
308
326
|
*/
|
|
309
327
|
const VERIFY_TTL_MS = 15_000;
|
|
310
328
|
const verifyCache = new Map<string, { directory: string | null; verifiedAt: number }>();
|
|
329
|
+
const verifyInflight = new Map<string, Promise<string | null>>();
|
|
311
330
|
|
|
312
331
|
export async function verifySessionDirectory(
|
|
313
332
|
client: unknown,
|
|
@@ -316,14 +335,32 @@ export async function verifySessionDirectory(
|
|
|
316
335
|
if (!sessionId) return null;
|
|
317
336
|
const hit = verifyCache.get(sessionId);
|
|
318
337
|
if (hit && Date.now() - hit.verifiedAt < VERIFY_TTL_MS) return hit.directory;
|
|
338
|
+
const pending = verifyInflight.get(sessionId);
|
|
339
|
+
if (pending) return pending;
|
|
340
|
+
const lookup = boundedVerification(client, sessionId).finally(() => {
|
|
341
|
+
verifyInflight.delete(sessionId);
|
|
342
|
+
});
|
|
343
|
+
verifyInflight.set(sessionId, lookup);
|
|
344
|
+
return lookup;
|
|
345
|
+
}
|
|
319
346
|
|
|
347
|
+
async function boundedVerification(client: unknown, sessionId: string): Promise<string | null> {
|
|
320
348
|
const c = client as OpenCodeClientShape;
|
|
321
349
|
const sessionApi = c?.session;
|
|
322
350
|
if (!sessionApi || typeof sessionApi.get !== "function") return null;
|
|
323
351
|
|
|
324
352
|
let dir: string | null = null;
|
|
353
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
325
354
|
try {
|
|
326
|
-
const result = await
|
|
355
|
+
const result = await Promise.race([
|
|
356
|
+
sessionApi.get({ path: { id: sessionId } }),
|
|
357
|
+
new Promise<never>((_, reject) => {
|
|
358
|
+
timer = setTimeout(
|
|
359
|
+
() => reject(new Error("Session verification timed out")),
|
|
360
|
+
LOOKUP_TIMEOUT_MS,
|
|
361
|
+
);
|
|
362
|
+
}),
|
|
363
|
+
]);
|
|
327
364
|
const session: SessionInfo | undefined =
|
|
328
365
|
(result as { data?: SessionInfo } | undefined)?.data ?? (result as SessionInfo | undefined);
|
|
329
366
|
if (session && typeof session.directory === "string" && session.directory.length > 0) {
|
|
@@ -334,6 +371,8 @@ export async function verifySessionDirectory(
|
|
|
334
371
|
// failures: the next poll should retry (transient SDK errors must not
|
|
335
372
|
// stick the sidebar on placeholder for the TTL window).
|
|
336
373
|
return null;
|
|
374
|
+
} finally {
|
|
375
|
+
clearTimeout(timer);
|
|
337
376
|
}
|
|
338
377
|
|
|
339
378
|
verifyCache.set(sessionId, { directory: dir, verifiedAt: Date.now() });
|
|
@@ -346,9 +385,26 @@ export async function verifySessionDirectory(
|
|
|
346
385
|
return dir;
|
|
347
386
|
}
|
|
348
387
|
|
|
388
|
+
/** Release transport routes on deletion without querying a session the host has already removed. */
|
|
389
|
+
export async function closeDeletedSessionRoutes(
|
|
390
|
+
pool: AftTransportPool,
|
|
391
|
+
sessionId: string,
|
|
392
|
+
fallbackDirectory: string,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
// A timeout may have bound a fallback before the stored root was known. Close both routes.
|
|
395
|
+
const roots = new Set(pool.activeBridges().map((bridge) => bridge.getCwd()));
|
|
396
|
+
roots.add(getSessionDirectoryCached(sessionId) ?? fallbackDirectory);
|
|
397
|
+
await Promise.allSettled([...roots].map((root) => pool.closeSession(root, sessionId)));
|
|
398
|
+
cache.delete(sessionId);
|
|
399
|
+
lookupGenerations.delete(sessionId);
|
|
400
|
+
verifyCache.delete(sessionId);
|
|
401
|
+
}
|
|
402
|
+
|
|
349
403
|
/** Test-only: clear the cache between unit tests. */
|
|
350
404
|
export function _resetSessionDirectoryCacheForTest(): void {
|
|
351
405
|
cache.clear();
|
|
352
406
|
inflight.clear();
|
|
407
|
+
lookupGenerations.clear();
|
|
353
408
|
verifyCache.clear();
|
|
409
|
+
verifyInflight.clear();
|
|
354
410
|
}
|
package/src/shared/status.ts
CHANGED
|
@@ -28,13 +28,14 @@ export const NOT_STARTED_STATUS_TEXT = "Starts on the first tool call.";
|
|
|
28
28
|
export function formatSemanticIndexLabel(
|
|
29
29
|
semantic: Pick<
|
|
30
30
|
AftStatusSnapshot["semantic_index"],
|
|
31
|
-
"status" | "stage" | "error" | "reason" | "backend_url"
|
|
31
|
+
"status" | "stage" | "error" | "reason" | "backend_url" | "pending_reason"
|
|
32
32
|
>,
|
|
33
33
|
): string {
|
|
34
|
-
|
|
34
|
+
const label = formatSemanticIndexStatus(semantic.status, semantic.stage, semantic.error, {
|
|
35
35
|
reason: semantic.reason,
|
|
36
36
|
backendUrl: semantic.backend_url,
|
|
37
37
|
});
|
|
38
|
+
return semantic.pending_reason ? `${label} — ${semantic.pending_reason}` : label;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export interface StatusCompressionAggregate {
|
|
@@ -117,6 +118,17 @@ export interface AftStatusSnapshot {
|
|
|
117
118
|
reason?: string | null;
|
|
118
119
|
/** Configured remote embedding backend URL, when there is one. */
|
|
119
120
|
backend_url?: string | null;
|
|
121
|
+
/** Operator-only coordination line; never an agent tool failure reason. */
|
|
122
|
+
pending_reason?: string | null;
|
|
123
|
+
coordination_error?: {
|
|
124
|
+
state: string;
|
|
125
|
+
kind: string;
|
|
126
|
+
reason: string;
|
|
127
|
+
detail: string;
|
|
128
|
+
retryable: boolean;
|
|
129
|
+
retry_scheduled: boolean;
|
|
130
|
+
retry_in_ms: number | null;
|
|
131
|
+
};
|
|
120
132
|
};
|
|
121
133
|
disk: {
|
|
122
134
|
storage_dir: string | null;
|
|
@@ -323,6 +335,7 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
|
|
|
323
335
|
const features = asRecord(response.features);
|
|
324
336
|
const searchIndex = asRecord(response.search_index);
|
|
325
337
|
const semanticIndex = asRecord(response.semantic_index);
|
|
338
|
+
const coordination = asRecord(semanticIndex.coordination_error);
|
|
326
339
|
const semanticConfig = {
|
|
327
340
|
...asRecord(response.semantic),
|
|
328
341
|
...asRecord((response as { semantic_config?: unknown }).semantic_config),
|
|
@@ -370,6 +383,23 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
|
|
|
370
383
|
error: readNullableString(semanticIndex.error),
|
|
371
384
|
reason: readNullableString(semanticIndex.reason),
|
|
372
385
|
backend_url: readNullableString(semanticIndex.backend_url),
|
|
386
|
+
...(typeof semanticIndex.pending_reason === "string" &&
|
|
387
|
+
semanticIndex.pending_reason.length > 0
|
|
388
|
+
? { pending_reason: semanticIndex.pending_reason }
|
|
389
|
+
: {}),
|
|
390
|
+
...(Object.keys(coordination).length > 0
|
|
391
|
+
? {
|
|
392
|
+
coordination_error: {
|
|
393
|
+
state: readString(coordination.state),
|
|
394
|
+
kind: readString(coordination.kind),
|
|
395
|
+
reason: readString(coordination.reason),
|
|
396
|
+
detail: readString(coordination.detail),
|
|
397
|
+
retryable: readBoolean(coordination.retryable),
|
|
398
|
+
retry_scheduled: readBoolean(coordination.retry_scheduled),
|
|
399
|
+
retry_in_ms: readOptionalNumber(coordination.retry_in_ms),
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
373
403
|
},
|
|
374
404
|
disk: {
|
|
375
405
|
storage_dir: readNullableString(disk.storage_dir),
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
* lookup must stay cheap and bounded:
|
|
26
26
|
* - concurrent first calls for one session share a single lookup;
|
|
27
27
|
* - a lookup that has not answered within LOOKUP_TIMEOUT_MS is abandoned and
|
|
28
|
-
* that call proceeds
|
|
29
|
-
*
|
|
28
|
+
* that call proceeds with an unknown role, so a slow host delays a read by
|
|
29
|
+
* at most LOOKUP_TIMEOUT_MS;
|
|
30
|
+
* - a failed or abandoned lookup is cached as unknown for
|
|
30
31
|
* FAILED_LOOKUP_RETRY_MS, then retried, so a struggling host is not asked on
|
|
31
32
|
* every call but the real answer is still picked up later.
|
|
32
33
|
*
|
|
@@ -50,10 +51,12 @@ interface OpenCodeClientShape {
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
export type SubagentStatus = boolean | "unknown";
|
|
55
|
+
|
|
53
56
|
interface CacheEntry {
|
|
54
|
-
isSubagent:
|
|
57
|
+
isSubagent: SubagentStatus;
|
|
55
58
|
/**
|
|
56
|
-
* When
|
|
59
|
+
* When an unknown answer (recorded after a failed or timed-out
|
|
57
60
|
* lookup) stops counting and the host is asked again. Absent for a real
|
|
58
61
|
* answer, which never changes: a session's parent is fixed at creation.
|
|
59
62
|
*/
|
|
@@ -63,19 +66,25 @@ interface CacheEntry {
|
|
|
63
66
|
const CACHE_MAX_ENTRIES = 200;
|
|
64
67
|
/** Longest a caller waits for the host's session lookup. */
|
|
65
68
|
export const LOOKUP_TIMEOUT_MS = 1_000;
|
|
66
|
-
/**
|
|
67
|
-
|
|
69
|
+
/**
|
|
70
|
+
* How long an unknown role (after a failed or timed-out lookup) is reused
|
|
71
|
+
* before the host is asked again. Short, because an unknown role is treated
|
|
72
|
+
* cautiously by the shell and stop handlers until the real answer arrives; not
|
|
73
|
+
* zero, because a host that keeps timing out would otherwise add up to
|
|
74
|
+
* LOOKUP_TIMEOUT_MS to every tool call.
|
|
75
|
+
*/
|
|
76
|
+
export const FAILED_LOOKUP_RETRY_MS = 5_000;
|
|
68
77
|
const cache = new Map<string, CacheEntry>();
|
|
69
78
|
/** One lookup per session at a time; concurrent first calls await it. */
|
|
70
|
-
const inflight = new Map<string, Promise<
|
|
79
|
+
const inflight = new Map<string, Promise<SubagentStatus>>();
|
|
71
80
|
let now: () => number = () => Date.now();
|
|
72
81
|
|
|
73
82
|
/**
|
|
74
|
-
* Returns `true` when the given session
|
|
75
|
-
* (subagent). Returns `false` for primary sessions, when the SDK is
|
|
76
|
-
* unavailable,
|
|
77
|
-
* LOOKUP_TIMEOUT_MS
|
|
78
|
-
*
|
|
83
|
+
* Returns `true` only when the given session is known to have a non-empty
|
|
84
|
+
* `parentID` (subagent). Returns `false` for primary sessions, when the SDK is
|
|
85
|
+
* unavailable, and for an unknown role (the lookup failed or took longer than
|
|
86
|
+
* LOOKUP_TIMEOUT_MS). Callers whose safe default for an unknown role is the
|
|
87
|
+
* subagent behaviour use resolveSubagentStatus instead.
|
|
79
88
|
*
|
|
80
89
|
* A known answer is an O(1) cache hit. Otherwise at most one host lookup
|
|
81
90
|
* per session is in flight, and it is bounded by LOOKUP_TIMEOUT_MS.
|
|
@@ -83,8 +92,24 @@ let now: () => number = () => Date.now();
|
|
|
83
92
|
export async function resolveIsSubagent(
|
|
84
93
|
client: unknown,
|
|
85
94
|
sessionId: string | undefined,
|
|
86
|
-
|
|
95
|
+
fallbackDirectory?: string,
|
|
87
96
|
): Promise<boolean> {
|
|
97
|
+
return (await resolveSubagentStatus(client, sessionId, fallbackDirectory)) === true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The session's role: `true` when a non-empty `parentID` marks a subagent,
|
|
102
|
+
* `false` when the host says it has no parent (or there is no session or no
|
|
103
|
+
* lookup API to ask), and "unknown" while the host lookup is failing or slow.
|
|
104
|
+
* The shell tool, the watch tool and the OpenCode 2 stop handler treat
|
|
105
|
+
* "unknown" like a subagent, so a slow lookup cannot strip a subagent of its
|
|
106
|
+
* foreground rule or its stop cleanup.
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveSubagentStatus(
|
|
109
|
+
client: unknown,
|
|
110
|
+
sessionId: string | undefined,
|
|
111
|
+
_fallbackDirectory?: string,
|
|
112
|
+
): Promise<SubagentStatus> {
|
|
88
113
|
if (!sessionId) {
|
|
89
114
|
sessionLog(undefined, "[subagent-detect] no sessionId provided → primary");
|
|
90
115
|
return false;
|
|
@@ -110,18 +135,22 @@ export async function resolveIsSubagent(
|
|
|
110
135
|
|
|
111
136
|
/**
|
|
112
137
|
* Runs the host lookup, giving up after LOOKUP_TIMEOUT_MS. A real answer is
|
|
113
|
-
* cached for good; a failure or timeout is cached as "
|
|
114
|
-
* FAILED_LOOKUP_RETRY_MS has passed.
|
|
115
|
-
*
|
|
138
|
+
* cached for good; a failure or timeout is cached as "unknown" until
|
|
139
|
+
* FAILED_LOOKUP_RETRY_MS has passed. On OpenCode 2 the lookup is an Effect, and
|
|
140
|
+
* Effect.timeout interrupts it when the time is up, so no abandoned lookup
|
|
141
|
+
* keeps running. An OpenCode 1 lookup is a promise that cannot be cancelled;
|
|
142
|
+
* its late answer is ignored.
|
|
116
143
|
*/
|
|
117
|
-
async function boundedLookup(client: unknown, sessionId: string): Promise<
|
|
144
|
+
async function boundedLookup(client: unknown, sessionId: string): Promise<SubagentStatus> {
|
|
118
145
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
119
146
|
const timedOut = new Promise<"timeout">((resolve) => {
|
|
120
147
|
timer = setTimeout(() => resolve("timeout"), LOOKUP_TIMEOUT_MS);
|
|
121
148
|
});
|
|
122
149
|
let answer: boolean | "failed" | "timeout";
|
|
123
150
|
try {
|
|
124
|
-
answer =
|
|
151
|
+
answer = isV2PluginContext(client)
|
|
152
|
+
? await resolveV2(client, sessionId)
|
|
153
|
+
: await Promise.race([lookupSession(client, sessionId), timedOut]);
|
|
125
154
|
} finally {
|
|
126
155
|
clearTimeout(timer);
|
|
127
156
|
}
|
|
@@ -132,11 +161,11 @@ async function boundedLookup(client: unknown, sessionId: string): Promise<boolea
|
|
|
132
161
|
if (answer === "timeout") {
|
|
133
162
|
sessionWarn(
|
|
134
163
|
sessionId,
|
|
135
|
-
`[subagent-detect] session lookup did not answer within ${LOOKUP_TIMEOUT_MS}ms →
|
|
164
|
+
`[subagent-detect] session lookup did not answer within ${LOOKUP_TIMEOUT_MS}ms → unknown for now`,
|
|
136
165
|
);
|
|
137
166
|
}
|
|
138
|
-
setCache(sessionId,
|
|
139
|
-
return
|
|
167
|
+
setCache(sessionId, "unknown", now() + FAILED_LOOKUP_RETRY_MS);
|
|
168
|
+
return "unknown";
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
/** The host's answer, or "failed" when the lookup threw. Never rejects. */
|
|
@@ -225,17 +254,16 @@ async function resolveV2(
|
|
|
225
254
|
let session: SessionInfo | undefined;
|
|
226
255
|
try {
|
|
227
256
|
session = await Effect.runPromise(
|
|
228
|
-
context.session.get({ sessionID: sessionId })
|
|
257
|
+
Effect.timeout(context.session.get({ sessionID: sessionId }), LOOKUP_TIMEOUT_MS),
|
|
229
258
|
);
|
|
230
259
|
} catch (err) {
|
|
231
|
-
// Same policy as the V1 path: a failed lookup counts as primary only
|
|
232
|
-
// until FAILED_LOOKUP_RETRY_MS has passed.
|
|
233
260
|
sessionWarn(
|
|
234
261
|
sessionId,
|
|
235
262
|
`[subagent-detect] OpenCode 2 session lookup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
236
263
|
);
|
|
237
264
|
return "failed";
|
|
238
265
|
}
|
|
266
|
+
if (!session) return "failed";
|
|
239
267
|
const isSubagent = typeof session?.parentID === "string" && session.parentID.length > 0;
|
|
240
268
|
sessionLog(
|
|
241
269
|
sessionId,
|
|
@@ -244,7 +272,7 @@ async function resolveV2(
|
|
|
244
272
|
return isSubagent;
|
|
245
273
|
}
|
|
246
274
|
|
|
247
|
-
function setCache(sessionId: string, isSubagent:
|
|
275
|
+
function setCache(sessionId: string, isSubagent: SubagentStatus, retryAfter?: number): void {
|
|
248
276
|
if (cache.has(sessionId)) cache.delete(sessionId);
|
|
249
277
|
cache.set(sessionId, retryAfter === undefined ? { isSubagent } : { isSubagent, retryAfter });
|
|
250
278
|
if (cache.size > CACHE_MAX_ENTRIES) {
|