@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.
- package/dist/bash-wait-detach.d.ts.map +1 -1
- package/dist/bg-notifications.d.ts +8 -0
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/bridge-bootstrap.d.ts +4 -4
- package/dist/config-live-reload.d.ts +44 -8
- package/dist/config-live-reload.d.ts.map +1 -1
- package/dist/config.d.ts +85 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/configure-warnings.d.ts.map +1 -1
- package/dist/entry/server.js +5329 -2607
- package/dist/hashline-tag-sources.d.ts +18 -0
- package/dist/hashline-tag-sources.d.ts.map +1 -0
- package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -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 +4511 -2373
- package/dist/lsp-auto-install.d.ts +30 -0
- package/dist/lsp-auto-install.d.ts.map +1 -1
- package/dist/lsp-github-install.d.ts +5 -2
- package/dist/lsp-github-install.d.ts.map +1 -1
- package/dist/lsp-github-table.d.ts +4 -3
- package/dist/lsp-github-table.d.ts.map +1 -1
- package/dist/lsp-npm-table.d.ts +10 -0
- package/dist/lsp-npm-table.d.ts.map +1 -1
- package/dist/lsp-registry-probe.d.ts +8 -4
- package/dist/lsp-registry-probe.d.ts.map +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/notifications.d.ts.map +1 -1
- package/dist/permissions/v2-service.d.ts +110 -5
- package/dist/permissions/v2-service.d.ts.map +1 -1
- package/dist/rpc/contract.d.ts +1 -1
- package/dist/rpc/contract.d.ts.map +1 -1
- package/dist/rpc/register.d.ts +3 -5
- package/dist/rpc/register.d.ts.map +1 -1
- package/dist/shared/ignored-message.d.ts.map +1 -1
- package/dist/shared/read-vision.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +8 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/session-directory.d.ts +9 -0
- package/dist/shared/session-directory.d.ts.map +1 -1
- package/dist/shared/status.d.ts +1 -0
- package/dist/shared/status.d.ts.map +1 -1
- package/dist/shared/subagent-detect.d.ts +11 -4
- package/dist/shared/subagent-detect.d.ts.map +1 -1
- package/dist/shared/v2-context.d.ts +3 -0
- package/dist/shared/v2-context.d.ts.map +1 -0
- package/dist/subc-tool-schemas.d.ts +23 -1
- package/dist/subc-tool-schemas.d.ts.map +1 -1
- package/dist/tool-registration.d.ts +11 -8
- package/dist/tool-registration.d.ts.map +1 -1
- package/dist/tools/_shared.d.ts +16 -15
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/bash.d.ts +41 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash_watch.d.ts +14 -4
- package/dist/tools/bash_watch.d.ts.map +1 -1
- package/dist/tools/bash_write.d.ts.map +1 -1
- package/dist/tools/definitions/v2.d.ts +4 -0
- package/dist/tools/definitions/v2.d.ts.map +1 -1
- package/dist/tools/hoisted/v2.d.ts +8 -1
- package/dist/tools/hoisted/v2.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts +27 -7
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/inspect.d.ts +10 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/permissions.d.ts +2 -1
- package/dist/tools/permissions.d.ts.map +1 -1
- package/dist/tools/reading.d.ts +0 -3
- package/dist/tools/reading.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/aft-rpc.d.ts +14 -0
- package/dist/tui/aft-rpc.d.ts.map +1 -1
- package/dist/tui/v2-status.d.ts.map +1 -1
- package/dist/v2-host-tool-overlap.d.ts +66 -0
- package/dist/v2-host-tool-overlap.d.ts.map +1 -0
- package/dist/v2-prompt-detach.d.ts +70 -0
- package/dist/v2-prompt-detach.d.ts.map +1 -0
- package/dist/v2-tool-hooks.d.ts +6 -0
- package/dist/v2-tool-hooks.d.ts.map +1 -0
- package/dist/v2-workflow-hints.d.ts +4 -0
- package/dist/v2-workflow-hints.d.ts.map +1 -0
- package/dist/wakes/runtime-consumer.d.ts +2 -0
- package/dist/wakes/runtime-consumer.d.ts.map +1 -1
- package/dist/wakes/session-delivery.d.ts +9 -0
- package/dist/wakes/session-delivery.d.ts.map +1 -1
- package/dist/workflow-hints.d.ts +20 -7
- package/dist/workflow-hints.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/entry/server-runtime.mjs +41 -5
- package/src/shared/ignored-message.ts +18 -0
- package/src/shared/read-vision.ts +14 -4
- package/src/shared/rpc-client.ts +46 -5
- package/src/shared/rpc-server.ts +10 -0
- package/src/shared/session-directory.ts +147 -7
- package/src/shared/status.ts +15 -5
- package/src/shared/subagent-detect.ts +89 -28
- package/src/shared/v2-context.ts +7 -0
- package/src/tui/aft-rpc.ts +11 -0
- package/src/tui/index.tsx +20 -16
- package/src/tui/sidebar-view.tsx +9 -1
- package/src/tui/v2-status.ts +7 -5
- package/src/tui/v2.tsx +47 -0
- package/src/tui-compiled/aft-rpc.ts +11 -0
- package/src/tui-compiled/index.tsx +27 -16
- package/src/tui-compiled/sidebar-view.tsx +11 -2
- package/src/tui-compiled/v2-status.ts +7 -5
- 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`
|
|
91
|
+
/** Resolved directory, or `null` while a failed lookup is cooling down. */
|
|
40
92
|
directory: string | null;
|
|
41
|
-
/**
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
package/src/shared/status.ts
CHANGED
|
@@ -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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
|
61
|
-
* primary sessions working
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
177
|
-
//
|
|
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
|
|
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
|
|
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
|
}
|
package/src/tui/aft-rpc.ts
CHANGED
|
@@ -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
|
-
{
|
|
496
|
+
{statusBar() ? (
|
|
496
497
|
<box flexDirection="column" width="100%" marginTop={1}>
|
|
497
498
|
<text fg={t().text}>
|
|
498
|
-
<b>{
|
|
499
|
+
<b>{statusBar()!.tier2_stale ? "Code Health ~" : "Code Health"}</b>
|
|
499
500
|
</text>
|
|
500
|
-
{
|
|
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(
|
|
505
|
-
tone={
|
|
508
|
+
value={formatCountShort(statusBar()!.errors)}
|
|
509
|
+
tone={statusBar()!.errors! > 0 ? "err" : "muted"}
|
|
506
510
|
/>
|
|
507
511
|
) : null}
|
|
508
|
-
{
|
|
512
|
+
{statusBar()!.warnings !== undefined ? (
|
|
509
513
|
<R
|
|
510
514
|
theme={t()}
|
|
511
515
|
label="Warnings"
|
|
512
|
-
value={formatCountShort(
|
|
513
|
-
tone={
|
|
516
|
+
value={formatCountShort(statusBar()!.warnings)}
|
|
517
|
+
tone={statusBar()!.warnings! > 0 ? "warn" : "muted"}
|
|
514
518
|
/>
|
|
515
519
|
) : null}
|
|
516
|
-
{
|
|
520
|
+
{statusBar()!.dead_code !== undefined ? (
|
|
517
521
|
<R
|
|
518
522
|
theme={t()}
|
|
519
523
|
label="Dead Code"
|
|
520
|
-
value={formatCountShort(
|
|
524
|
+
value={formatCountShort(statusBar()!.dead_code)}
|
|
521
525
|
tone="muted"
|
|
522
526
|
/>
|
|
523
527
|
) : null}
|
|
524
|
-
{
|
|
528
|
+
{statusBar()!.unused_exports !== undefined ? (
|
|
525
529
|
<R
|
|
526
530
|
theme={t()}
|
|
527
531
|
label="Unused Exports"
|
|
528
|
-
value={formatCountShort(
|
|
532
|
+
value={formatCountShort(statusBar()!.unused_exports)}
|
|
529
533
|
tone="muted"
|
|
530
534
|
/>
|
|
531
535
|
) : null}
|
|
532
|
-
{
|
|
536
|
+
{statusBar()!.duplicates !== undefined ? (
|
|
533
537
|
<R
|
|
534
538
|
theme={t()}
|
|
535
539
|
label="Duplicates"
|
|
536
|
-
value={formatCountShort(
|
|
540
|
+
value={formatCountShort(statusBar()!.duplicates)}
|
|
537
541
|
tone="muted"
|
|
538
542
|
/>
|
|
539
543
|
) : null}
|
|
540
|
-
{
|
|
544
|
+
{statusBar()!.todos !== undefined ? (
|
|
541
545
|
<R
|
|
542
546
|
theme={t()}
|
|
543
547
|
label="TODOs"
|
|
544
|
-
value={formatCountShort(
|
|
548
|
+
value={formatCountShort(statusBar()!.todos)}
|
|
545
549
|
tone="muted"
|
|
546
550
|
/>
|
|
547
551
|
) : null}
|
package/src/tui/sidebar-view.tsx
CHANGED
|
@@ -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}
|