@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.
Files changed (92) hide show
  1. package/dist/bash-wait-detach.d.ts +1 -0
  2. package/dist/bash-wait-detach.d.ts.map +1 -1
  3. package/dist/bg-notifications.d.ts +37 -12
  4. package/dist/bg-notifications.d.ts.map +1 -1
  5. package/dist/bridge-bootstrap.d.ts +1 -1
  6. package/dist/bridge-bootstrap.d.ts.map +1 -1
  7. package/dist/config-live-reload.d.ts +18 -2
  8. package/dist/config-live-reload.d.ts.map +1 -1
  9. package/dist/config.d.ts +43 -8
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/entry/server.js +4869 -1622
  12. package/dist/hooks/auto-update-checker/index.d.ts +5 -1
  13. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3996 -1482
  16. package/dist/logger.d.ts +6 -10
  17. package/dist/logger.d.ts.map +1 -1
  18. package/dist/lsp-auto-install.d.ts.map +1 -1
  19. package/dist/lsp.d.ts +1 -0
  20. package/dist/lsp.d.ts.map +1 -1
  21. package/dist/shared/rpc-client.d.ts.map +1 -1
  22. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  23. package/dist/shared/rpc-server.d.ts +2 -0
  24. package/dist/shared/rpc-server.d.ts.map +1 -1
  25. package/dist/shared/session-cleanup.d.ts +6 -0
  26. package/dist/shared/session-cleanup.d.ts.map +1 -0
  27. package/dist/shared/session-directory.d.ts +24 -0
  28. package/dist/shared/session-directory.d.ts.map +1 -1
  29. package/dist/shared/status.d.ts +12 -1
  30. package/dist/shared/status.d.ts.map +1 -1
  31. package/dist/shared/subagent-detect.d.ts +24 -8
  32. package/dist/shared/subagent-detect.d.ts.map +1 -1
  33. package/dist/shared/wake-admission.d.ts +68 -0
  34. package/dist/shared/wake-admission.d.ts.map +1 -0
  35. package/dist/shutdown-hooks.d.ts +2 -0
  36. package/dist/shutdown-hooks.d.ts.map +1 -1
  37. package/dist/subc-tool-schemas.d.ts +13 -2
  38. package/dist/subc-tool-schemas.d.ts.map +1 -1
  39. package/dist/tools/_shared.d.ts +5 -1
  40. package/dist/tools/_shared.d.ts.map +1 -1
  41. package/dist/tools/bash.d.ts +1 -0
  42. package/dist/tools/bash.d.ts.map +1 -1
  43. package/dist/tools/bash_watch.d.ts +4 -0
  44. package/dist/tools/bash_watch.d.ts.map +1 -1
  45. package/dist/tools/definitions/v2.d.ts +2 -1
  46. package/dist/tools/definitions/v2.d.ts.map +1 -1
  47. package/dist/tools/hoisted.d.ts +8 -1
  48. package/dist/tools/hoisted.d.ts.map +1 -1
  49. package/dist/tools/inspect.d.ts.map +1 -1
  50. package/dist/tools/metadata.d.ts +7 -0
  51. package/dist/tools/metadata.d.ts.map +1 -0
  52. package/dist/tools/outside-permissions.d.ts +35 -0
  53. package/dist/tools/outside-permissions.d.ts.map +1 -0
  54. package/dist/tools/permissions.d.ts +9 -11
  55. package/dist/tools/permissions.d.ts.map +1 -1
  56. package/dist/tools/reading.d.ts.map +1 -1
  57. package/dist/tools/safety.d.ts +1 -1
  58. package/dist/tools/safety.d.ts.map +1 -1
  59. package/dist/tools/search.d.ts.map +1 -1
  60. package/dist/tools/semantic.d.ts.map +1 -1
  61. package/dist/tui/notification-socket.d.ts.map +1 -1
  62. package/dist/tui/preferences.d.ts +11 -0
  63. package/dist/tui/preferences.d.ts.map +1 -1
  64. package/dist/v2-session-lifecycle.d.ts +7 -0
  65. package/dist/v2-session-lifecycle.d.ts.map +1 -1
  66. package/dist/v2-tool-hooks.d.ts +1 -0
  67. package/dist/v2-tool-hooks.d.ts.map +1 -1
  68. package/dist/wakes/runtime-consumer.d.ts +14 -3
  69. package/dist/wakes/runtime-consumer.d.ts.map +1 -1
  70. package/dist/wakes/session-delivery.d.ts +24 -4
  71. package/dist/wakes/session-delivery.d.ts.map +1 -1
  72. package/dist/wakes/subc-recovery.d.ts +8 -0
  73. package/dist/wakes/subc-recovery.d.ts.map +1 -0
  74. package/package.json +10 -10
  75. package/src/entry/server-runtime.mjs +67 -19
  76. package/src/logger.ts +12 -5
  77. package/src/shared/rpc-client.ts +35 -28
  78. package/src/shared/rpc-notifications.ts +5 -3
  79. package/src/shared/rpc-server.ts +65 -7
  80. package/src/shared/session-cleanup.ts +13 -0
  81. package/src/shared/session-directory.ts +59 -3
  82. package/src/shared/status.ts +32 -2
  83. package/src/shared/subagent-detect.ts +53 -25
  84. package/src/shared/wake-admission.ts +340 -0
  85. package/src/tui/notification-socket.ts +30 -4
  86. package/src/tui/preferences.ts +66 -29
  87. package/src/tui/sidebar-view.tsx +1 -0
  88. package/src/tui/v2.tsx +49 -1
  89. package/src/tui-compiled/notification-socket.ts +30 -4
  90. package/src/tui-compiled/preferences.ts +66 -29
  91. package/src/tui-compiled/sidebar-view.tsx +2 -1
  92. package/src/tui-compiled/v2.tsx +54 -1
@@ -250,7 +250,7 @@ export class AftRpcClient {
250
250
  info: PortInfo,
251
251
  signal?: AbortSignal,
252
252
  ): Promise<T> {
253
- const response = await this.fetchWithTimeout(
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
- const response = await this.fetchWithTimeout(
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?: AbortSignal,
502
- ): Promise<Response> {
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
- return await fetch(url, { ...options, signal: controller.signal });
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
- for (const notification of byNewest) {
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.sort((a, b) => a.id - b.id);
112
+ queue = kept.reverse();
111
113
  }
112
114
  }
113
115
 
@@ -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
- if (!entry.endsWith(".json")) continue;
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
- unlinkSync(filePath);
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 bodyText = await req.text();
309
- if (bodyText.length > MAX_BODY_BYTES) {
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
- let body = "";
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
- body += chunk.toString();
441
- if (body.length > MAX_BODY_BYTES) {
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
- /** Ignore late answers: a timed-out lookup must not overwrite a later retry. */
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 sessionApi.get({ path: { id: sessionId } });
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
  }
@@ -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
- return formatSemanticIndexStatus(semantic.status, semantic.stage, semantic.error, {
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 as primary, so a slow host can't delay a read;
29
- * - a failed or abandoned lookup is cached as "primary" for
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: boolean;
57
+ isSubagent: SubagentStatus;
55
58
  /**
56
- * When a provisional "primary" answer (recorded after a failed or timed-out
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
- /** How long a failed or timed-out lookup is answered as "primary" before retrying. */
67
- export const FAILED_LOOKUP_RETRY_MS = 30_000;
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<boolean>>();
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 has a non-empty `parentID`
75
- * (subagent). Returns `false` for primary sessions, when the SDK is
76
- * unavailable, or when the lookup fails or takes longer than
77
- * LOOKUP_TIMEOUT_MS; the false default keeps primary sessions working
78
- * exactly as before.
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
- _fallbackDirectory?: string,
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 "primary" until
114
- * FAILED_LOOKUP_RETRY_MS has passed. The abandoned host call is left to
115
- * settle on its own; its late answer is ignored.
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<boolean> {
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 = await Promise.race([lookupSession(client, sessionId), timedOut]);
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 → primary for now`,
164
+ `[subagent-detect] session lookup did not answer within ${LOOKUP_TIMEOUT_MS}ms → unknown for now`,
136
165
  );
137
166
  }
138
- setCache(sessionId, false, now() + FAILED_LOOKUP_RETRY_MS);
139
- return false;
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 }) as Effect.Effect<SessionInfo | undefined>,
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: boolean, retryAfter?: number): void {
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) {