@cortexkit/aft-opencode 0.57.2 → 0.58.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 (53) hide show
  1. package/dist/bash-wait-detach.d.ts +14 -5
  2. package/dist/bash-wait-detach.d.ts.map +1 -1
  3. package/dist/bridge-bootstrap.d.ts +191 -0
  4. package/dist/bridge-bootstrap.d.ts.map +1 -0
  5. package/dist/config-error-surface.d.ts +23 -0
  6. package/dist/config-error-surface.d.ts.map +1 -0
  7. package/dist/config.d.ts +66 -54
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/entry/server.js +9729 -7942
  10. package/dist/hooks/auto-update-checker/index.d.ts +6 -0
  11. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  12. package/dist/index.d.ts +3 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +9515 -7793
  15. package/dist/logger.d.ts.map +1 -1
  16. package/dist/lsp-auto-install.d.ts.map +1 -1
  17. package/dist/rpc/register.d.ts +7 -1
  18. package/dist/rpc/register.d.ts.map +1 -1
  19. package/dist/shared/status.d.ts +45 -0
  20. package/dist/shared/status.d.ts.map +1 -1
  21. package/dist/shared/subagent-detect.d.ts.map +1 -1
  22. package/dist/shutdown-hooks.d.ts +3 -9
  23. package/dist/shutdown-hooks.d.ts.map +1 -1
  24. package/dist/subc-tool-schemas.d.ts +9 -1
  25. package/dist/subc-tool-schemas.d.ts.map +1 -1
  26. package/dist/tool-registration.d.ts +19 -12
  27. package/dist/tool-registration.d.ts.map +1 -1
  28. package/dist/tools/ast.d.ts.map +1 -1
  29. package/dist/tools/bash.d.ts +1 -1
  30. package/dist/tools/bash.d.ts.map +1 -1
  31. package/dist/tools/bash_watch.d.ts.map +1 -1
  32. package/dist/tools/definitions/v2.d.ts.map +1 -1
  33. package/dist/tools/hoisted.d.ts +9 -12
  34. package/dist/tools/hoisted.d.ts.map +1 -1
  35. package/dist/tools/inspect.d.ts +5 -7
  36. package/dist/tools/inspect.d.ts.map +1 -1
  37. package/dist/tools/reading.d.ts.map +1 -1
  38. package/dist/tools/search.d.ts.map +1 -1
  39. package/dist/tui/v2-status.d.ts +1 -1
  40. package/dist/tui/v2-status.d.ts.map +1 -1
  41. package/dist/workflow-hints.d.ts +1 -7
  42. package/dist/workflow-hints.d.ts.map +1 -1
  43. package/package.json +8 -8
  44. package/src/entry/server-runtime.mjs +117 -33
  45. package/src/logger.ts +5 -6
  46. package/src/shared/status.ts +83 -4
  47. package/src/shared/subagent-detect.ts +56 -1
  48. package/src/tui/index.tsx +4 -10
  49. package/src/tui/sidebar-view.tsx +36 -15
  50. package/src/tui/v2-status.ts +25 -4
  51. package/src/tui-compiled/index.tsx +3 -3
  52. package/src/tui-compiled/sidebar-view.tsx +168 -126
  53. package/src/tui-compiled/v2-status.ts +25 -4
@@ -1,19 +1,21 @@
1
- import {
2
- acquireBridge,
3
- findBinary,
4
- releaseBridge,
5
- resolveCortexKitStorageRoot,
6
- } from "@cortexkit/aft-bridge";
1
+ import { acquireBridge, releaseBridge } from "@cortexkit/aft-bridge";
7
2
  import { Effect } from "effect";
8
3
 
9
4
  import {
10
- buildConfigTierConfigureParams,
11
- loadAftConfig,
12
- resolveBridgePoolTransportOptions,
13
- } from "../config.js";
14
- import { debug, log } from "../logger.js";
5
+ applyToolSurfaceOverrides,
6
+ createProjectAcceptance,
7
+ createSharedPoolOptions,
8
+ defaultBridgeBootstrapDependencies,
9
+ prepareBridgeEnvironment,
10
+ reportHashlineDowngrade,
11
+ resolveBootstrapConfig,
12
+ unknownDisabledToolsReporter,
13
+ } from "../bridge-bootstrap.js";
14
+ import { resolveBridgePoolTransportOptions } from "../config.js";
15
+ import { buildConfigErrorToolMap } from "../config-error-surface.js";
16
+ import { debug, log, warn } from "../logger.js";
15
17
  import { resolvePluginVersion } from "../plugin-version.js";
16
- import { registerAftRpc } from "../rpc/register.js";
18
+ import { registerAftConfigErrorRpc, registerAftRpc } from "../rpc/register.js";
17
19
  import { hoistedV2ToolConsumers } from "../tools/hoisted/v2.js";
18
20
  import { createV2RuntimeConsumer } from "../wakes/runtime-consumer.js";
19
21
  import {
@@ -22,61 +24,134 @@ import {
22
24
  registerAftTools,
23
25
  } from "../tool-registration.js";
24
26
 
27
+ // The bridge environment (binary, storage migration, configure overrides, ONNX
28
+ // Runtime, LSP installs) comes from the bootstrap shared with the OpenCode 1
29
+ // entry; every one of those steps can be replaced here through overrides.
25
30
  const defaults = {
26
- buildConfigureParams: buildConfigTierConfigureParams,
31
+ ...defaultBridgeBootstrapDependencies,
27
32
  buildToolMap: buildAftToolDefinitions,
28
33
  registerTools: registerAftTools,
29
34
  registerRpc: registerAftRpc,
35
+ registerConfigErrorRpc: registerAftConfigErrorRpc,
30
36
  toolConsumers: (context) => ({
31
37
  ...hoistedV2ToolConsumers(context),
32
38
  ...createV2RuntimeConsumer(context),
33
39
  }),
34
40
  acquireBridge,
35
- loadConfig: loadAftConfig,
36
41
  releaseBridge,
37
- resolveBinary: findBinary,
38
42
  resolvePoolOptions: resolveBridgePoolTransportOptions,
39
- resolveStorageRoot: resolveCortexKitStorageRoot,
40
43
  resolveVersion: () => resolvePluginVersion(import.meta.url),
41
44
  };
42
45
 
43
46
  async function bootLocation(context, location, dependencies) {
44
47
  const directory = location.directory;
45
- const config = dependencies.loadConfig(directory);
46
- if (config.enabled === false) return undefined;
48
+ // The V2 host has no session UI to deliver startup warnings into, so they
49
+ // go to the plugin log.
50
+ const notify = (message) => warn(message);
51
+ const bootstrap = await resolveBootstrapConfig(directory, notify, dependencies);
52
+ if (!bootstrap.ok) {
53
+ // The config error state: register the tool surface with every call
54
+ // failing, and acquire no bridge.
55
+ log(`AFT is in the config error state for ${directory}; every tool call will fail`);
56
+ return {
57
+ configError: bootstrap.message,
58
+ consumers: {},
59
+ pool: null,
60
+ tools: buildConfigErrorToolMap(
61
+ bootstrap.config,
62
+ bootstrap.message,
63
+ context,
64
+ dependencies.buildToolMap,
65
+ ),
66
+ };
67
+ }
68
+ const config = bootstrap.config;
47
69
 
48
- const storageDir = dependencies.resolveStorageRoot();
49
- const configOverrides = dependencies.buildConfigureParams(directory, {
50
- bash_permissions: true,
51
- harness: "opencode",
52
- storage_dir: storageDir,
53
- });
54
- const binaryPath = await dependencies.resolveBinary(dependencies.resolveVersion());
70
+ const pluginVersion = dependencies.resolveVersion();
71
+ const environment = await prepareBridgeEnvironment(
72
+ { configRoot: directory, lspDirectory: directory, config, pluginVersion, notify },
73
+ dependencies,
74
+ );
55
75
  const canonicalDirectory = location.project?.canonical ?? directory;
56
76
  const consumers = dependencies.toolConsumers(context);
77
+ // Only a rejected configuration keeps AFT out of a project; there is no
78
+ // config switch that turns it off.
79
+ const isProjectEnabled = createProjectAcceptance(directory, dependencies);
80
+ // getPool is only called on a version mismatch, after the pool exists.
57
81
  const pool = await dependencies.acquireBridge(canonicalDirectory, {
58
82
  harness: "opencode",
59
- binaryPath,
83
+ binaryPath: environment.binaryPath,
60
84
  poolOptions: {
61
85
  ...dependencies.resolvePoolOptions(config),
86
+ ...createSharedPoolOptions({
87
+ pluginVersion,
88
+ getPool: () => pool,
89
+ isProjectEnabled,
90
+ notifyForRoot: (_projectRoot, message) => notify(message),
91
+ dependencies,
92
+ }),
62
93
  ...consumers.bridgeOptions,
63
94
  },
64
- configOverrides,
95
+ configOverrides: environment.configOverrides,
65
96
  subcConnectionFile: config.subc?.connection_file,
66
97
  });
98
+ environment.attach(pool);
67
99
  const toolContext = {
68
100
  pool,
69
101
  client: context,
70
102
  config,
71
103
  hashlineEffective: openCodeHashlineEffective(config),
72
- storageDir,
73
- isProjectEnabled: (projectRoot) =>
74
- projectRoot === directory ? true : dependencies.loadConfig(projectRoot).enabled !== false,
104
+ storageDir: environment.storageDir,
105
+ isProjectEnabled,
75
106
  };
76
- const tools = dependencies.buildToolMap(toolContext, config);
107
+ const tools = dependencies.buildToolMap(
108
+ toolContext,
109
+ config,
110
+ unknownDisabledToolsReporter(notify),
111
+ );
112
+ const registeredTools = new Set(Object.keys(tools));
113
+ const { hashlineEditRegistered } = applyToolSurfaceOverrides(pool, config, registeredTools);
114
+ reportHashlineDowngrade(config, registeredTools, notify);
115
+ toolContext.hashlineEffective = hashlineEditRegistered;
77
116
  return { consumers, pool, tools };
78
117
  }
79
118
 
119
+ /**
120
+ * Locations whose runtime is running in this process, by directory, so the
121
+ * start line can say when the host starts a second Location for a directory
122
+ * that already has one.
123
+ */
124
+ const runningByDirectory = new Map();
125
+ let startsInProcess = 0;
126
+
127
+ /**
128
+ * The start line names the entry, the Location and how many starts this
129
+ * process has seen. The V2 host calls the server effect once per Location and
130
+ * again when it reloads one, so two start lines on one host launch are two
131
+ * Locations or a reload; with the Location and the matching "stopped" line in
132
+ * the log, a reader can tell which instead of seeing the same line twice.
133
+ */
134
+ function logRuntimeStart(location) {
135
+ startsInProcess += 1;
136
+ const directory = location.directory;
137
+ const running = runningByDirectory.get(directory) ?? 0;
138
+ runningByDirectory.set(directory, running + 1);
139
+ const id = typeof location.id === "string" ? location.id : "(no id)";
140
+ const overlap =
141
+ running > 0
142
+ ? `; ${running} other Location(s) for this directory still running`
143
+ : "";
144
+ log(
145
+ `AFT V2 runtime starting (server entry, Location ${id} at ${directory}; start ${startsInProcess} in this process${overlap})`,
146
+ );
147
+ return () => {
148
+ const left = (runningByDirectory.get(directory) ?? 1) - 1;
149
+ if (left > 0) runningByDirectory.set(directory, left);
150
+ else runningByDirectory.delete(directory);
151
+ log(`AFT V2 runtime stopped (server entry, Location ${id} at ${directory})`);
152
+ };
153
+ }
154
+
80
155
  export function makeServerEffect(overrides = {}) {
81
156
  const dependencies = { ...defaults, ...overrides };
82
157
 
@@ -93,12 +168,21 @@ export function makeServerEffect(overrides = {}) {
93
168
  return Effect.void;
94
169
  }
95
170
 
96
- log("AFT V2 runtime starting");
97
-
98
171
  return Effect.gen(function* () {
172
+ // Logged when the program runs, so every start pairs with the stop its
173
+ // finalizer logs when the host disposes the Location.
174
+ const logRuntimeStop = logRuntimeStart(location);
175
+ yield* Effect.addFinalizer(() => Effect.sync(logRuntimeStop));
99
176
  const runtime = yield* Effect.promise(() => bootLocation(context, location, dependencies));
100
177
  if (!runtime) return;
101
178
 
179
+ if (runtime.configError !== undefined) {
180
+ const rpc = yield* dependencies.registerConfigErrorRpc(context, runtime.configError);
181
+ yield* Effect.addFinalizer(() => Effect.promise(() => rpc.dispose()));
182
+ yield* dependencies.registerTools(context, location, runtime.tools, runtime.consumers);
183
+ return;
184
+ }
185
+
102
186
  yield* Effect.addFinalizer(() =>
103
187
  Effect.promise(async () => {
104
188
  runtime.consumers.dispose?.();
package/src/logger.ts CHANGED
@@ -1,12 +1,11 @@
1
- import { RotatingLogSink, resolveAftLogPath } from "@cortexkit/aft-bridge";
1
+ import { RotatingLogSink, resolvePluginLogPath } from "@cortexkit/aft-bridge";
2
2
 
3
3
  const TAG = "[aft-plugin]";
4
4
 
5
- // Route test runs to a separate log file so `bun test` never pollutes the
6
- // live session log that users read to diagnose problems. Bun sets BUN_TEST=1
7
- // automatically; NODE_ENV=test covers other test harnesses.
8
- const isTestEnv = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
9
- const logFile = resolveAftLogPath(isTestEnv ? "aft-plugin-test.log" : "aft-plugin.log");
5
+ // Test runs log to aft-plugin-test.log under the system temp directory, so
6
+ // `bun test` never writes into the live log directory users read to diagnose
7
+ // problems.
8
+ const logFile = resolvePluginLogPath();
10
9
  const fileSink = new RotatingLogSink(logFile);
11
10
 
12
11
  /**
@@ -12,6 +12,31 @@ import {
12
12
  */
13
13
  export { formatSemanticIndexStatus, type SemanticIndexStatusKind, semanticIndexStatusKind };
14
14
 
15
+ /**
16
+ * What the sidebar and status dialog show before AFT has started for this
17
+ * project. AFT starts on the first tool call, so until then there is nothing
18
+ * to report beyond that fact.
19
+ */
20
+ export const NOT_STARTED_STATUS_TEXT = "Starts on the first tool call.";
21
+
22
+ /**
23
+ * The status label for a snapshot's semantic index, including the backend URL
24
+ * and reason when the embedding backend is unavailable. Every surface that
25
+ * shows the semantic status goes through this so none of them can drop the
26
+ * reason and fall back to a bare word.
27
+ */
28
+ export function formatSemanticIndexLabel(
29
+ semantic: Pick<
30
+ AftStatusSnapshot["semantic_index"],
31
+ "status" | "stage" | "error" | "reason" | "backend_url"
32
+ >,
33
+ ): string {
34
+ return formatSemanticIndexStatus(semantic.status, semantic.stage, semantic.error, {
35
+ reason: semantic.reason,
36
+ backendUrl: semantic.backend_url,
37
+ });
38
+ }
39
+
15
40
  export interface StatusCompressionAggregate {
16
41
  events: number;
17
42
  original_tokens: number;
@@ -37,6 +62,12 @@ export interface StatusBar {
37
62
  duplicates?: number;
38
63
  todos?: number;
39
64
  tier2_stale?: boolean;
65
+ /**
66
+ * `"no_language_server"` when no language server is running, so errors and
67
+ * warnings will not arrive until one starts. Absent while the counts are
68
+ * present or a running server has not reported yet.
69
+ */
70
+ diagnostics?: "no_language_server";
40
71
  }
41
72
 
42
73
  export interface AftStatusSnapshot {
@@ -80,6 +111,10 @@ export interface AftStatusSnapshot {
80
111
  entries: number | null;
81
112
  dimension: number | null;
82
113
  error?: string | null;
114
+ /** Engine's reason when `status === "backend_unavailable"`. */
115
+ reason?: string | null;
116
+ /** Configured remote embedding backend URL, when there is one. */
117
+ backend_url?: string | null;
83
118
  };
84
119
  disk: {
85
120
  storage_dir: string | null;
@@ -125,6 +160,12 @@ export interface AftStatusSnapshot {
125
160
  * Empty string when the snapshot is real bridge data.
126
161
  */
127
162
  message: string;
163
+ /**
164
+ * Present only when the plugin is in the config error state: its
165
+ * configuration could not be used, so no bridge runs and every tool call
166
+ * fails with this text. `message` then carries its one-line form.
167
+ */
168
+ config_error?: string;
128
169
  }
129
170
 
130
171
  function asRecord(value: unknown): Record<string, unknown> {
@@ -179,13 +220,15 @@ function readStatusBar(value: unknown): StatusBar | undefined {
179
220
  const unusedExports = readOptionalNumber(bar.unused_exports);
180
221
  const duplicates = readOptionalNumber(bar.duplicates);
181
222
  const todos = readOptionalNumber(bar.todos);
223
+ const noLanguageServer = bar.diagnostics === "no_language_server";
182
224
  if (
183
225
  errors === null &&
184
226
  warnings === null &&
185
227
  deadCode === null &&
186
228
  unusedExports === null &&
187
229
  duplicates === null &&
188
- todos === null
230
+ todos === null &&
231
+ !noLanguageServer
189
232
  ) {
190
233
  return undefined;
191
234
  }
@@ -197,6 +240,7 @@ function readStatusBar(value: unknown): StatusBar | undefined {
197
240
  ...(duplicates !== null ? { duplicates } : {}),
198
241
  ...(todos !== null ? { todos } : {}),
199
242
  ...(bar.tier2_stale === true ? { tier2_stale: true } : {}),
243
+ ...(noLanguageServer ? { diagnostics: "no_language_server" as const } : {}),
200
244
  };
201
245
  }
202
246
 
@@ -297,6 +341,8 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
297
341
  entries: readOptionalNumber(semanticIndex.entries),
298
342
  dimension: readOptionalNumber(semanticIndex.dimension),
299
343
  error: readNullableString(semanticIndex.error),
344
+ reason: readNullableString(semanticIndex.reason),
345
+ backend_url: readNullableString(semanticIndex.backend_url),
300
346
  },
301
347
  disk: {
302
348
  storage_dir: readNullableString(disk.storage_dir),
@@ -324,6 +370,9 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
324
370
  status_bar: readStatusBar(response.status_bar),
325
371
  status_bar_values: readStatusBar(response.status_bar_values),
326
372
  message: readString(response.message, ""),
373
+ ...(typeof response.config_error === "string" && response.config_error.length > 0
374
+ ? { config_error: response.config_error }
375
+ : {}),
327
376
  };
328
377
  }
329
378
 
@@ -356,7 +405,7 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
356
405
  `- trigrams: ${formatCount(status.search_index.trigrams)}`,
357
406
  "",
358
407
  "Semantic index",
359
- `- status: ${formatSemanticIndexStatus(status.semantic_index.status, status.semantic_index.stage, status.semantic_index.error)}`,
408
+ `- status: ${formatSemanticIndexLabel(status.semantic_index)}`,
360
409
  );
361
410
  const refreshing = formatSemanticRefreshing(status.semantic_index.refreshing_count);
362
411
  if (refreshing) {
@@ -431,9 +480,39 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
431
480
  return lines.join("\n");
432
481
  }
433
482
 
483
+ /**
484
+ * Opening words of the reason the engine reports when it has turned git off
485
+ * because Apple's developer tools are missing (MISSING_DEVELOPER_TOOLS_REASON
486
+ * in crates/aft/src/developer_tools.rs).
487
+ */
488
+ const GIT_OFF_REASON_PREFIX = "git features are off";
489
+
490
+ /**
491
+ * How a surface should head the degraded state. A Mac without developer tools
492
+ * is the normal state of a fresh machine and everything except git-backed
493
+ * features works, so that case gets a calm warning mark naming what is off
494
+ * instead of a capitalised DEGRADED next to the product name, which read as
495
+ * broken. Any other reason (home directory as project root, too many files)
496
+ * switches off heavy features and keeps the stronger wording.
497
+ */
498
+ export function degradedHeaderMarker(reasons: readonly string[]): {
499
+ /** Short header text: "⚠ git features off" or "DEGRADED". */
500
+ text: string;
501
+ /** Section heading for the status dialog. */
502
+ heading: string;
503
+ /** True for the git-only case, drawn as plain warning text rather than a badge. */
504
+ calm: boolean;
505
+ } {
506
+ if (reasons.length > 0 && reasons.every((reason) => reason.startsWith(GIT_OFF_REASON_PREFIX))) {
507
+ return { text: "⚠ git features off", heading: "Git features off", calm: true };
508
+ }
509
+ return { text: "DEGRADED", heading: "Degraded mode", calm: false };
510
+ }
511
+
434
512
  function appendDegradedStatus(lines: string[], status: AftStatusSnapshot, markdown: boolean): void {
435
513
  if (!status.degraded || status.degraded_reasons.length === 0) return;
436
- lines.push("", markdown ? "### Degraded mode" : "Degraded mode");
514
+ const { heading } = degradedHeaderMarker(status.degraded_reasons);
515
+ lines.push("", markdown ? `### ${heading}` : heading);
437
516
  for (const reason of status.degraded_reasons) {
438
517
  const detail =
439
518
  reason === "home_root"
@@ -467,7 +546,7 @@ export function formatStatusMarkdown(status: AftStatusSnapshot): string {
467
546
  `- **Trigrams:** ${formatCount(status.search_index.trigrams)}`,
468
547
  "",
469
548
  "### Semantic index",
470
- `- **Status:** \`${formatSemanticIndexStatus(status.semantic_index.status, status.semantic_index.stage, status.semantic_index.error)}\``,
549
+ `- **Status:** \`${formatSemanticIndexLabel(status.semantic_index)}\``,
471
550
  );
472
551
  const refreshing = formatSemanticRefreshing(status.semantic_index.refreshing_count);
473
552
  if (refreshing) {
@@ -15,7 +15,8 @@
15
15
  * 2. Disable auto-promotion: the foreground poll window extends to the
16
16
  * task's full hard-kill timeout instead of the default 5s.
17
17
  *
18
- * Detection is via the OpenCode SDK `client.session.get`. The result is
18
+ * Detection is via the OpenCode SDK `client.session.get` on OpenCode 1 and
19
+ * via the plugin context's `session.get` Effect on OpenCode 2. The result is
19
20
  * cached per sessionID for the lifetime of the plugin process — subagent
20
21
  * identity is sticky (parentID never changes after session creation), so
21
22
  * the cache never needs invalidation.
@@ -27,6 +28,8 @@
27
28
  *
28
29
  * Mirrors the pattern in `session-directory.ts`.
29
30
  */
31
+ import { Effect } from "effect";
32
+
30
33
  import { sessionLog, sessionWarn } from "../logger.js";
31
34
 
32
35
  interface SessionInfo {
@@ -79,6 +82,8 @@ export async function resolveIsSubagent(
79
82
  return cached.isSubagent;
80
83
  }
81
84
 
85
+ if (isV2PluginContext(client)) return resolveV2(client, sessionId);
86
+
82
87
  const c = client as OpenCodeClientShape;
83
88
  const sessionApi = c?.session;
84
89
  if (!sessionApi || typeof sessionApi.get !== "function") {
@@ -135,6 +140,56 @@ export async function resolveIsSubagent(
135
140
  return isSubagent;
136
141
  }
137
142
 
143
+ /**
144
+ * The OpenCode 2 runtime passes its plugin context where the V1 plugin passes
145
+ * the SDK client (see `entry/server-runtime.mjs`). That context has no SDK
146
+ * client at all; its `session.get` takes `{ sessionID }` and returns an Effect
147
+ * that resolves to the bare session record. Calling it the V1 way builds an
148
+ * Effect that never runs, and awaiting that Effect yields the Effect itself, so
149
+ * no `parentID` is ever read and every OpenCode 2 session would be cached as
150
+ * primary. The context is recognised by its `location`, the same capability
151
+ * the runtime checks before booting.
152
+ */
153
+ interface V2PluginContextShape {
154
+ location: { directory: string };
155
+ session: {
156
+ get(input: { sessionID: string }): Effect.Effect<SessionInfo | undefined, unknown>;
157
+ };
158
+ }
159
+
160
+ function isV2PluginContext(client: unknown): client is V2PluginContextShape {
161
+ if (!client || typeof client !== "object") return false;
162
+ const candidate = client as { location?: { directory?: unknown }; session?: { get?: unknown } };
163
+ return (
164
+ typeof candidate.location?.directory === "string" &&
165
+ typeof candidate.session?.get === "function"
166
+ );
167
+ }
168
+
169
+ async function resolveV2(context: V2PluginContextShape, sessionId: string): Promise<boolean> {
170
+ let session: SessionInfo | undefined;
171
+ try {
172
+ session = await Effect.runPromise(
173
+ context.session.get({ sessionID: sessionId }) as Effect.Effect<SessionInfo | undefined>,
174
+ );
175
+ } 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.
178
+ sessionWarn(
179
+ sessionId,
180
+ `[subagent-detect] OpenCode 2 session lookup failed: ${err instanceof Error ? err.message : String(err)}`,
181
+ );
182
+ return false;
183
+ }
184
+ const isSubagent = typeof session?.parentID === "string" && session.parentID.length > 0;
185
+ sessionLog(
186
+ sessionId,
187
+ `[subagent-detect] OpenCode 2 session parentID=${JSON.stringify(session?.parentID)} → isSubagent=${isSubagent}`,
188
+ );
189
+ setCache(sessionId, isSubagent);
190
+ return isSubagent;
191
+ }
192
+
138
193
  function setCache(sessionId: string, isSubagent: boolean): void {
139
194
  if (cache.has(sessionId)) cache.delete(sessionId);
140
195
  cache.set(sessionId, { isSubagent, recordedAt: Date.now() });
package/src/tui/index.tsx CHANGED
@@ -11,8 +11,9 @@ import {
11
11
  coerceAftStatus,
12
12
  formatBytes,
13
13
  formatCacheRoleLabel,
14
- formatSemanticIndexStatus,
14
+ formatSemanticIndexLabel,
15
15
  formatSemanticRefreshing,
16
+ NOT_STARTED_STATUS_TEXT,
16
17
  } from "../shared/status";
17
18
  import {
18
19
  createDebouncedStatusRefresh,
@@ -265,10 +266,7 @@ const StatusDialog = (props: StatusDialogProps) => {
265
266
  instead of an empty grid of zeros and "unknown" rows. */}
266
267
  {status()?.cache_role === "not_initialized" ? (
267
268
  <box width="100%" marginTop={1} justifyContent="center">
268
- <text fg={t().textMuted}>
269
- {status()!.message ||
270
- "AFT bridge is now spawned lazily, information here will be populated after first tool call."}
271
- </text>
269
+ <text fg={t().textMuted}>{status()!.message || NOT_STARTED_STATUS_TEXT}</text>
272
270
  </box>
273
271
  ) : null}
274
272
 
@@ -380,11 +378,7 @@ const StatusDialog = (props: StatusDialogProps) => {
380
378
  <R
381
379
  theme={t()}
382
380
  label="Status"
383
- value={formatSemanticIndexStatus(
384
- status()!.semantic_index.status,
385
- status()!.semantic_index.stage,
386
- status()!.semantic_index.error,
387
- )}
381
+ value={formatSemanticIndexLabel(status()!.semantic_index)}
388
382
  tone={statusTone(status()!.semantic_index.status)}
389
383
  />
390
384
  {formatSemanticRefreshing(status()!.semantic_index.refreshing_count) ? (
@@ -14,8 +14,10 @@
14
14
  import { createMemo, createSignal, onCleanup } from "solid-js";
15
15
  import {
16
16
  type AftStatusSnapshot,
17
- formatSemanticIndexStatus,
17
+ degradedHeaderMarker,
18
+ formatSemanticIndexLabel,
18
19
  formatSemanticRefreshing,
20
+ NOT_STARTED_STATUS_TEXT,
19
21
  type StatusBar,
20
22
  type StatusCompression,
21
23
  semanticIndexStatusKind,
@@ -215,10 +217,16 @@ const StatRow = (props: {
215
217
  }
216
218
  });
217
219
 
220
+ // The label never shrinks and the value wraps in the space left beside it:
221
+ // left to flexbox, a long value (a semantic index that is unavailable, with
222
+ // its reason) squeezed the label and drew over it, so "Status" came out as
223
+ // "Statu" followed by the value.
218
224
  return (
219
225
  <box width="100%" flexDirection="row" justifyContent="space-between">
220
- <text fg={props.palette.textMuted}>{props.label}</text>
221
- <text fg={fg()}>
226
+ <text fg={props.palette.textMuted} flexShrink={0}>
227
+ {props.label}
228
+ </text>
229
+ <text fg={fg()} flexShrink={1} marginLeft={1} wrapMode="word">
222
230
  <b>{props.value}</b>
223
231
  </text>
224
232
  </box>
@@ -397,12 +405,15 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
397
405
  return {
398
406
  ...display,
399
407
  // The error is passed through so a capability failure (a missing ONNX
400
- // Runtime) is named here instead of arriving as a bare status word.
401
- label: formatSemanticIndexStatus(
402
- rawStatus,
403
- s()?.semantic_index?.stage,
404
- s()?.semantic_index?.error,
405
- ),
408
+ // Runtime) is named here instead of arriving as a bare status word, and
409
+ // an unreachable backend carries its URL and reason.
410
+ label: formatSemanticIndexLabel({
411
+ status: rawStatus,
412
+ stage: s()?.semantic_index?.stage,
413
+ error: s()?.semantic_index?.error,
414
+ reason: s()?.semantic_index?.reason,
415
+ backend_url: s()?.semantic_index?.backend_url,
416
+ }),
406
417
  };
407
418
  };
408
419
  const semanticRefreshing = () =>
@@ -459,7 +470,14 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
459
470
  </b>
460
471
  </text>
461
472
  </box>
462
- {s()?.degraded && (
473
+ {s()?.degraded && degradedHeaderMarker(s()?.degraded_reasons ?? []).calm && (
474
+ <box marginLeft={1}>
475
+ <text fg={props.palette.warning}>
476
+ {degradedHeaderMarker(s()?.degraded_reasons ?? []).text}
477
+ </text>
478
+ </box>
479
+ )}
480
+ {s()?.degraded && !degradedHeaderMarker(s()?.degraded_reasons ?? []).calm && (
463
481
  <box
464
482
  paddingLeft={1}
465
483
  paddingRight={1}
@@ -467,7 +485,7 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
467
485
  backgroundColor={props.palette.warning}
468
486
  >
469
487
  <text fg={badgeForeground(props.palette.warning, props.palette.background)}>
470
- <b>DEGRADED</b>
488
+ <b>{degradedHeaderMarker(s()?.degraded_reasons ?? []).text}</b>
471
489
  </text>
472
490
  </box>
473
491
  )}
@@ -501,10 +519,13 @@ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
501
519
  rows so users understand why metrics are blank. */}
502
520
  {notInitialized() && (
503
521
  <box marginTop={1} width="100%">
504
- <text fg={props.palette.textMuted}>
505
- {s()!.message ||
506
- "AFT bridge is now spawned lazily, information here will be populated after first tool call."}
507
- </text>
522
+ {s()!.config_error ? (
523
+ // Config error state: no bridge will ever start in this process,
524
+ // so the placeholder text would mislead; show the error instead.
525
+ <text fg={props.palette.error}>✖ {s()!.message}</text>
526
+ ) : (
527
+ <text fg={props.palette.textMuted}>{s()!.message || NOT_STARTED_STATUS_TEXT}</text>
528
+ )}
508
529
  </box>
509
530
  )}
510
531
 
@@ -1,9 +1,25 @@
1
- import type { AftStatusSnapshot, StatusBar } from "../shared/status";
1
+ import { type AftStatusSnapshot, degradedHeaderMarker, type StatusBar } from "../shared/status";
2
2
 
3
3
  function count(value: number | undefined): string {
4
4
  return value === undefined ? "?" : String(value);
5
5
  }
6
6
 
7
+ /**
8
+ * The errors/warnings part of the footer. With no language server running the
9
+ * counts will never arrive, so the footer says that instead of showing `E? W?`
10
+ * indefinitely.
11
+ */
12
+ function diagnosticsPart(bar: StatusBar): string {
13
+ if (
14
+ bar.errors === undefined &&
15
+ bar.warnings === undefined &&
16
+ bar.diagnostics === "no_language_server"
17
+ ) {
18
+ return "no LSP";
19
+ }
20
+ return `E${count(bar.errors)} W${count(bar.warnings)}`;
21
+ }
22
+
7
23
  /**
8
24
  * The V2 footer segment. "AFT starting…" is only true before the bridge has
9
25
  * delivered a real snapshot. After that the footer shows every health category
@@ -12,11 +28,14 @@ function count(value: number | undefined): string {
12
28
  * can leave the complete `status_bar` missing for the whole session.
13
29
  */
14
30
  export function formatAftStatusSegment(status: AftStatusSnapshot | null): string {
31
+ // The config error state never produces a real snapshot; say so instead of
32
+ // "starting", which would suggest the bridge is on its way.
33
+ if (status?.config_error) return "AFT config error";
15
34
  if (!status || status.cache_role === "not_initialized") return "AFT starting…";
16
35
  const bar: StatusBar = status.status_bar ?? status.status_bar_values ?? {};
17
36
  const stale = bar.tier2_stale ? "~" : "";
18
37
  return (
19
- `AFT E${count(bar.errors)} W${count(bar.warnings)} | ` +
38
+ `AFT ${diagnosticsPart(bar)} | ` +
20
39
  `${stale}D${count(bar.dead_code)} U${count(bar.unused_exports)} ` +
21
40
  `C${count(bar.duplicates)} | T${count(bar.todos)}`
22
41
  );
@@ -33,7 +52,7 @@ function healthSummary(bar: StatusBar | undefined): string {
33
52
  if (!bar) return "health pending";
34
53
  const stale = bar.tier2_stale ? "~" : "";
35
54
  return (
36
- `E${count(bar.errors)} W${count(bar.warnings)} ` +
55
+ `${diagnosticsPart(bar)} ` +
37
56
  `${stale}D${count(bar.dead_code)} U${count(bar.unused_exports)} ` +
38
57
  `C${count(bar.duplicates)} T${count(bar.todos)}`
39
58
  );
@@ -61,7 +80,9 @@ export function summarizeAftSidebar(status: AftStatusSnapshot | null): AftSideba
61
80
  ? `${status.semantic_index.status} (${status.semantic_index.stage})`
62
81
  : status.semantic_index.status;
63
82
  return {
64
- title: status.degraded ? "AFT · DEGRADED" : "AFT",
83
+ title: status.degraded
84
+ ? `AFT · ${degradedHeaderMarker(status.degraded_reasons ?? []).text}`
85
+ : "AFT",
65
86
  version: status.version || undefined,
66
87
  search: status.search_index.status,
67
88
  semantic,