@cortexkit/aft-opencode 0.58.1 → 0.59.0

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 (109) hide show
  1. package/dist/bash-wait-detach.d.ts.map +1 -1
  2. package/dist/bg-notifications.d.ts +8 -0
  3. package/dist/bg-notifications.d.ts.map +1 -1
  4. package/dist/bridge-bootstrap.d.ts +4 -4
  5. package/dist/config-live-reload.d.ts +44 -8
  6. package/dist/config-live-reload.d.ts.map +1 -1
  7. package/dist/config.d.ts +85 -13
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/configure-warnings.d.ts.map +1 -1
  10. package/dist/entry/server.js +5329 -2607
  11. package/dist/hashline-tag-sources.d.ts +18 -0
  12. package/dist/hashline-tag-sources.d.ts.map +1 -0
  13. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  14. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4511 -2373
  17. package/dist/lsp-auto-install.d.ts +30 -0
  18. package/dist/lsp-auto-install.d.ts.map +1 -1
  19. package/dist/lsp-github-install.d.ts +5 -2
  20. package/dist/lsp-github-install.d.ts.map +1 -1
  21. package/dist/lsp-github-table.d.ts +4 -3
  22. package/dist/lsp-github-table.d.ts.map +1 -1
  23. package/dist/lsp-npm-table.d.ts +10 -0
  24. package/dist/lsp-npm-table.d.ts.map +1 -1
  25. package/dist/lsp-registry-probe.d.ts +8 -4
  26. package/dist/lsp-registry-probe.d.ts.map +1 -1
  27. package/dist/notifications.d.ts +1 -1
  28. package/dist/notifications.d.ts.map +1 -1
  29. package/dist/permissions/v2-service.d.ts +110 -5
  30. package/dist/permissions/v2-service.d.ts.map +1 -1
  31. package/dist/rpc/contract.d.ts +1 -1
  32. package/dist/rpc/contract.d.ts.map +1 -1
  33. package/dist/rpc/register.d.ts +3 -5
  34. package/dist/rpc/register.d.ts.map +1 -1
  35. package/dist/shared/ignored-message.d.ts.map +1 -1
  36. package/dist/shared/read-vision.d.ts.map +1 -1
  37. package/dist/shared/rpc-client.d.ts +8 -0
  38. package/dist/shared/rpc-client.d.ts.map +1 -1
  39. package/dist/shared/rpc-server.d.ts.map +1 -1
  40. package/dist/shared/session-directory.d.ts +9 -0
  41. package/dist/shared/session-directory.d.ts.map +1 -1
  42. package/dist/shared/status.d.ts +1 -0
  43. package/dist/shared/status.d.ts.map +1 -1
  44. package/dist/shared/subagent-detect.d.ts +11 -4
  45. package/dist/shared/subagent-detect.d.ts.map +1 -1
  46. package/dist/shared/v2-context.d.ts +3 -0
  47. package/dist/shared/v2-context.d.ts.map +1 -0
  48. package/dist/subc-tool-schemas.d.ts +23 -1
  49. package/dist/subc-tool-schemas.d.ts.map +1 -1
  50. package/dist/tool-registration.d.ts +11 -8
  51. package/dist/tool-registration.d.ts.map +1 -1
  52. package/dist/tools/_shared.d.ts +16 -15
  53. package/dist/tools/_shared.d.ts.map +1 -1
  54. package/dist/tools/bash.d.ts +41 -1
  55. package/dist/tools/bash.d.ts.map +1 -1
  56. package/dist/tools/bash_watch.d.ts +14 -4
  57. package/dist/tools/bash_watch.d.ts.map +1 -1
  58. package/dist/tools/bash_write.d.ts.map +1 -1
  59. package/dist/tools/definitions/v2.d.ts +4 -0
  60. package/dist/tools/definitions/v2.d.ts.map +1 -1
  61. package/dist/tools/hoisted/v2.d.ts +8 -1
  62. package/dist/tools/hoisted/v2.d.ts.map +1 -1
  63. package/dist/tools/hoisted.d.ts +27 -7
  64. package/dist/tools/hoisted.d.ts.map +1 -1
  65. package/dist/tools/inspect.d.ts +10 -1
  66. package/dist/tools/inspect.d.ts.map +1 -1
  67. package/dist/tools/permissions.d.ts +2 -1
  68. package/dist/tools/permissions.d.ts.map +1 -1
  69. package/dist/tools/reading.d.ts +0 -3
  70. package/dist/tools/reading.d.ts.map +1 -1
  71. package/dist/tools/search.d.ts.map +1 -1
  72. package/dist/tools/semantic.d.ts.map +1 -1
  73. package/dist/tui/aft-rpc.d.ts +14 -0
  74. package/dist/tui/aft-rpc.d.ts.map +1 -1
  75. package/dist/tui/v2-status.d.ts.map +1 -1
  76. package/dist/v2-host-tool-overlap.d.ts +66 -0
  77. package/dist/v2-host-tool-overlap.d.ts.map +1 -0
  78. package/dist/v2-prompt-detach.d.ts +70 -0
  79. package/dist/v2-prompt-detach.d.ts.map +1 -0
  80. package/dist/v2-tool-hooks.d.ts +6 -0
  81. package/dist/v2-tool-hooks.d.ts.map +1 -0
  82. package/dist/v2-workflow-hints.d.ts +4 -0
  83. package/dist/v2-workflow-hints.d.ts.map +1 -0
  84. package/dist/wakes/runtime-consumer.d.ts +2 -0
  85. package/dist/wakes/runtime-consumer.d.ts.map +1 -1
  86. package/dist/wakes/session-delivery.d.ts +9 -0
  87. package/dist/wakes/session-delivery.d.ts.map +1 -1
  88. package/dist/workflow-hints.d.ts +20 -7
  89. package/dist/workflow-hints.d.ts.map +1 -1
  90. package/package.json +9 -9
  91. package/src/entry/server-runtime.mjs +41 -5
  92. package/src/shared/ignored-message.ts +18 -0
  93. package/src/shared/read-vision.ts +14 -4
  94. package/src/shared/rpc-client.ts +46 -5
  95. package/src/shared/rpc-server.ts +10 -0
  96. package/src/shared/session-directory.ts +147 -7
  97. package/src/shared/status.ts +15 -5
  98. package/src/shared/subagent-detect.ts +89 -28
  99. package/src/shared/v2-context.ts +7 -0
  100. package/src/tui/aft-rpc.ts +11 -0
  101. package/src/tui/index.tsx +20 -16
  102. package/src/tui/sidebar-view.tsx +9 -1
  103. package/src/tui/v2-status.ts +7 -5
  104. package/src/tui/v2.tsx +47 -0
  105. package/src/tui-compiled/aft-rpc.ts +11 -0
  106. package/src/tui-compiled/index.tsx +27 -16
  107. package/src/tui-compiled/sidebar-view.tsx +11 -2
  108. package/src/tui-compiled/v2-status.ts +7 -5
  109. package/src/tui-compiled/v2.tsx +26 -0
@@ -19,12 +19,64 @@
19
19
  * `packages/opencode/src/session/registry.ts`. Until then this
20
20
  * workaround makes AFT robust against the wrong cwd.
21
21
  */
22
+ import { Effect } from "effect";
23
+
22
24
  import { sessionWarn } from "../logger.js";
23
25
 
24
26
  interface SessionInfo {
25
27
  directory?: string;
26
28
  }
27
29
 
30
+ /**
31
+ * OpenCode 2 passes its plugin context where OpenCode 1 passes the SDK client
32
+ * (`entry/server-runtime.mjs` hands the context over as `client`). That context
33
+ * has a different session API, verified against the `v2` branch of the OpenCode repository:
34
+ *
35
+ * - `session.get` takes `{ sessionID }` and returns an Effect of `Session.Info`
36
+ * (packages/client/src/effect/api/api.ts `SessionGetOperation`,
37
+ * packages/plugin/src/effect/plugin.ts `Context`). Calling it the OpenCode 1
38
+ * way builds an Effect that never runs, and awaiting an Effect yields the
39
+ * Effect itself, so no directory was ever read.
40
+ * - `Session.Info` has no `directory`; the session's working directory is
41
+ * `location.directory`, a required `Location.Ref`
42
+ * (packages/schema/src/session.ts `Info`, packages/schema/src/location.ts
43
+ * `Ref`). For a session created in a linked git worktree it is that worktree.
44
+ *
45
+ * It is told apart from an OpenCode 1 SDK client by having a string
46
+ * `location.directory` next to a `session.get` function; the OpenCode 2 entry
47
+ * refuses to boot without that `location`, and `subagent-detect.ts` uses the
48
+ * same test for its own session lookup.
49
+ */
50
+ interface V2SessionInfo {
51
+ location?: { directory?: unknown };
52
+ }
53
+
54
+ interface V2PluginContextShape {
55
+ location: { directory: string };
56
+ session: {
57
+ get(input: { sessionID: string }): Effect.Effect<V2SessionInfo | undefined, unknown>;
58
+ };
59
+ }
60
+
61
+ function isV2PluginContext(client: unknown): client is V2PluginContextShape {
62
+ if (!client || typeof client !== "object") return false;
63
+ const candidate = client as { location?: { directory?: unknown }; session?: { get?: unknown } };
64
+ return (
65
+ typeof candidate.location?.directory === "string" &&
66
+ typeof candidate.session?.get === "function"
67
+ );
68
+ }
69
+
70
+ /**
71
+ * How long a session lookup may run on either host version. Tool routing
72
+ * awaits this lookup, so an unresponsive host must not hold the call forever.
73
+ * After this budget the call proceeds with its own directory instead.
74
+ */
75
+ export const LOOKUP_TIMEOUT_MS = 1_000;
76
+ export const V2_SESSION_LOOKUP_TIMEOUT_MS = LOOKUP_TIMEOUT_MS;
77
+ /** A failed lookup is provisional; retry later without querying on every call. */
78
+ export const FAILED_LOOKUP_RETRY_MS = 30_000;
79
+
28
80
  interface OpenCodeClientShape {
29
81
  session?: {
30
82
  get?: (input: {
@@ -36,14 +88,15 @@ interface OpenCodeClientShape {
36
88
  }
37
89
 
38
90
  interface CacheEntry {
39
- /** Resolved directory, or `null` if lookup failed and we should not retry. */
91
+ /** Resolved directory, or `null` while a failed lookup is cooling down. */
40
92
  directory: string | null;
41
- /** Wall-clock timestamp of the cache entry, used only for LRU eviction. */
93
+ /** When the answer was recorded; failed lookups become retryable after 30 seconds. */
42
94
  recordedAt: number;
43
95
  }
44
96
 
45
97
  const CACHE_MAX_ENTRIES = 200;
46
98
  const cache = new Map<string, CacheEntry>();
99
+ const inflight = new Map<string, Promise<string | null>>();
47
100
 
48
101
  /**
49
102
  * Resolve the project directory the session was created with from the SDK's
@@ -64,17 +117,63 @@ export async function getSessionDirectory(
64
117
  if (!sessionId) return null;
65
118
 
66
119
  const cached = cache.get(sessionId);
67
- if (cached) {
120
+ if (
121
+ cached &&
122
+ (cached.directory !== null || Date.now() - cached.recordedAt < FAILED_LOOKUP_RETRY_MS)
123
+ ) {
68
124
  // Refresh LRU position
69
125
  cache.delete(sessionId);
70
126
  cache.set(sessionId, cached);
71
127
  return cached.directory;
72
128
  }
73
129
 
130
+ const pending = inflight.get(sessionId);
131
+ if (pending) return pending;
132
+ const lookup = boundedLookup(client, sessionId, fallbackDirectory).finally(() => {
133
+ inflight.delete(sessionId);
134
+ });
135
+ inflight.set(sessionId, lookup);
136
+ return lookup;
137
+ }
138
+
139
+ /** Ignore late answers: a timed-out lookup must not overwrite a later retry. */
140
+ async function boundedLookup(
141
+ client: unknown,
142
+ sessionId: string,
143
+ fallbackDirectory: string,
144
+ ): Promise<string | null> {
145
+ let timer: ReturnType<typeof setTimeout> | undefined;
146
+ try {
147
+ const timeout = new Promise<null>((resolve) => {
148
+ timer = setTimeout(() => {
149
+ sessionWarn(
150
+ sessionId,
151
+ `[aft-plugin] session lookup did not answer within ${LOOKUP_TIMEOUT_MS}ms; using the call's directory for now`,
152
+ );
153
+ resolve(null);
154
+ }, LOOKUP_TIMEOUT_MS);
155
+ });
156
+ const directory = await Promise.race([
157
+ lookupSessionDirectory(client, sessionId, fallbackDirectory),
158
+ timeout,
159
+ ]);
160
+ setCache(sessionId, directory);
161
+ return directory;
162
+ } finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+
167
+ async function lookupSessionDirectory(
168
+ client: unknown,
169
+ sessionId: string,
170
+ fallbackDirectory: string,
171
+ ): Promise<string | null> {
172
+ if (isV2PluginContext(client)) return lookupV2SessionDirectory(client, sessionId);
173
+
74
174
  const c = client as OpenCodeClientShape;
75
175
  const sessionApi = c?.session;
76
176
  if (!sessionApi || typeof sessionApi.get !== "function") {
77
- setCache(sessionId, null);
78
177
  return null;
79
178
  }
80
179
 
@@ -96,7 +195,7 @@ export async function getSessionDirectory(
96
195
  dir = session.directory;
97
196
  }
98
197
  } catch (err) {
99
- // Don't poison the cache on transient errors — but do log once.
198
+ // The bounded caller caches failures briefly so a struggling host is not polled per call.
100
199
  sessionWarn(
101
200
  sessionId,
102
201
  `[aft-plugin] session.get lookup failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -104,7 +203,43 @@ export async function getSessionDirectory(
104
203
  return null;
105
204
  }
106
205
 
107
- setCache(sessionId, dir);
206
+ return dir;
207
+ }
208
+
209
+ /**
210
+ * OpenCode 2 lookup: run the context's `session.get` Effect under a timeout and
211
+ * read `location.directory`.
212
+ *
213
+ * The outer bounded lookup applies the same retry policy as OpenCode 1.
214
+ * The Effect timeout also interrupts the abandoned V2 lookup; fallback remains
215
+ * the tool call's runtime.directory (the host's Location directory), never
216
+ * the main checkout.
217
+ */
218
+ async function lookupV2SessionDirectory(
219
+ context: V2PluginContextShape,
220
+ sessionId: string,
221
+ ): Promise<string | null> {
222
+ let dir: string | null = null;
223
+ let failure: string | undefined;
224
+ try {
225
+ const session = await Effect.runPromise(
226
+ Effect.timeout(
227
+ context.session.get({ sessionID: sessionId }) as Effect.Effect<V2SessionInfo | undefined>,
228
+ V2_SESSION_LOOKUP_TIMEOUT_MS,
229
+ ),
230
+ );
231
+ const directory = session?.location?.directory;
232
+ if (typeof directory === "string" && directory.length > 0) dir = directory;
233
+ else failure = "the session record has no location.directory";
234
+ } catch (err) {
235
+ failure = err instanceof Error ? err.message || err.name : String(err);
236
+ }
237
+ if (failure !== undefined) {
238
+ sessionWarn(
239
+ sessionId,
240
+ `[aft-plugin] OpenCode 2 session lookup failed (${failure}); tool paths and bash use the Location directory ${context.location.directory} for this session`,
241
+ );
242
+ }
108
243
  return dir;
109
244
  }
110
245
 
@@ -134,6 +269,10 @@ export function getSessionDirectoryCached(
134
269
  if (!sessionId) return undefined;
135
270
  const cached = cache.get(sessionId);
136
271
  if (!cached) return undefined;
272
+ if (cached.directory === null && Date.now() - cached.recordedAt >= FAILED_LOOKUP_RETRY_MS) {
273
+ cache.delete(sessionId);
274
+ return undefined;
275
+ }
137
276
  return cached.directory;
138
277
  }
139
278
 
@@ -148,7 +287,7 @@ export function warmSessionDirectory(
148
287
  fallbackDirectory: string,
149
288
  ): void {
150
289
  if (!sessionId) return;
151
- if (cache.has(sessionId)) return;
290
+ if (getSessionDirectoryCached(sessionId) !== undefined) return;
152
291
  void getSessionDirectory(client, sessionId, fallbackDirectory);
153
292
  }
154
293
 
@@ -210,5 +349,6 @@ export async function verifySessionDirectory(
210
349
  /** Test-only: clear the cache between unit tests. */
211
350
  export function _resetSessionDirectoryCacheForTest(): void {
212
351
  cache.clear();
352
+ inflight.clear();
213
353
  verifyCache.clear();
214
354
  }
@@ -55,6 +55,7 @@ export interface StatusCompression {
55
55
  * absence as a clean zero.
56
56
  */
57
57
  export interface StatusBar {
58
+ disabled_categories?: string[];
58
59
  errors?: number;
59
60
  warnings?: number;
60
61
  dead_code?: number;
@@ -214,6 +215,9 @@ function readCompression(value: unknown): StatusCompression | undefined {
214
215
  function readStatusBar(value: unknown): StatusBar | undefined {
215
216
  if (typeof value !== "object" || value === null) return undefined;
216
217
  const bar = asRecord(value);
218
+ const disabled = Array.isArray(bar.disabled_categories)
219
+ ? bar.disabled_categories.filter((key): key is string => typeof key === "string")
220
+ : [];
217
221
  const errors = readOptionalNumber(bar.errors);
218
222
  const warnings = readOptionalNumber(bar.warnings);
219
223
  const deadCode = readOptionalNumber(bar.dead_code);
@@ -228,11 +232,13 @@ function readStatusBar(value: unknown): StatusBar | undefined {
228
232
  unusedExports === null &&
229
233
  duplicates === null &&
230
234
  todos === null &&
231
- !noLanguageServer
235
+ !noLanguageServer &&
236
+ disabled.length === 0
232
237
  ) {
233
238
  return undefined;
234
239
  }
235
240
  return {
241
+ ...(disabled.length > 0 ? { disabled_categories: disabled } : {}),
236
242
  ...(errors !== null ? { errors } : {}),
237
243
  ...(warnings !== null ? { warnings } : {}),
238
244
  ...(deadCode !== null ? { dead_code: deadCode } : {}),
@@ -437,8 +443,10 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
437
443
  lines.push(`- storage dir: ${status.storage_dir ?? status.disk.storage_dir}`);
438
444
  }
439
445
 
440
- if (status.status_bar) {
441
- const sb = status.status_bar;
446
+ const sb = status.status_bar ?? status.status_bar_values;
447
+ if (sb) {
448
+ for (const key of sb.disabled_categories ?? [])
449
+ lines.push(`- ${key.replaceAll("_", " ")}: ○ off`);
442
450
  const rows = [
443
451
  ["errors", sb.errors],
444
452
  ["warnings", sb.warnings],
@@ -594,8 +602,10 @@ export function formatStatusMarkdown(status: AftStatusSnapshot): string {
594
602
  lines.push(`- **Storage dir:** \`${status.storage_dir ?? status.disk.storage_dir}\``);
595
603
  }
596
604
 
597
- if (status.status_bar) {
598
- const sb = status.status_bar;
605
+ const sb = status.status_bar ?? status.status_bar_values;
606
+ if (sb) {
607
+ for (const key of sb.disabled_categories ?? [])
608
+ lines.push(`- **${key.replaceAll("_", " ")}:** ○ off`);
599
609
  const rows = [
600
610
  ["Errors", sb.errors],
601
611
  ["Warnings", sb.warnings],
@@ -21,10 +21,14 @@
21
21
  * identity is sticky (parentID never changes after session creation), so
22
22
  * the cache never needs invalidation.
23
23
  *
24
- * Errors are not cached. If the SDK is briefly unavailable on the first
25
- * call, we retry on the next bash invocation rather than permanently
26
- * misclassifying. This is cheap because the cache is hit on every
27
- * subsequent call within the same session.
24
+ * Every bridge call asks for the role (AFT words its replies by it), so the
25
+ * lookup must stay cheap and bounded:
26
+ * - concurrent first calls for one session share a single lookup;
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
30
+ * FAILED_LOOKUP_RETRY_MS, then retried, so a struggling host is not asked on
31
+ * every call but the real answer is still picked up later.
28
32
  *
29
33
  * Mirrors the pattern in `session-directory.ts`.
30
34
  */
@@ -48,20 +52,33 @@ interface OpenCodeClientShape {
48
52
 
49
53
  interface CacheEntry {
50
54
  isSubagent: boolean;
51
- recordedAt: number;
55
+ /**
56
+ * When a provisional "primary" answer (recorded after a failed or timed-out
57
+ * lookup) stops counting and the host is asked again. Absent for a real
58
+ * answer, which never changes: a session's parent is fixed at creation.
59
+ */
60
+ retryAfter?: number;
52
61
  }
53
62
 
54
63
  const CACHE_MAX_ENTRIES = 200;
64
+ /** Longest a caller waits for the host's session lookup. */
65
+ 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;
55
68
  const cache = new Map<string, CacheEntry>();
69
+ /** One lookup per session at a time; concurrent first calls await it. */
70
+ const inflight = new Map<string, Promise<boolean>>();
71
+ let now: () => number = () => Date.now();
56
72
 
57
73
  /**
58
74
  * Returns `true` when the given session has a non-empty `parentID`
59
75
  * (subagent). Returns `false` for primary sessions, when the SDK is
60
- * unavailable, or when any error occurs — the false default keeps
61
- * primary sessions working exactly as before.
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.
62
79
  *
63
- * First call per session: one SDK round-trip. All subsequent calls:
64
- * O(1) cache lookup.
80
+ * A known answer is an O(1) cache hit. Otherwise at most one host lookup
81
+ * per session is in flight, and it is bounded by LOOKUP_TIMEOUT_MS.
65
82
  */
66
83
  export async function resolveIsSubagent(
67
84
  client: unknown,
@@ -74,7 +91,7 @@ export async function resolveIsSubagent(
74
91
  }
75
92
 
76
93
  const cached = cache.get(sessionId);
77
- if (cached) {
94
+ if (cached && (cached.retryAfter === undefined || now() < cached.retryAfter)) {
78
95
  // Refresh LRU position. Don't log the hit — it's pure noise; the
79
96
  // downstream call sites already log their effective gate decisions.
80
97
  cache.delete(sessionId);
@@ -82,6 +99,48 @@ export async function resolveIsSubagent(
82
99
  return cached.isSubagent;
83
100
  }
84
101
 
102
+ const pending = inflight.get(sessionId);
103
+ if (pending) return pending;
104
+ const lookup = boundedLookup(client, sessionId).finally(() => {
105
+ inflight.delete(sessionId);
106
+ });
107
+ inflight.set(sessionId, lookup);
108
+ return lookup;
109
+ }
110
+
111
+ /**
112
+ * 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.
116
+ */
117
+ async function boundedLookup(client: unknown, sessionId: string): Promise<boolean> {
118
+ let timer: ReturnType<typeof setTimeout> | undefined;
119
+ const timedOut = new Promise<"timeout">((resolve) => {
120
+ timer = setTimeout(() => resolve("timeout"), LOOKUP_TIMEOUT_MS);
121
+ });
122
+ let answer: boolean | "failed" | "timeout";
123
+ try {
124
+ answer = await Promise.race([lookupSession(client, sessionId), timedOut]);
125
+ } finally {
126
+ clearTimeout(timer);
127
+ }
128
+ if (typeof answer === "boolean") {
129
+ setCache(sessionId, answer);
130
+ return answer;
131
+ }
132
+ if (answer === "timeout") {
133
+ sessionWarn(
134
+ sessionId,
135
+ `[subagent-detect] session lookup did not answer within ${LOOKUP_TIMEOUT_MS}ms → primary for now`,
136
+ );
137
+ }
138
+ setCache(sessionId, false, now() + FAILED_LOOKUP_RETRY_MS);
139
+ return false;
140
+ }
141
+
142
+ /** The host's answer, or "failed" when the lookup threw. Never rejects. */
143
+ async function lookupSession(client: unknown, sessionId: string): Promise<boolean | "failed"> {
85
144
  if (isV2PluginContext(client)) return resolveV2(client, sessionId);
86
145
 
87
146
  const c = client as OpenCodeClientShape;
@@ -93,7 +152,6 @@ export async function resolveIsSubagent(
93
152
  sessionId,
94
153
  `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`,
95
154
  );
96
- setCache(sessionId, false);
97
155
  return false;
98
156
  }
99
157
 
@@ -102,8 +160,6 @@ export async function resolveIsSubagent(
102
160
  `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`,
103
161
  );
104
162
 
105
- let isSubagent = false;
106
- let parentIdRaw: unknown;
107
163
  try {
108
164
  // Call as a method so the SDK's `this._client` reference resolves
109
165
  // correctly. Extracting `sessionApi.get` into a local would lose
@@ -119,25 +175,21 @@ export async function resolveIsSubagent(
119
175
  // `Session` depending on `ThrowOnError`. Handle both shapes.
120
176
  const session: SessionInfo | undefined =
121
177
  (result as { data?: SessionInfo } | undefined)?.data ?? (result as SessionInfo | undefined);
122
- parentIdRaw = session?.parentID;
123
- isSubagent =
178
+ const parentIdRaw = session?.parentID;
179
+ const isSubagent =
124
180
  session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
125
181
  sessionLog(
126
182
  sessionId,
127
183
  `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`,
128
184
  );
185
+ return isSubagent;
129
186
  } catch (err) {
130
- // Don't poison the cache on transient errors — but do log once.
131
- // Return false so primary-session behavior is preserved.
132
187
  sessionWarn(
133
188
  sessionId,
134
189
  `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`,
135
190
  );
136
- return false;
191
+ return "failed";
137
192
  }
138
-
139
- setCache(sessionId, isSubagent);
140
- return isSubagent;
141
193
  }
142
194
 
143
195
  /**
@@ -166,33 +218,35 @@ function isV2PluginContext(client: unknown): client is V2PluginContextShape {
166
218
  );
167
219
  }
168
220
 
169
- async function resolveV2(context: V2PluginContextShape, sessionId: string): Promise<boolean> {
221
+ async function resolveV2(
222
+ context: V2PluginContextShape,
223
+ sessionId: string,
224
+ ): Promise<boolean | "failed"> {
170
225
  let session: SessionInfo | undefined;
171
226
  try {
172
227
  session = await Effect.runPromise(
173
228
  context.session.get({ sessionID: sessionId }) as Effect.Effect<SessionInfo | undefined>,
174
229
  );
175
230
  } catch (err) {
176
- // Same policy as the V1 path: a failed lookup is not cached, and the call
177
- // is treated as primary for now so the next tool call can retry.
231
+ // Same policy as the V1 path: a failed lookup counts as primary only
232
+ // until FAILED_LOOKUP_RETRY_MS has passed.
178
233
  sessionWarn(
179
234
  sessionId,
180
235
  `[subagent-detect] OpenCode 2 session lookup failed: ${err instanceof Error ? err.message : String(err)}`,
181
236
  );
182
- return false;
237
+ return "failed";
183
238
  }
184
239
  const isSubagent = typeof session?.parentID === "string" && session.parentID.length > 0;
185
240
  sessionLog(
186
241
  sessionId,
187
242
  `[subagent-detect] OpenCode 2 session parentID=${JSON.stringify(session?.parentID)} → isSubagent=${isSubagent}`,
188
243
  );
189
- setCache(sessionId, isSubagent);
190
244
  return isSubagent;
191
245
  }
192
246
 
193
- function setCache(sessionId: string, isSubagent: boolean): void {
247
+ function setCache(sessionId: string, isSubagent: boolean, retryAfter?: number): void {
194
248
  if (cache.has(sessionId)) cache.delete(sessionId);
195
- cache.set(sessionId, { isSubagent, recordedAt: Date.now() });
249
+ cache.set(sessionId, retryAfter === undefined ? { isSubagent } : { isSubagent, retryAfter });
196
250
  if (cache.size > CACHE_MAX_ENTRIES) {
197
251
  const oldest = cache.keys().next().value;
198
252
  if (oldest !== undefined) cache.delete(oldest);
@@ -202,4 +256,11 @@ function setCache(sessionId: string, isSubagent: boolean): void {
202
256
  /** Test-only cache reset. Not exported from the public surface. */
203
257
  export function _resetSubagentCacheForTest(): void {
204
258
  cache.clear();
259
+ inflight.clear();
260
+ now = () => Date.now();
261
+ }
262
+
263
+ /** Test-only clock override for the failed-lookup retry window. */
264
+ export function _setSubagentClockForTest(clock: () => number): void {
265
+ now = clock;
205
266
  }
@@ -0,0 +1,7 @@
1
+ /** V2 passes a Location-scoped Effect context where V1 passes an SDK client. */
2
+ export function isV2PluginContext(client: unknown): boolean {
3
+ return (
4
+ typeof (client as { location?: { directory?: unknown } } | null)?.location?.directory ===
5
+ "string"
6
+ );
7
+ }
@@ -24,8 +24,18 @@ export const IndexProgress = Schema.Struct({
24
24
  total: Schema.optional(Schema.Number),
25
25
  });
26
26
 
27
+ /**
28
+ * A one-off notice for the user, shown by the OpenCode 2 TUI as a toast. It
29
+ * travels over RPC rather than into the session because anything written into
30
+ * a session (a synthetic record included) is sent to the model on later turns.
31
+ */
32
+ export const HostToolOverlapNotice = Schema.Struct({
33
+ message: Schema.String,
34
+ });
35
+
27
36
  export type AftRpcSession = typeof RpcSession.Type;
28
37
  export type AftIndexProgress = typeof IndexProgress.Type;
38
+ export type AftHostToolOverlapNotice = typeof HostToolOverlapNotice.Type;
29
39
 
30
40
  export const AftRpc = Rpc.define({
31
41
  id: "aft",
@@ -39,5 +49,6 @@ export const AftRpc = Rpc.define({
39
49
  statusInvalidated: { schema: RpcSession },
40
50
  showStatusDialog: { schema: RpcSession },
41
51
  indexProgress: { schema: IndexProgress },
52
+ hostToolOverlap: { schema: HostToolOverlapNotice },
42
53
  },
43
54
  });
package/src/tui/index.tsx CHANGED
@@ -230,6 +230,7 @@ const StatusDialog = (props: StatusDialogProps) => {
230
230
  // The earlier `formatCompressionDialogRows` returned padded strings that
231
231
  // looked offset against neighboring sections.
232
232
  const compressionAggregateRows = () => formatCompressionSidebarRows(status()?.compression);
233
+ const statusBar = () => status()?.status_bar ?? status()?.status_bar_values;
233
234
 
234
235
  return (
235
236
  <box
@@ -492,56 +493,59 @@ const StatusDialog = (props: StatusDialogProps) => {
492
493
 
493
494
  {/* Human health values are optional. Omit a category until the server
494
495
  proves it rather than displaying an unproven zero as clean. */}
495
- {status()?.status_bar ? (
496
+ {statusBar() ? (
496
497
  <box flexDirection="column" width="100%" marginTop={1}>
497
498
  <text fg={t().text}>
498
- <b>{status()!.status_bar!.tier2_stale ? "Code Health ~" : "Code Health"}</b>
499
+ <b>{statusBar()!.tier2_stale ? "Code Health ~" : "Code Health"}</b>
499
500
  </text>
500
- {status()!.status_bar!.errors !== undefined ? (
501
+ {(statusBar()!.disabled_categories ?? []).map((key) => (
502
+ <R theme={t()} label={key.replaceAll("_", " ")} value="○ off" tone="muted" />
503
+ ))}
504
+ {statusBar()!.errors !== undefined ? (
501
505
  <R
502
506
  theme={t()}
503
507
  label="Errors"
504
- value={formatCountShort(status()!.status_bar!.errors)}
505
- tone={status()!.status_bar!.errors! > 0 ? "err" : "muted"}
508
+ value={formatCountShort(statusBar()!.errors)}
509
+ tone={statusBar()!.errors! > 0 ? "err" : "muted"}
506
510
  />
507
511
  ) : null}
508
- {status()!.status_bar!.warnings !== undefined ? (
512
+ {statusBar()!.warnings !== undefined ? (
509
513
  <R
510
514
  theme={t()}
511
515
  label="Warnings"
512
- value={formatCountShort(status()!.status_bar!.warnings)}
513
- tone={status()!.status_bar!.warnings! > 0 ? "warn" : "muted"}
516
+ value={formatCountShort(statusBar()!.warnings)}
517
+ tone={statusBar()!.warnings! > 0 ? "warn" : "muted"}
514
518
  />
515
519
  ) : null}
516
- {status()!.status_bar!.dead_code !== undefined ? (
520
+ {statusBar()!.dead_code !== undefined ? (
517
521
  <R
518
522
  theme={t()}
519
523
  label="Dead Code"
520
- value={formatCountShort(status()!.status_bar!.dead_code)}
524
+ value={formatCountShort(statusBar()!.dead_code)}
521
525
  tone="muted"
522
526
  />
523
527
  ) : null}
524
- {status()!.status_bar!.unused_exports !== undefined ? (
528
+ {statusBar()!.unused_exports !== undefined ? (
525
529
  <R
526
530
  theme={t()}
527
531
  label="Unused Exports"
528
- value={formatCountShort(status()!.status_bar!.unused_exports)}
532
+ value={formatCountShort(statusBar()!.unused_exports)}
529
533
  tone="muted"
530
534
  />
531
535
  ) : null}
532
- {status()!.status_bar!.duplicates !== undefined ? (
536
+ {statusBar()!.duplicates !== undefined ? (
533
537
  <R
534
538
  theme={t()}
535
539
  label="Duplicates"
536
- value={formatCountShort(status()!.status_bar!.duplicates)}
540
+ value={formatCountShort(statusBar()!.duplicates)}
537
541
  tone="muted"
538
542
  />
539
543
  ) : null}
540
- {status()!.status_bar!.todos !== undefined ? (
544
+ {statusBar()!.todos !== undefined ? (
541
545
  <R
542
546
  theme={t()}
543
547
  label="TODOs"
544
- value={formatCountShort(status()!.status_bar!.todos)}
548
+ value={formatCountShort(statusBar()!.todos)}
545
549
  tone="muted"
546
550
  />
547
551
  ) : null}
@@ -421,7 +421,7 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
421
421
  const trigramBytes = () => s()?.disk?.trigram_disk_bytes ?? 0;
422
422
  const semanticBytes = () => s()?.disk?.semantic_disk_bytes ?? 0;
423
423
  const compressionRows = () => formatCompressionSidebarRows(s()?.compression);
424
- const statusBar = () => s()?.status_bar;
424
+ const statusBar = () => s()?.status_bar ?? s()?.status_bar_values;
425
425
 
426
426
  const degradedSummary = () => {
427
427
  const snap = s();
@@ -653,6 +653,14 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
653
653
  palette={props.palette}
654
654
  title={statusBar()!.tier2_stale ? "Code Health ~" : "Code Health"}
655
655
  />
656
+ {(statusBar()!.disabled_categories ?? []).map((key) => (
657
+ <StatRow
658
+ palette={props.palette}
659
+ label={key.replaceAll("_", " ")}
660
+ value="○ off"
661
+ tone="muted"
662
+ />
663
+ ))}
656
664
  {statusBar()!.errors !== undefined && (
657
665
  <StatRow
658
666
  palette={props.palette}